mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:34:39 +08:00
Compare commits
74 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1284d5d052 | |||
| 2402c70d6f | |||
| 2fcfcad613 | |||
| bacfe1ef4b | |||
| 7ff9474a5d | |||
| d7531f9305 | |||
| 845657e3c2 | |||
| 2fca031362 | |||
| 1f8fb7acf4 | |||
| 5ae02054ee | |||
| 5ac760d196 | |||
| c04be0b263 | |||
| a51d0fb605 | |||
| 885ba9f6f9 | |||
| c7ee5cf0df | |||
| b6f92968d0 | |||
| d4b260c160 | |||
| 0da5f51e2e | |||
| fe7419709e | |||
| a800b026b3 | |||
| f6d5765d81 | |||
| 8affea2508 | |||
| f2be702a15 | |||
| fab8b315a5 | |||
| 4175bd520f | |||
| b71aa89a45 | |||
| d22d5160d7 | |||
| d893351c5a | |||
| 57d0f931b5 | |||
| 51f264bca2 | |||
| b23b1ba194 | |||
| f0499c32f6 | |||
| 797ecbb992 | |||
| 1c451b7722 | |||
| 3252e4583c | |||
| 5240756f96 | |||
| 8b55a3d9ab | |||
| 7b5a683557 | |||
| ceffe59234 | |||
| ec507965b2 | |||
| 2b43ce6a3e | |||
| cbaf02c769 | |||
| 13b671cc9f | |||
| e603a55cb3 | |||
| f3e2762f30 | |||
| d3002dbfde | |||
| 33e2dc4f13 | |||
| 94b602613f | |||
| 139715efb5 | |||
| cd49519058 | |||
| 6c8ea56e8d | |||
| ed92aeb1e6 | |||
| f196bfaf6d | |||
| aa2b8b1b23 | |||
| d1d44d0914 | |||
| 46f5780057 | |||
| 69d3b76512 | |||
| f99103ede6 | |||
| 155e535b8a | |||
| f2942e9c9e | |||
| 0aaba8e1f9 | |||
| 1391ddca07 | |||
| bfaafb4b35 | |||
| da0170d2c0 | |||
| e911a2d469 | |||
| 5eda6ed127 | |||
| 00fc944da8 | |||
| 01439507d5 | |||
| 8fbd789dfb | |||
| 06a10e2df8 | |||
| 1f1c2398b6 | |||
| 57bf3cfc67 | |||
| 8eb311e54b | |||
| a693d281f8 |
134 changed files with 12560 additions and 2226 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.
|
||||
99
.github/workflows/ci.yml
vendored
99
.github/workflows/ci.yml
vendored
|
|
@ -2,19 +2,32 @@ name: CI
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, 'release/**' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ main, 'release/**' ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
pr-gate:
|
||||
name: PR gate (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-24.04, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
|
|
@ -39,11 +52,26 @@ jobs:
|
|||
- name: Run release preflight
|
||||
run: make release-check
|
||||
|
||||
extended-linux-runtime:
|
||||
name: Extended Linux runtime
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y expect libssh-dev valgrind
|
||||
|
||||
- name: Run extended release preflight
|
||||
run: |
|
||||
RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
|
||||
|
||||
- name: Check for memory leaks
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
set -eu
|
||||
sudo apt-get install -y valgrind
|
||||
STATE_DIR=$(mktemp -d)
|
||||
SERVER_LOG="$STATE_DIR/server.log"
|
||||
VALGRIND_LOG="$STATE_DIR/valgrind.log"
|
||||
|
|
@ -98,3 +126,60 @@ jobs:
|
|||
cat "$VALGRIND_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
portable-container-builds:
|
||||
name: Portable build (${{ matrix.name }})
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: debian-stable-glibc
|
||||
image: debian:stable-slim
|
||||
setup: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential libssh-dev ca-certificates
|
||||
- name: ubuntu-24.04-glibc
|
||||
image: ubuntu:24.04
|
||||
setup: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential libssh-dev ca-certificates
|
||||
- name: alpine-musl
|
||||
image: alpine:3.20
|
||||
setup: apk add --no-cache build-base libssh-dev ca-certificates
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build in container
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/src:ro" "${{ matrix.image }}" sh -c '
|
||||
set -eu
|
||||
${{ matrix.setup }}
|
||||
mkdir /work
|
||||
cp -R /src/. /work/
|
||||
cd /work
|
||||
make clean
|
||||
make
|
||||
./tnt --version
|
||||
./tntctl --version
|
||||
'
|
||||
|
||||
package-recipe-gate:
|
||||
name: Package recipe gate
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ruby cpio
|
||||
|
||||
- name: Validate packaging metadata
|
||||
run: |
|
||||
for script in scripts/*.sh; do
|
||||
sh -n "$script"
|
||||
done
|
||||
bash -n packaging/arch/PKGBUILD
|
||||
ruby -c packaging/homebrew/tnt-chat.rb
|
||||
scripts/package_debian_source.sh "$RUNNER_TEMP/debian-source"
|
||||
|
|
|
|||
148
.github/workflows/release.yml
vendored
148
.github/workflows/release.yml
vendored
|
|
@ -5,28 +5,42 @@ on:
|
|||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-24.04
|
||||
target: linux-amd64
|
||||
artifact: tnt-linux-amd64
|
||||
ctl_artifact: tntctl-linux-amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
target: linux-arm64
|
||||
artifact: tnt-linux-arm64
|
||||
ctl_artifact: tntctl-linux-arm64
|
||||
- os: macos-15-intel
|
||||
target: darwin-amd64
|
||||
artifact: tnt-darwin-amd64
|
||||
ctl_artifact: tntctl-darwin-amd64
|
||||
- os: macos-15
|
||||
target: darwin-arm64
|
||||
artifact: tnt-darwin-arm64
|
||||
ctl_artifact: tntctl-darwin-arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Verify release tag matches source version
|
||||
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
|
|
@ -48,97 +62,191 @@ jobs:
|
|||
- name: Verify artifact architecture
|
||||
run: |
|
||||
file tnt
|
||||
file tntctl
|
||||
case "${{ matrix.target }}" in
|
||||
linux-amd64)
|
||||
file tnt | grep -E 'ELF 64-bit.*x86-64'
|
||||
file tntctl | grep -E 'ELF 64-bit.*x86-64'
|
||||
;;
|
||||
linux-arm64)
|
||||
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||
file tntctl | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||
;;
|
||||
darwin-amd64)
|
||||
file tnt | grep -E 'Mach-O 64-bit.*x86_64'
|
||||
file tntctl | grep -E 'Mach-O 64-bit.*x86_64'
|
||||
;;
|
||||
darwin-arm64)
|
||||
file tnt | grep -E 'Mach-O 64-bit.*arm64'
|
||||
file tntctl | grep -E 'Mach-O 64-bit.*arm64'
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Rename binary
|
||||
run: mv tnt ${{ matrix.artifact }}
|
||||
run: |
|
||||
mv tnt ${{ matrix.artifact }}
|
||||
mv tntctl ${{ matrix.ctl_artifact }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: ${{ matrix.artifact }}
|
||||
path: |
|
||||
${{ matrix.artifact }}
|
||||
${{ matrix.ctl_artifact }}
|
||||
|
||||
source-archive:
|
||||
name: Source archive
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify release tag matches source version
|
||||
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Build source archive
|
||||
run: |
|
||||
archive=$(scripts/package_source_archive.sh "${GITHUB_REF_NAME}" dist)
|
||||
sha256sum "$archive"
|
||||
|
||||
- name: Upload source archive
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tnt-chat-source
|
||||
path: dist/tnt-chat-v*-source.tar.gz
|
||||
|
||||
artifact-gate:
|
||||
name: Release artifact gate
|
||||
needs: [build, source-archive]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Verify release tag matches source version
|
||||
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./artifacts
|
||||
|
||||
- name: Verify and package release assets
|
||||
run: scripts/package_release_assets.sh ./artifacts ./dist/release-assets
|
||||
|
||||
- name: Upload release asset bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-assets
|
||||
path: dist/release-assets/*
|
||||
|
||||
release:
|
||||
needs: build
|
||||
needs: [artifact-gate, source-archive]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download all artifacts
|
||||
- name: Verify release tag matches source version
|
||||
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Download release asset bundle
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./artifacts
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
|
||||
- name: Create checksums
|
||||
- name: Verify release checksums
|
||||
run: |
|
||||
cd artifacts
|
||||
: > checksums.txt
|
||||
for artifact in */tnt-*; do
|
||||
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
|
||||
done
|
||||
cd release-assets
|
||||
sha256sum -c checksums.txt
|
||||
cat checksums.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
artifacts/*/tnt-*
|
||||
artifacts/checksums.txt
|
||||
release-assets/*
|
||||
body: |
|
||||
## Installation
|
||||
|
||||
Install the libssh runtime before running TNT:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install libssh-4
|
||||
|
||||
# Arch
|
||||
sudo pacman -S libssh
|
||||
|
||||
# macOS
|
||||
brew install libssh
|
||||
```
|
||||
|
||||
Download the binary for your platform:
|
||||
|
||||
**Linux AMD64:**
|
||||
```bash
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-amd64
|
||||
chmod +x tnt-linux-amd64
|
||||
chmod +x tntctl-linux-amd64
|
||||
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
|
||||
sudo mv tntctl-linux-amd64 /usr/local/bin/tntctl
|
||||
```
|
||||
|
||||
**Linux ARM64:**
|
||||
```bash
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-arm64
|
||||
chmod +x tnt-linux-arm64
|
||||
chmod +x tntctl-linux-arm64
|
||||
sudo mv tnt-linux-arm64 /usr/local/bin/tnt
|
||||
sudo mv tntctl-linux-arm64 /usr/local/bin/tntctl
|
||||
```
|
||||
|
||||
**macOS Intel:**
|
||||
```bash
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-amd64
|
||||
chmod +x tnt-darwin-amd64
|
||||
chmod +x tntctl-darwin-amd64
|
||||
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
|
||||
sudo mv tntctl-darwin-amd64 /usr/local/bin/tntctl
|
||||
```
|
||||
|
||||
**macOS Apple Silicon:**
|
||||
```bash
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-arm64
|
||||
chmod +x tnt-darwin-arm64
|
||||
chmod +x tntctl-darwin-arm64
|
||||
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
|
||||
sudo mv tntctl-darwin-arm64 /usr/local/bin/tntctl
|
||||
```
|
||||
|
||||
**Verify checksums:**
|
||||
```bash
|
||||
sha256sum -c checksums.txt
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.txt
|
||||
|
||||
# Linux
|
||||
sha256sum -c checksums.txt --ignore-missing
|
||||
|
||||
# macOS
|
||||
for f in tnt-* tntctl-* tnt-chat-*-source.tar.gz; do
|
||||
grep " $f$" checksums.txt | shasum -a 256 -c -
|
||||
done
|
||||
```
|
||||
|
||||
The release also includes `tnt-chat-${{ github.ref_name }}-source.tar.gz`
|
||||
for package-manager recipes. Verify it with the same `checksums.txt`
|
||||
before updating Arch, Homebrew, Debian, Ubuntu, or container package
|
||||
metadata.
|
||||
|
||||
## What's Changed
|
||||
See [docs/CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/docs/CHANGELOG.md)
|
||||
draft: true
|
||||
|
|
|
|||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
*.o
|
||||
obj/
|
||||
tnt
|
||||
tntctl
|
||||
messages.log
|
||||
host_key
|
||||
host_key.pub
|
||||
|
|
@ -8,13 +9,20 @@ host_key.pub
|
|||
.DS_Store
|
||||
test.log
|
||||
*.dSYM/
|
||||
demos/*.gif
|
||||
demos/*.mp4
|
||||
demos/*.webm
|
||||
tests/unit/test_utf8
|
||||
tests/unit/test_message
|
||||
tests/unit/test_chat_room
|
||||
tests/unit/test_history_view
|
||||
tests/unit/test_i18n
|
||||
tests/unit/test_system_message
|
||||
tests/unit/test_command_catalog
|
||||
tests/unit/test_exec_catalog
|
||||
tests/unit/test_help_text
|
||||
tests/unit/test_manual_text
|
||||
tests/unit/test_support_text
|
||||
tests/unit/test_cli_text
|
||||
tests/unit/test_tntctl_text
|
||||
tests/unit/test_ratelimit
|
||||
|
|
|
|||
74
Makefile
74
Makefile
|
|
@ -4,6 +4,7 @@
|
|||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700
|
||||
LDFLAGS = -pthread -lssh
|
||||
CTL_LDFLAGS =
|
||||
INCLUDES = -Iinclude
|
||||
DEPFLAGS = -MMD -MP
|
||||
|
||||
|
|
@ -20,10 +21,13 @@ SRC_DIR = src
|
|||
INC_DIR = include
|
||||
OBJ_DIR = obj
|
||||
|
||||
SOURCES = $(wildcard $(SRC_DIR)/*.c)
|
||||
SOURCES = $(filter-out $(SRC_DIR)/tntctl.c $(SRC_DIR)/tntctl_text.c,$(wildcard $(SRC_DIR)/*.c))
|
||||
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
|
||||
DEPS = $(OBJECTS:.o=.d)
|
||||
DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d)
|
||||
TARGET = tnt
|
||||
CTL_TARGET = tntctl
|
||||
CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/tntctl_text.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/config_defaults.o $(OBJ_DIR)/i18n.o
|
||||
TARGETS = $(TARGET) $(CTL_TARGET)
|
||||
|
||||
PREFIX ?= /usr/local
|
||||
BINDIR ?= $(PREFIX)/bin
|
||||
|
|
@ -31,14 +35,18 @@ MANDIR ?= $(PREFIX)/share/man
|
|||
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
|
||||
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222)
|
||||
|
||||
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info
|
||||
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict package-publish-check debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test module-runtime-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info
|
||||
|
||||
all: $(TARGET)
|
||||
all: $(TARGETS)
|
||||
|
||||
$(TARGET): $(OBJECTS)
|
||||
$(CC) $(OBJECTS) -o $@ $(LDFLAGS)
|
||||
@echo "Build complete: $(TARGET)"
|
||||
|
||||
$(CTL_TARGET): $(CTL_OBJECTS)
|
||||
$(CC) $(CTL_OBJECTS) -o $@ $(CTL_LDFLAGS)
|
||||
@echo "Build complete: $(CTL_TARGET)"
|
||||
|
||||
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
|
||||
|
||||
|
|
@ -46,34 +54,40 @@ $(OBJ_DIR):
|
|||
mkdir -p $(OBJ_DIR)
|
||||
|
||||
clean:
|
||||
rm -rf $(OBJ_DIR) $(TARGET)
|
||||
rm -rf $(OBJ_DIR) $(TARGETS)
|
||||
rm -f tests/*.log tests/host_key* tests/messages.log
|
||||
@echo "Clean complete"
|
||||
|
||||
install: $(TARGET)
|
||||
install: $(TARGETS)
|
||||
install -d $(DESTDIR)$(BINDIR)
|
||||
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
|
||||
install -m 755 $(CTL_TARGET) $(DESTDIR)$(BINDIR)/
|
||||
install -d $(DESTDIR)$(MANDIR)/man1
|
||||
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
|
||||
install -m 644 tntctl.1 $(DESTDIR)$(MANDIR)/man1/
|
||||
|
||||
install-systemd:
|
||||
install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR)
|
||||
install -m 644 tnt.service $(DESTDIR)$(SYSTEMD_UNIT_DIR)/
|
||||
sed 's#^ExecStart=.*#ExecStart=$(BINDIR)/$(TARGET)#' tnt.service > "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
|
||||
chmod 644 "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
|
||||
rm -f $(DESTDIR)$(BINDIR)/$(CTL_TARGET)
|
||||
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
|
||||
rm -f $(DESTDIR)$(MANDIR)/man1/tntctl.1
|
||||
|
||||
uninstall-systemd:
|
||||
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
|
||||
|
||||
# Development targets
|
||||
debug: CFLAGS += -g -DDEBUG
|
||||
debug: clean $(TARGET)
|
||||
debug: clean $(TARGETS)
|
||||
|
||||
release: CFLAGS += -O3 -DNDEBUG
|
||||
release: clean $(TARGET)
|
||||
release: clean $(TARGETS)
|
||||
strip $(TARGET)
|
||||
strip $(CTL_TARGET)
|
||||
|
||||
release-check:
|
||||
./scripts/release_check.sh
|
||||
|
|
@ -81,9 +95,16 @@ release-check:
|
|||
release-check-strict:
|
||||
./scripts/release_check.sh --strict
|
||||
|
||||
package-publish-check:
|
||||
./scripts/package_publish_check.sh
|
||||
|
||||
debian-source-package:
|
||||
./scripts/package_debian_source.sh $${OUT_DIR:-dist/debian-source}
|
||||
|
||||
asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
|
||||
asan: LDFLAGS += -fsanitize=address
|
||||
asan: clean $(TARGET)
|
||||
asan: CTL_LDFLAGS += -fsanitize=address
|
||||
asan: clean $(TARGETS)
|
||||
@echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt"
|
||||
|
||||
valgrind: debug
|
||||
|
|
@ -95,23 +116,42 @@ check:
|
|||
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
|
||||
|
||||
# Test
|
||||
test: all unit-test integration-test
|
||||
test: all unit-test script-test integration-test
|
||||
|
||||
test-advisory: all unit-test
|
||||
@echo "Running integration tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh || echo "(basic integration tests are advisory)"
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh || echo "(exec mode tests are advisory)"
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh || echo "(interactive input tests are advisory)"
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 6)) ./test_module_runtime.sh || echo "(module runtime tests are advisory)"
|
||||
|
||||
unit-test:
|
||||
@echo "Running unit tests..."
|
||||
@$(MAKE) -C tests/unit run
|
||||
|
||||
script-test: all
|
||||
@echo "Running script tests..."
|
||||
@cd tests && ./test_cli_options.sh
|
||||
@cd tests && ./test_docs_help_surface.sh
|
||||
@cd tests && ./test_logrotate.sh
|
||||
@cd tests && ./test_message_log_tool.sh
|
||||
@cd tests && ./test_source_archive.sh
|
||||
@cd tests && ./test_release_artifact_gate.sh
|
||||
|
||||
integration-test: all
|
||||
@echo "Running integration tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 3)) ./test_user_lifecycle.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 4)) ./test_mute_joins_view.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 5)) ./test_empty_view.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 6)) ./test_module_runtime.sh
|
||||
@cd tests && ./test_tntctl_cli.sh
|
||||
|
||||
module-runtime-test: all
|
||||
@echo "Running module runtime tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_module_runtime.sh
|
||||
|
||||
anonymous-access-test: all
|
||||
@echo "Running anonymous access tests..."
|
||||
|
|
@ -129,6 +169,18 @@ stress-test: all
|
|||
@echo "Running stress tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_stress.sh $${CLIENTS:-10} $${DURATION:-30}
|
||||
|
||||
soak-test: all
|
||||
@echo "Running soak tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_soak.sh $${DURATION:-8} $${RECONNECTS:-5}
|
||||
|
||||
slow-client-test: all
|
||||
@echo "Running slow-client tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_slow_client.sh $${DURATION:-8} $${BURST_CHARS:-1600}
|
||||
|
||||
user-lifecycle-test: all
|
||||
@echo "Running user lifecycle tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_user_lifecycle.sh
|
||||
|
||||
ci-test:
|
||||
@$(MAKE) test PORT=$(CI_TEST_PORT)
|
||||
@$(MAKE) anonymous-access-test PORT=$$(($(CI_TEST_PORT) + 5))
|
||||
|
|
|
|||
152
README.md
152
README.md
|
|
@ -21,8 +21,9 @@ A minimalist terminal chat server with Vim-style interface over SSH.
|
|||
```sh
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
The installer verifies the downloaded release binary against `checksums.txt`
|
||||
before installing it.
|
||||
The installer verifies downloaded release binaries against `checksums.txt`
|
||||
before installing them. Older releases may provide only `tnt`; newer releases
|
||||
also install `tntctl`.
|
||||
|
||||
**From source:**
|
||||
```sh
|
||||
|
|
@ -47,9 +48,12 @@ PORT=3333 tnt # via env var
|
|||
### Connecting
|
||||
|
||||
```sh
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
ssh -p 2222 localhost
|
||||
```
|
||||
|
||||
For a deployed server, replace `localhost` with your public host, for example
|
||||
`chat.example.com`.
|
||||
|
||||
**Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
|
||||
|
||||
## Usage
|
||||
|
|
@ -74,7 +78,7 @@ past the limit is ignored with a terminal bell.
|
|||
```
|
||||
Opens at latest messages
|
||||
Stays pinned to latest until you scroll up
|
||||
i - Return to INSERT mode
|
||||
i/a/o - Return to INSERT mode
|
||||
: - Enter COMMAND mode
|
||||
j/k - Scroll down/up one line
|
||||
Ctrl+D/U - Scroll half page down/up
|
||||
|
|
@ -82,7 +86,7 @@ Ctrl+F/B - Scroll full page down/up
|
|||
PgDn/PgUp - Scroll full page down/up
|
||||
End/Home - Jump to bottom/top
|
||||
g/G - Jump to top/bottom
|
||||
? - Show help
|
||||
? - Show full key reference
|
||||
Ctrl+C - Exit chat
|
||||
```
|
||||
|
||||
|
|
@ -90,20 +94,33 @@ Ctrl+C - Exit chat
|
|||
```
|
||||
:list, :users - Show online users
|
||||
:nick <name> - Change nickname
|
||||
:msg <user> <text> - Whisper to user
|
||||
:msg <user> <message> - Send private message
|
||||
:w <user> <text> - Short alias for :msg
|
||||
:reply <text> - Reply to latest private message
|
||||
:r <text> - Short alias for :reply
|
||||
:inbox - Show private messages
|
||||
:inbox clear - Clear private messages for this session
|
||||
:last [N] - Show last N messages from history (max 50, default 10)
|
||||
:search <keyword> - Search full message history (case-insensitive)
|
||||
:search <keyword> - Search message history (shows last 15 matches)
|
||||
:mute-joins - Toggle join/leave system notifications
|
||||
:support - Show quick support guide
|
||||
:lang <en|zh> - Switch UI language for this session
|
||||
:help - Show available commands
|
||||
:help - Show concise manual
|
||||
:clear - Clear command output
|
||||
:q, :quit, :exit - Disconnect
|
||||
Up/Down - Browse command history
|
||||
ESC - Return to NORMAL mode
|
||||
```
|
||||
|
||||
Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox`
|
||||
shows incoming and sent private messages newest-first; press `r` to refresh it
|
||||
manually, and it refreshes when a new private message arrives while the inbox
|
||||
is open. `:reply text` and `:r text` send to the latest private-message peer.
|
||||
Unread incoming private messages are marked with `*` until `:inbox` renders.
|
||||
The inbox title shows a transient unread count when new private messages are
|
||||
present.
|
||||
`:inbox clear` removes private messages and the reply target for this session.
|
||||
Private messages are per-session only and are not written to `messages.log`.
|
||||
|
||||
**Special messages (INSERT mode)**
|
||||
```
|
||||
/me <action> - Send action (e.g. /me waves)
|
||||
|
|
@ -127,12 +144,27 @@ TNT_BIND_ADDR=192.168.1.100 tnt
|
|||
TNT_STATE_DIR=/var/lib/tnt tnt
|
||||
|
||||
# Show the public SSH endpoint in startup logs
|
||||
TNT_PUBLIC_HOST=chat.m1ng.space tnt
|
||||
TNT_PUBLIC_HOST=chat.example.com tnt
|
||||
|
||||
# Choose interactive UI language (en or zh; defaults from locale)
|
||||
TNT_LANG=zh tnt
|
||||
```
|
||||
|
||||
The same operational settings can be passed explicitly, which is often
|
||||
clearer in package scripts and one-off test deployments:
|
||||
|
||||
```sh
|
||||
tnt \
|
||||
--bind 127.0.0.1 \
|
||||
--public-host chat.example.com \
|
||||
--max-connections 100 \
|
||||
--max-conn-per-ip 10 \
|
||||
--max-conn-rate-per-ip 30 \
|
||||
--idle-timeout 3600 \
|
||||
-p 2222 \
|
||||
-d /var/lib/tnt
|
||||
```
|
||||
|
||||
**Rate limiting:**
|
||||
```sh
|
||||
# Max total connections (default 64)
|
||||
|
|
@ -173,17 +205,55 @@ tnt -p 2222
|
|||
TNT also exposes a small non-interactive SSH surface for scripts:
|
||||
|
||||
```sh
|
||||
ssh -p 2222 chat.m1ng.space health
|
||||
ssh -p 2222 chat.m1ng.space stats --json
|
||||
ssh -p 2222 chat.m1ng.space users
|
||||
ssh -p 2222 chat.m1ng.space support
|
||||
ssh -p 2222 chat.m1ng.space "tail -n 20"
|
||||
ssh -p 2222 operator@chat.m1ng.space post "service notice"
|
||||
ssh -p 2222 chat.m1ng.space post "/me deploys v2.0"
|
||||
ssh -p 2222 chat.example.com health
|
||||
ssh -p 2222 chat.example.com stats --json
|
||||
ssh -p 2222 chat.example.com users
|
||||
ssh -p 2222 chat.example.com "tail -n 20"
|
||||
ssh -p 2222 chat.example.com "dump -n 100"
|
||||
ssh -p 2222 operator@chat.example.com post "service notice"
|
||||
ssh -p 2222 chat.example.com post "/me deploys v2.0"
|
||||
```
|
||||
|
||||
**`post` identity**: the message is attributed to the SSH login name (the `user@` part of the URL, falling back to `anonymous`). In the default anonymous-access configuration there is no identity check, so any client can post as any name. Set `TNT_ACCESS_TOKEN` if you need authenticated posting.
|
||||
|
||||
See [docs/INTERFACE.md](docs/INTERFACE.md) for the stable exec command
|
||||
contract, exit statuses, and JSON field definitions.
|
||||
|
||||
Source and package-manager installs also include `tntctl`, a thin wrapper
|
||||
around the same SSH exec interface:
|
||||
|
||||
```sh
|
||||
tntctl chat.example.com health
|
||||
tntctl -p 2222 chat.example.com stats --json
|
||||
tntctl -p 2222 chat.example.com dump -n 100
|
||||
tntctl -l operator chat.example.com post "service notice"
|
||||
```
|
||||
|
||||
### Log Maintenance
|
||||
|
||||
Persisted public history is stored as `messages.log` in the TNT state
|
||||
directory. Private messages and local inbox state are intentionally excluded.
|
||||
For manual maintenance, archive and compact it with:
|
||||
|
||||
```sh
|
||||
scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000
|
||||
```
|
||||
|
||||
The script archives the full log, keeps the last `KEEP_LINES` records in the
|
||||
active file, compresses the archive when `gzip` is available, and can be
|
||||
previewed with `--dry-run`.
|
||||
|
||||
Installed binaries also include offline checks for the v1 log format:
|
||||
|
||||
```sh
|
||||
tnt --log-check /var/lib/tnt/messages.log
|
||||
tnt --log-recover /var/lib/tnt/messages.log > messages.recovered.log
|
||||
```
|
||||
|
||||
`--log-check` prints record counts and exits non-zero when invalid records are
|
||||
found. `--log-recover` writes valid records to stdout and reports skipped
|
||||
records to stderr; it never edits the source log in place.
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
|
@ -206,6 +276,9 @@ make anonymous-access-test # verify default anonymous login behavior
|
|||
make connection-limit-test # verify per-IP concurrency and rate limits
|
||||
make security-test # run security feature checks
|
||||
make stress-test # run configurable concurrent-client stress test
|
||||
make soak-test # run idle/reconnect/control-plane soak test
|
||||
make slow-client-test # run slow interactive-client backpressure test
|
||||
make user-lifecycle-test # run a two-user TUI lifecycle test
|
||||
make ci-test # run the same checks as GitHub Actions
|
||||
|
||||
# Individual tests
|
||||
|
|
@ -215,6 +288,9 @@ cd tests
|
|||
./test_anonymous_access.sh # anonymous access
|
||||
./test_connection_limits.sh # per-IP concurrency and rate limits
|
||||
./test_stress.sh # stress test
|
||||
./test_soak.sh # soak test
|
||||
./test_slow_client.sh # slow-client backpressure
|
||||
./test_user_lifecycle.sh # two-user TUI lifecycle
|
||||
```
|
||||
|
||||
**Test coverage:**
|
||||
|
|
@ -222,6 +298,8 @@ cd tests
|
|||
- Anonymous access: 2 tests
|
||||
- Security features: 12 tests
|
||||
- Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`)
|
||||
- Slow-client test: an unread interactive SSH client cannot block health,
|
||||
stats, post, tail, or server survival checks
|
||||
|
||||
### Dependencies
|
||||
|
||||
|
|
@ -251,14 +329,26 @@ TNT/
|
|||
├── src/ # source code
|
||||
│ ├── main.c # entry point
|
||||
│ ├── cli_text.c # startup CLI help and option text
|
||||
│ ├── command_catalog.c # command metadata, usage, and argument shape
|
||||
│ ├── commands.c # COMMAND-mode command dispatch
|
||||
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
|
||||
│ ├── exec.c # SSH exec command dispatch
|
||||
│ ├── tntctl.c # local wrapper around the SSH exec interface
|
||||
│ ├── tntctl_text.c # tntctl help and option text
|
||||
│ ├── ssh_server.c # SSH server implementation
|
||||
│ ├── bootstrap.c # SSH authentication and session bootstrap
|
||||
│ ├── chat_room.c # chat room logic
|
||||
│ ├── message.c # message persistence
|
||||
│ ├── module_protocol.c # external module JSONL protocol helpers
|
||||
│ ├── module_runtime.c # optional external module supervisor
|
||||
│ ├── json_text.c # small JSON string helpers
|
||||
│ ├── input_buffer.c # validated terminal input buffer helpers
|
||||
│ ├── history_view.c # message viewport and scroll state
|
||||
│ ├── help_text.c # full-screen and command help content
|
||||
│ ├── support_text.c # quick support guide content
|
||||
│ ├── i18n.c # language selection and shared UI text
|
||||
│ ├── help_text.c # full-screen key reference content
|
||||
│ ├── manual.c # concise manual panel rendering
|
||||
│ ├── manual_text.c # concise manual content
|
||||
│ ├── i18n.c # UI language and locale selection
|
||||
│ ├── i18n_text.c # shared UI text catalog
|
||||
│ ├── ratelimit.c # connection limits and rate limiting
|
||||
│ ├── tui.c # terminal UI rendering
|
||||
│ ├── tui_status.c # status/input line rendering
|
||||
|
|
@ -292,7 +382,7 @@ TNT_MAX_CONN_PER_IP=30
|
|||
TNT_MAX_CONN_RATE_PER_IP=60
|
||||
TNT_RATE_LIMIT=1
|
||||
TNT_SSH_LOG_LEVEL=0
|
||||
TNT_PUBLIC_HOST=chat.m1ng.space
|
||||
TNT_PUBLIC_HOST=chat.example.com
|
||||
EOF
|
||||
```
|
||||
|
||||
|
|
@ -319,10 +409,17 @@ Before preparing a release locally:
|
|||
make release-check
|
||||
```
|
||||
|
||||
Before publishing package recipes, replace placeholder checksums and run:
|
||||
Longer local preflight can opt into runtime soak and slow-client coverage:
|
||||
|
||||
```sh
|
||||
make release-check-strict
|
||||
RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
|
||||
```
|
||||
|
||||
Before publishing package recipes, download the explicit release source archive,
|
||||
replace placeholder checksums, and run:
|
||||
|
||||
```sh
|
||||
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check
|
||||
```
|
||||
|
||||
## Files
|
||||
|
|
@ -334,6 +431,13 @@ motd.txt - Message of the Day (optional, shown to users on connect)
|
|||
tnt.service - systemd service unit
|
||||
```
|
||||
|
||||
The persisted chat-history format is documented in
|
||||
[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md). Experimental community modules
|
||||
should follow the external-process protocol in
|
||||
[docs/MODULE_PROTOCOL.md](docs/MODULE_PROTOCOL.md). Module-generated content
|
||||
must always include a plain-text fallback so TNT can keep working on basic
|
||||
terminal clients and preserve the stable `messages.log` v1 history contract.
|
||||
|
||||
### MOTD (Message of the Day)
|
||||
|
||||
Place a `motd.txt` file in the state directory to show a welcome message to every user on connect. Users see the MOTD before entering the chat and press any key to continue.
|
||||
|
|
@ -353,6 +457,8 @@ Delete `motd.txt` to disable the MOTD.
|
|||
- [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual
|
||||
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
|
||||
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
|
||||
- [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields
|
||||
- [Module Protocol](docs/MODULE_PROTOCOL.md) - External-process module contract
|
||||
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
|
||||
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
|
||||
- [Changelog](docs/CHANGELOG.md) - Version history
|
||||
|
|
|
|||
61
SECURITY.md
Normal file
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
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
**用户体验:**
|
||||
```bash
|
||||
# 用户连接(零配置)
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
ssh -p 2222 chat.example.com
|
||||
# 输入任意内容或直接按回车
|
||||
# 开始聊天!
|
||||
```
|
||||
|
|
@ -143,7 +143,7 @@ ssh -p 2222 chat.m1ng.space
|
|||
tnt
|
||||
|
||||
# 用户端(任何人)
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
ssh -p 2222 chat.example.com
|
||||
# 输入任何内容作为密码或直接回车
|
||||
# 选择显示名称(可留空)
|
||||
# 开始聊天!
|
||||
|
|
|
|||
|
|
@ -1,11 +1,239 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Added a release tag/version guard used by the GitHub release workflow, so a
|
||||
`vX.Y.Z` tag must match `TNT_VERSION` before release assets are built.
|
||||
- Added `make package-publish-check` for verifying Arch/Homebrew source
|
||||
checksums against the explicit release source archive after a tag exists.
|
||||
- Added a release artifact gate that bundles Linux/macOS binaries, the explicit
|
||||
release source archive, and `checksums.txt` before opening the draft release.
|
||||
- Added CI governance layers for fast PR checks, release-branch validation,
|
||||
extended runtime validation, container portability builds, and package recipe
|
||||
validation.
|
||||
- Added `scripts/package_source_archive.sh` so the explicit release source
|
||||
archive can be built and tested locally instead of living only inside the
|
||||
GitHub release workflow.
|
||||
- Added a `config_defaults` module and unit coverage for runtime default
|
||||
values, env keys, and accepted numeric ranges.
|
||||
- Added a dedicated `tntctl_text` module with unit coverage for local
|
||||
`tntctl` help and validation diagnostics.
|
||||
- Documented the stable SSH exec interface contract, including exit statuses
|
||||
and JSON field shapes for package tests, scripts, and future `tntctl` work.
|
||||
- Documented `messages.log` v1 as the stable TNT 1.x persisted history format,
|
||||
including parser, sanitization, and partial-record recovery rules.
|
||||
- Added `dump [N]` / `dump -n N` to the SSH exec interface and `tntctl` for
|
||||
exporting valid persisted `messages.log` v1 records.
|
||||
- Added regression-tested manual log archive and compaction coverage for
|
||||
`scripts/logrotate.sh`.
|
||||
- Added offline `tnt --log-check` and `tnt --log-recover` modes for auditing
|
||||
and recovering valid `messages.log` v1 records without editing the source
|
||||
log in place.
|
||||
- Added a public security policy, supported-version guidance, and GitHub issue
|
||||
templates for bug reports and feature requests.
|
||||
- Added `tntctl`, a thin local wrapper around the documented SSH exec
|
||||
interface for health, stats, users, tail, post, help, and exit commands.
|
||||
- Added explicit server configuration flags for bind address, public host,
|
||||
connection limits, rate limiting, idle timeout, and SSH log verbosity.
|
||||
- Added a configurable soak test that keeps an interactive session open while
|
||||
repeatedly checking health, stats, users, reconnects, and post/tail behavior.
|
||||
- Added a two-user TUI lifecycle regression test and user-lifecycle notes for
|
||||
the main onboarding, chat, help, history, search, private-message, nickname,
|
||||
action-message, and exit paths.
|
||||
- Added a VHS tape draft for recording the core TNT terminal-chat experience.
|
||||
- Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and
|
||||
an open inbox refreshes when a new private message arrives.
|
||||
- Added `/` in NORMAL mode as a fast history-search entrypoint backed by the
|
||||
existing `:search` command.
|
||||
- Added `make slow-client-test`, an opt-in regression for an unread
|
||||
interactive SSH client under backpressure while health, stats, post, tail,
|
||||
and server survival stay responsive.
|
||||
|
||||
### Changed
|
||||
- INSERT-mode chrome now only advertises message sending and `Esc` to NORMAL;
|
||||
`? keys` appears only in NORMAL mode, matching where help keys work.
|
||||
- Dismissing MOTD now returns first-time users to INSERT mode, and `Ctrl+C`
|
||||
closes the full key reference before it disconnects from NORMAL mode.
|
||||
- COMMAND mode now accepts an optional leading `:` in typed commands, matching
|
||||
the way commands are written in the manual.
|
||||
- `:search` output and docs now state that the command shows the last 15
|
||||
matches, avoiding the impression that the pager is a complete result set.
|
||||
- Release checks now separate tag/source-archive readiness from package-manager
|
||||
checksum publishing, avoiding self-referential checksum requirements before
|
||||
the explicit release source archive exists.
|
||||
- `tntctl --help` now gets its exec command list from `exec_catalog`, reducing
|
||||
duplicate command metadata between the local wrapper and SSH exec mode.
|
||||
- Updated `tnt(1)` to document the current TUI search and pager keys, and
|
||||
added script coverage to keep active help surfaces free of removed support
|
||||
commands.
|
||||
- `make install-systemd` now rewrites the installed unit's `ExecStart` to match
|
||||
the selected `PREFIX`/`BINDIR`, so package builds that install to `/usr`
|
||||
produce a unit pointing at `/usr/bin/tnt`.
|
||||
- Release preflight now checks the staged systemd unit path, and strict release
|
||||
checks also require a clean tree, tag-at-HEAD, changelog release section, and
|
||||
non-placeholder maintainer metadata.
|
||||
- CI and release workflows now use explicit least-privilege repository
|
||||
permissions.
|
||||
- The release guide now documents SemVer expectations, manual release review,
|
||||
smoke testing, and rollback steps.
|
||||
- Package installs now include `tntctl` and its man page alongside `tnt`.
|
||||
- The binary naming policy is now explicit: `tnt` remains the stable 1.x
|
||||
server process name, and any future `tntd` split requires a major-version
|
||||
compatibility plan.
|
||||
- SSH exec commands longer than the command buffer are now rejected with a
|
||||
usage error instead of being truncated and executed.
|
||||
- SSH exec `post` now persists the message before broadcasting or returning
|
||||
`posted`, so persistence failures are not visible as successful room events.
|
||||
- Mention and private-message bell notifications are now queued on the target
|
||||
client and flushed by that client's own session loop, so slow SSH writes do
|
||||
not block the sender's message path.
|
||||
- Interactive client writes now pass through a bounded per-client outbox and
|
||||
flush against the remote SSH window from that client's session loop. Exec
|
||||
sessions still write synchronously to preserve script output ordering.
|
||||
- Session callback refs are now owned and released through `client.c`, so
|
||||
bootstrap and interactive cleanup no longer need to manually mirror the
|
||||
main-ref / callback-ref release sequence.
|
||||
- Message-log replay and search now share one strict record parser and skip
|
||||
malformed, invalid UTF-8, extra-separator, oversized, or unterminated
|
||||
records instead of accepting partial replay data.
|
||||
- `scripts/logrotate.sh` now has validated arguments, stable exit statuses,
|
||||
dry-run support, archive retention, gzip-aware archives, and a regression
|
||||
test in the normal test suite.
|
||||
- `messages.log` v1 record parsing and formatting now live in a dedicated
|
||||
`message_log` module instead of being embedded in `message.c`.
|
||||
- Offline message-log recovery shares the same `message_log` parser used by
|
||||
replay, search, and `dump`, so recovery behavior follows the documented v1
|
||||
contract.
|
||||
- The two-user lifecycle test now covers opening `:inbox` before a private
|
||||
message arrives, matching the way users often leave an inbox page open.
|
||||
- Help and command-output pagers now accept arrow keys, PgUp/PgDn, Home/End,
|
||||
and Space/`b` in addition to the existing Vim-style keys.
|
||||
- Pre-login username entry now handles Ctrl+C/Ctrl+D cancel, Ctrl+U clear
|
||||
line, and Ctrl+W delete-word before the user joins the room.
|
||||
- Long COMMAND-mode input is now left-truncated with a visible marker in the
|
||||
status line instead of wrapping and damaging the TUI.
|
||||
- Private-message inbox access now uses its own mutex instead of sharing the
|
||||
SSH channel write lock, reducing unrelated contention on slow clients.
|
||||
- Client writes now check the SSH channel's remote window before writing and
|
||||
mark the client disconnected when the window is closed, avoiding the most
|
||||
direct slow-reader blocking path.
|
||||
- `make release-check` can now run the soak test with `RUN_SOAK=1`, keeping
|
||||
longer runtime checks opt-in for local release validation.
|
||||
- `make release-check` can also run the slow-client backpressure test with
|
||||
`RUN_SLOW_CLIENT=1`.
|
||||
- Room capacity and mention notification bookkeeping now follow
|
||||
`TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit.
|
||||
- Updated the roadmap to reflect completed `tntctl`, stable exec contract, and
|
||||
monitoring-interface work, leaving the remaining daemon naming and runtime
|
||||
queue work explicit.
|
||||
- Strict release preflight now builds and installs from the local `vX.Y.Z` tag
|
||||
source archive, catching untracked files that would be missing from a GitHub
|
||||
source release.
|
||||
- Release documentation now creates the local tag before strict release checks,
|
||||
matching the strict gate's tag-at-HEAD requirement.
|
||||
- Startup option parsing now reports missing values for `--bind`, `-p`,
|
||||
`--idle-timeout`, and related flags with the localized
|
||||
"option requires argument" diagnostic instead of treating the option as
|
||||
unknown.
|
||||
- `tntctl` now reuses the SSH exec command matcher for local command
|
||||
validation, so `tntctl host --help` reaches the server-side exec help alias
|
||||
instead of being rejected locally.
|
||||
- `tntctl` local help and local validation errors now follow `TNT_LANG` and
|
||||
locale selection, matching the server CLI's i18n behavior.
|
||||
- Arch and Debian packaging drafts now create the `tnt` system user used by
|
||||
the packaged systemd unit, and release preflight checks that metadata.
|
||||
- The Homebrew formula draft now defines a `brew services` entry that runs the
|
||||
installed `tnt` binary with state under `var/tnt`.
|
||||
- Added `scripts/package_debian_source.sh` and `make debian-source-package`
|
||||
to assemble Debian/Ubuntu source-package trees from the current project
|
||||
without publishing or uploading anything.
|
||||
- Release preflight now smoke-tests the staged installed `tnt` binary's
|
||||
`--log-check` and `--log-recover` modes, catching package artifact drift.
|
||||
- The i18n helper now supports language-keyed string initializers through
|
||||
`I18N_STRING_MAP`, so future languages can be added incrementally without
|
||||
changing every existing two-language string initializer.
|
||||
- Split UI-language parsing from localized text lookup: `src/i18n.c` now owns
|
||||
locale/code parsing, while `src/i18n_text.c` owns the table-driven text
|
||||
catalog with coverage checks for every message ID.
|
||||
- Kept command placeholders stable across localized output: Chinese help and
|
||||
usage text now uses ASCII metavariables such as `<user>` and `<message>`.
|
||||
- Standardized user-facing `:msg` / `:inbox` terminology around "private
|
||||
message" / "私信" instead of mixing it with "whisper" wording.
|
||||
- Kept localized startup CLI syntax stable by using `用法: tnt [options]`
|
||||
instead of localizing the `[options]` metavariable.
|
||||
- Moved SSH exec help rows into an `exec_catalog` module so command metadata
|
||||
no longer lives as one large translated blob inside the shared i18n table.
|
||||
- Refreshed contributor and development guidance so new commands are added
|
||||
through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale
|
||||
`ssh_server.c` / inline-`strcmp` instructions.
|
||||
- Refreshed developer ownership guidance to match the current update-sequence
|
||||
model: room broadcasts update shared state only, while each interactive
|
||||
client renders and flushes its own SSH channel.
|
||||
- `exec_catalog` now owns SSH exec command matching as well as help metadata,
|
||||
reducing duplicate command knowledge in `src/exec.c`.
|
||||
- Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so
|
||||
public documentation does not imply a specific production host.
|
||||
- First-run connection examples now use `localhost`, keeping
|
||||
`chat.example.com` for deployed public-host examples.
|
||||
- Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so
|
||||
`src/exec.c` no longer duplicates `--json` and required-message validation.
|
||||
- Moved interactive command usage text and first-pass argument-shape checks
|
||||
into `command_catalog`, so known commands with bad arguments now show usage
|
||||
instead of unknown-command guidance.
|
||||
- Renamed the internal language state from help-oriented names to
|
||||
UI-language names (`ui_lang_t`, `client->ui_lang`, and
|
||||
`i18n_*_ui_lang`) so future i18n work has a correctly named seam.
|
||||
- Command names, aliases, help summaries, concise-manual command rows, and
|
||||
unknown-command suggestions now share a dedicated `command_catalog` module.
|
||||
- COMMAND-mode output is now a small scrollable pager with `j/k`, page
|
||||
movement, `g/G`, and `q`/Esc close controls, so long `:last` and `:search`
|
||||
results are readable instead of being cut off by the terminal height.
|
||||
- Collapsed the interactive help surface around a concise Unix-style `:help`
|
||||
manual and the `?` full key reference; `:support` is no longer a user-facing
|
||||
command.
|
||||
- First-use hints and unknown-command guidance now point users to `:help`
|
||||
instead of the removed support entry.
|
||||
- The concise manual module is now named `manual_text`, and the redundant
|
||||
interactive `:commands` entrypoint was removed.
|
||||
- The concise `:help` manual now stays within one command-output screen so it
|
||||
does not truncate on normal terminal sizes.
|
||||
- Language selection is limited to stable codes (`en`, `zh`) and
|
||||
locale-shaped environment values; natural-language labels are not accepted
|
||||
as command arguments.
|
||||
- Full-screen help now uses `l` to cycle the UI language through the i18n
|
||||
module instead of hard-coding one key per language.
|
||||
- Language parsing, language-code output, and help-language cycling now share
|
||||
one internal language registry.
|
||||
- Removed the unused `MODE_HELP` enum value and refreshed development-guide
|
||||
module descriptions for the split between language parsing and text lookup.
|
||||
- `i18n_text` now indexes localized strings by `UI_LANG_*` instead of storing
|
||||
English/Chinese as hard-coded struct fields.
|
||||
- `command_catalog` now uses the shared localized-string helper for help,
|
||||
manual, and usage text instead of per-field English/Chinese members.
|
||||
- `exec_catalog` now uses the same localized-string helper for exec help
|
||||
summaries.
|
||||
- Startup CLI help and option error formats now use the shared
|
||||
localized-string helper and English fallback path.
|
||||
- The concise `:help` manual text now uses the shared localized-string helper
|
||||
around the command-catalog rows.
|
||||
- The full-screen key reference now uses the shared localized-string helper
|
||||
around the command-catalog rows.
|
||||
- SSH exec help headers and usage prefixes now use the shared
|
||||
localized-string helper and English fallback path.
|
||||
- Documented i18n and user-facing text rules for English-first source text,
|
||||
stable command syntax, concise help copy, and translation-only localization.
|
||||
- Rewrote the quick setup guide as a concise English-first user lifecycle
|
||||
document with a short Chinese notes section.
|
||||
- The shared UI text catalog now uses the same localized-string initializer
|
||||
as the smaller text modules, avoiding GCC missing-braces warnings.
|
||||
|
||||
## 1.0.1 - 2026-05-24 - Release candidate hardening
|
||||
|
||||
### Added
|
||||
- Added a first i18n boundary: `TNT_LANG` / locale detection now chooses the
|
||||
default interactive UI language (`en` or `zh`) for username prompts, status
|
||||
hints, help language, and `:support`.
|
||||
hints, help output, and `:support`.
|
||||
- Added `:lang <en|zh>` so users can switch the interactive UI language for
|
||||
their current session.
|
||||
- COMMAND-mode `:help`, unknown-command guidance, language command output, and
|
||||
|
|
|
|||
332
docs/CICD.md
332
docs/CICD.md
|
|
@ -1,115 +1,269 @@
|
|||
CI / RELEASE GUIDE
|
||||
==================
|
||||
# CI/CD and Release Governance
|
||||
|
||||
AUTOMATIC TESTING
|
||||
-----------------
|
||||
Every push or PR automatically runs:
|
||||
- Build on Ubuntu
|
||||
- AddressSanitizer build
|
||||
- `make ci-test` (strict integration, anonymous access, connection limits,
|
||||
and security feature checks)
|
||||
- Release/package preflight (`make release-check`)
|
||||
TNT is a C SSH terminal chat server. The CI/CD system is designed for a public
|
||||
open-source project: fast feedback on pull requests, broader push and manual
|
||||
validation across target environments, reproducible release artifacts, and a
|
||||
manual production deployment boundary.
|
||||
|
||||
Check status:
|
||||
https://github.com/m1ngsama/TNT/actions
|
||||
Production deployment is intentionally manual. Workflows must not SSH into
|
||||
production, restart services, upload to OSS buckets, publish package-manager
|
||||
recipes, or mutate running servers.
|
||||
|
||||
Production deployment is intentionally manual. The CI workflow must not SSH
|
||||
into production or restart services on push.
|
||||
## Pipeline Layers
|
||||
|
||||
### PR Fast Gate
|
||||
|
||||
CREATING RELEASES
|
||||
-----------------
|
||||
1. Update version metadata:
|
||||
- include/common.h
|
||||
- tnt.1
|
||||
- docs/CHANGELOG.md
|
||||
- packaging/arch/PKGBUILD
|
||||
- packaging/homebrew/tnt-chat.rb
|
||||
Workflow: `.github/workflows/ci.yml`
|
||||
|
||||
2. Run the local preflight:
|
||||
make release-check
|
||||
Runs on pull requests targeting `main` or `release/**`, and pushes to `main`
|
||||
or `release/**`:
|
||||
|
||||
3. Replace package checksum placeholders and run:
|
||||
make release-check-strict
|
||||
- Ubuntu 24.04 and macOS latest builds.
|
||||
- Normal build with `make`.
|
||||
- AddressSanitizer build with `make asan`.
|
||||
- Integration/security gate with `make ci-test`.
|
||||
- Local release/package preflight with `make release-check`.
|
||||
|
||||
4. Create and push tag:
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
Purpose:
|
||||
|
||||
5. GitHub Actions automatically:
|
||||
- Builds binaries (Linux/macOS, AMD64/ARM64)
|
||||
- Creates a draft release
|
||||
- Uploads binaries
|
||||
- Generates one `checksums.txt` file
|
||||
- Verifies that artifact architecture matches the asset name
|
||||
- Keep contributor feedback fast enough for normal review.
|
||||
- Catch build, integration, packaging metadata, and release-preflight regressions
|
||||
before merge.
|
||||
- Avoid slow soak, valgrind, and container matrix jobs on every PR.
|
||||
|
||||
6. Review the draft release, smoke-test downloaded assets, then publish it
|
||||
manually from GitHub.
|
||||
### Extended Validation
|
||||
|
||||
7. Release appears at:
|
||||
https://github.com/m1ngsama/TNT/releases
|
||||
Workflow: `.github/workflows/ci.yml`
|
||||
|
||||
Runs on `main` or `release/**` pushes and manual dispatch:
|
||||
|
||||
DEPLOYING TO SERVERS
|
||||
--------------------
|
||||
Deployments are operator-driven:
|
||||
1. Build and test locally or in a temporary server directory.
|
||||
2. Back up the installed binary.
|
||||
3. Install the new binary.
|
||||
4. Restart the service.
|
||||
5. Run black-box checks (`health`, `stats --json`, `users --json`,
|
||||
`support`, and a post/tail smoke test).
|
||||
- `extended-linux-runtime`
|
||||
- Runs `RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check`.
|
||||
- Runs a valgrind smoke test against a temporary server.
|
||||
- `portable-container-builds`
|
||||
- Builds in Debian stable glibc.
|
||||
- Builds in Ubuntu 24.04 glibc.
|
||||
- Builds in Alpine musl.
|
||||
- `package-recipe-gate`
|
||||
- Syntax-checks shell scripts.
|
||||
- Syntax-checks the Arch `PKGBUILD`.
|
||||
- Syntax-checks the Homebrew formula.
|
||||
- Assembles the Debian source tree.
|
||||
|
||||
The installer can still be used manually on a server:
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
Purpose:
|
||||
|
||||
- Broaden platform confidence without making every PR wait for the full matrix.
|
||||
- Detect musl/glibc portability issues early.
|
||||
- Keep package metadata reviewable before public registry submission.
|
||||
|
||||
PRODUCTION SETUP (systemd)
|
||||
---------------------------
|
||||
1. Install binary (see above)
|
||||
### Release Artifact Gates
|
||||
|
||||
2. Setup service:
|
||||
sudo useradd -r -s /bin/false tnt
|
||||
sudo mkdir -p /var/lib/tnt
|
||||
sudo chown tnt:tnt /var/lib/tnt
|
||||
sudo cp tnt.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now tnt
|
||||
Workflow: `.github/workflows/release.yml`
|
||||
|
||||
3. Check status:
|
||||
sudo systemctl status tnt
|
||||
sudo journalctl -u tnt -f
|
||||
Runs only for SemVer tags matching `vMAJOR.MINOR.PATCH`:
|
||||
|
||||
- Verifies the tag matches `TNT_VERSION` through `scripts/check_release_ref.sh`.
|
||||
- Builds Linux glibc AMD64 and ARM64 binaries.
|
||||
- Builds macOS Intel and Apple Silicon binaries.
|
||||
- Verifies binary architecture labels.
|
||||
- Builds an explicit source archive with `scripts/package_source_archive.sh`:
|
||||
`tnt-chat-vX.Y.Z-source.tar.gz`.
|
||||
- Runs `scripts/package_release_assets.sh` to collect release assets, verify
|
||||
expected asset names, verify binary architecture labels again after artifact
|
||||
download, verify source archive contents, generate `checksums.txt`, and verify
|
||||
the checksum file.
|
||||
- Creates a GitHub draft release only. Publishing stays manual.
|
||||
|
||||
The release workflow does not publish package-manager recipes or deploy
|
||||
production servers.
|
||||
|
||||
## Platform Policy
|
||||
|
||||
Current release assets:
|
||||
|
||||
- Linux glibc AMD64: `tnt-linux-amd64`, `tntctl-linux-amd64`
|
||||
- Linux glibc ARM64: `tnt-linux-arm64`, `tntctl-linux-arm64`
|
||||
- macOS Intel: `tnt-darwin-amd64`, `tntctl-darwin-amd64`
|
||||
- macOS Apple Silicon: `tnt-darwin-arm64`, `tntctl-darwin-arm64`
|
||||
- Source archive: `tnt-chat-vX.Y.Z-source.tar.gz`
|
||||
|
||||
Current CI validation:
|
||||
|
||||
- Ubuntu 24.04
|
||||
- macOS latest
|
||||
- Debian stable glibc container build
|
||||
- Ubuntu 24.04 glibc container build
|
||||
- Alpine musl container build
|
||||
|
||||
Package-manager routes:
|
||||
|
||||
- Debian/Ubuntu: maintain draft Debian metadata and start with a Launchpad PPA.
|
||||
- Arch/AUR: maintain `packaging/arch/PKGBUILD` and `.SRCINFO`; submit manually.
|
||||
- Homebrew/macOS: maintain a tap formula first; Homebrew core can wait for a
|
||||
stable release cadence and broader adoption.
|
||||
- Source archive: every public package recipe must pin the final GitHub release
|
||||
source archive checksum.
|
||||
- Containers: first stage is Docker-based build validation in CI. Publishing
|
||||
images should wait until image labels, SBOM, provenance, CVE scanning, and
|
||||
registry ownership are defined.
|
||||
|
||||
## Release Policy
|
||||
|
||||
- Use SemVer-style tags: `vMAJOR.MINOR.PATCH`.
|
||||
- Bump PATCH for compatible bug fixes and release hardening.
|
||||
- Bump MINOR for new commands, new documented flags, JSON field additions, or
|
||||
visible user-interface behavior changes.
|
||||
- Bump MAJOR for incompatible command, config, storage, or package behavior.
|
||||
- Keep GitHub release publishing manual by using draft releases.
|
||||
- Keep production deployment manual.
|
||||
|
||||
Update version metadata before tagging:
|
||||
|
||||
- `include/common.h`
|
||||
- `tnt.1`
|
||||
- `tntctl.1`
|
||||
- `docs/CHANGELOG.md`
|
||||
- `packaging/arch/PKGBUILD`
|
||||
- `packaging/arch/.SRCINFO`
|
||||
- `packaging/homebrew/tnt-chat.rb`
|
||||
- `packaging/debian/debian/changelog`
|
||||
|
||||
Local preflight:
|
||||
|
||||
```sh
|
||||
make release-check
|
||||
```
|
||||
|
||||
Longer local runtime gate:
|
||||
|
||||
```sh
|
||||
RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
|
||||
```
|
||||
|
||||
Strict local release gate before pushing a tag:
|
||||
|
||||
```sh
|
||||
git tag vX.Y.Z
|
||||
make release-check-strict
|
||||
```
|
||||
|
||||
Strict mode requires the local `vX.Y.Z` tag to point at `HEAD` and builds from
|
||||
the tagged source archive, so it catches files that were left untracked and
|
||||
would be missing from the release source archive.
|
||||
|
||||
After strict checks pass:
|
||||
|
||||
```sh
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
GitHub Actions then builds artifacts and opens a draft release. Review and
|
||||
publish that draft manually.
|
||||
|
||||
## Release Review Checklist
|
||||
|
||||
Before publishing a draft release:
|
||||
|
||||
- Confirm the Git tag points at the intended commit.
|
||||
- Confirm the release workflow passed.
|
||||
- Download every release asset from GitHub, not from the local workspace.
|
||||
- Verify downloaded assets against `checksums.txt`.
|
||||
- Run downloaded `tnt --version` and `tntctl --version`.
|
||||
- Start a temporary server and check:
|
||||
|
||||
```sh
|
||||
ssh -p 2222 server health
|
||||
ssh -p 2222 server stats --json
|
||||
ssh -p 2222 server users --json
|
||||
ssh -p 2222 operator@server post "release smoke"
|
||||
ssh -p 2222 server "tail -n 1"
|
||||
```
|
||||
|
||||
- Check runtime dynamic links with `ldd` on Linux or `otool -L` on macOS.
|
||||
- Confirm `libssh` runtime installation is documented for the target install
|
||||
path.
|
||||
- Verify the explicit source archive checksum before updating Arch, Homebrew,
|
||||
Debian, Ubuntu, or container package metadata.
|
||||
- Run package publication preflight after package recipes pin final source
|
||||
checksums:
|
||||
|
||||
```sh
|
||||
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check
|
||||
```
|
||||
|
||||
## Checksums
|
||||
|
||||
Release assets include `checksums.txt`.
|
||||
|
||||
Linux:
|
||||
|
||||
```sh
|
||||
sha256sum -c checksums.txt --ignore-missing
|
||||
```
|
||||
|
||||
macOS:
|
||||
|
||||
```sh
|
||||
for f in tnt-* tntctl-* tnt-chat-*-source.tar.gz; do
|
||||
grep " $f$" checksums.txt | shasum -a 256 -c -
|
||||
done
|
||||
```
|
||||
|
||||
## Supply Chain Roadmap
|
||||
|
||||
Stage 1, implemented now:
|
||||
|
||||
- Tag/version gate.
|
||||
- Draft release, manual publish.
|
||||
- Binary architecture validation.
|
||||
- Source archive validation.
|
||||
- Local source-archive dry-run coverage.
|
||||
- SHA-256 checksums for every release asset.
|
||||
- Package recipe checksum preflight.
|
||||
|
||||
Stage 2, next:
|
||||
|
||||
- Generate an SBOM for release artifacts, preferably CycloneDX or SPDX.
|
||||
- Attach SBOM files to draft releases.
|
||||
- Add package lint jobs for Debian source packages, Arch packages, Homebrew
|
||||
audit, and container image metadata.
|
||||
|
||||
Stage 3, later:
|
||||
|
||||
- Sign release checksums and/or artifacts with a documented maintainer key or
|
||||
Sigstore flow.
|
||||
- Add SLSA provenance for GitHub Actions builds.
|
||||
- Define container image ownership, tag policy, vulnerability scan policy, and
|
||||
rollback behavior before publishing images.
|
||||
|
||||
## Manual Production Deployment
|
||||
|
||||
Deployment remains operator-driven:
|
||||
|
||||
1. Build and test locally or in a temporary server directory.
|
||||
2. Back up the installed binary.
|
||||
3. Install the new binary.
|
||||
4. Restart only the intended `tnt` service.
|
||||
5. Run black-box checks: `health`, `stats --json`, `users --json`, and one
|
||||
post/tail smoke test.
|
||||
|
||||
UPDATING SERVERS
|
||||
----------------
|
||||
Manual binary replacement pattern:
|
||||
backup=/usr/local/bin/tnt.bak-$(date +%Y%m%d%H%M%S)
|
||||
sudo cp -a /usr/local/bin/tnt "$backup"
|
||||
sudo install -m 755 ./tnt /usr/local/bin/tnt
|
||||
sudo systemctl restart tnt
|
||||
|
||||
```sh
|
||||
backup=/usr/local/bin/tnt.bak-$(date +%Y%m%d%H%M%S)
|
||||
sudo cp -a /usr/local/bin/tnt "$backup"
|
||||
sudo install -m 755 ./tnt /usr/local/bin/tnt
|
||||
sudo systemctl restart tnt
|
||||
```
|
||||
|
||||
PLATFORMS SUPPORTED
|
||||
-------------------
|
||||
✓ Linux AMD64 (x86_64)
|
||||
✓ Linux ARM64 (aarch64)
|
||||
✓ macOS Intel (x86_64)
|
||||
✓ macOS Apple Silicon (arm64)
|
||||
## Rollback
|
||||
|
||||
Production rollback stays manual:
|
||||
|
||||
EXAMPLE WORKFLOW
|
||||
----------------
|
||||
# Local development
|
||||
make && make asan && make release-check
|
||||
./tnt
|
||||
1. Keep the previous binary before replacing it.
|
||||
2. Stop or restart only the intended `tnt` service.
|
||||
3. Restore the previous binary if smoke checks fail.
|
||||
4. Re-run `health`, `stats --json`, and one post/tail smoke test.
|
||||
|
||||
# Create release
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
# Wait 5 minutes for builds
|
||||
|
||||
# Deploy to production manually after validation
|
||||
ssh server "sudo install -m 755 /tmp/tnt-build/tnt /usr/local/bin/tnt"
|
||||
ssh server "sudo systemctl restart tnt"
|
||||
ssh -p 2222 server health
|
||||
Do not overwrite `TNT_STATE_DIR` during rollback. If a future release changes
|
||||
the message log format, its release notes must include downgrade behavior.
|
||||
|
|
|
|||
|
|
@ -3,17 +3,22 @@
|
|||
## Build
|
||||
|
||||
```sh
|
||||
make # normal build
|
||||
make debug # with symbols
|
||||
make asan # AddressSanitizer
|
||||
make release # optimized
|
||||
make # normal build
|
||||
make debug # with symbols
|
||||
make asan # AddressSanitizer
|
||||
make release # optimized
|
||||
make release-check # release preflight
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
./test_basic.sh # functional tests
|
||||
./test_stress.sh 20 60 # 20 clients, 60 seconds
|
||||
make test # unit + integration tests
|
||||
make ci-test # local CI-equivalent checks
|
||||
make stress-test # concurrent-client stress test
|
||||
make soak-test # idle/reconnect/control-plane soak
|
||||
make slow-client-test # slow interactive-client backpressure
|
||||
make user-lifecycle-test # two-user TUI lifecycle
|
||||
```
|
||||
|
||||
## Debug
|
||||
|
|
@ -34,10 +39,23 @@ make check
|
|||
|
||||
```
|
||||
main.c → entry point, signal handling
|
||||
ssh_server.c → SSH protocol, client threads
|
||||
cli_text.c → startup CLI text
|
||||
tntctl_text.c → tntctl local help and diagnostics
|
||||
command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
|
||||
commands.c → COMMAND-mode command dispatch
|
||||
exec_catalog.c → SSH exec command matching, usage, and argument shape
|
||||
exec.c → SSH exec command dispatch
|
||||
tntctl.c → local wrapper around the SSH exec interface
|
||||
ssh_server.c → SSH listener setup
|
||||
bootstrap.c → SSH authentication/session bootstrap
|
||||
input.c → interactive session loop
|
||||
chat_room.c → client list, message broadcast
|
||||
history_view.c → message viewport and scroll state
|
||||
i18n.c → UI language and locale selection
|
||||
i18n_text.c → shared UI text catalog
|
||||
message.c → persistent storage
|
||||
tui.c → terminal rendering
|
||||
tui_status.c → status/input-line rendering
|
||||
utf8.c → UTF-8 string handling
|
||||
```
|
||||
|
||||
|
|
@ -56,7 +74,7 @@ utf8.c → UTF-8 string handling
|
|||
|
||||
## Known Limits
|
||||
|
||||
- Max 64 clients (MAX_CLIENTS)
|
||||
- Default 64 clients, configurable with `TNT_MAX_CONNECTIONS`
|
||||
- Max 100 messages in memory (MAX_MESSAGES)
|
||||
- Max 1024 bytes per message (MAX_MESSAGE_LEN)
|
||||
- Max 64 bytes username (MAX_USERNAME_LEN)
|
||||
|
|
@ -64,15 +82,20 @@ utf8.c → UTF-8 string handling
|
|||
## Common Bugs to Avoid
|
||||
|
||||
1. Don't use `strtok()` on client data - use `strtok_r()` or copy first
|
||||
2. Always increment ref_count before using client outside lock
|
||||
2. Always use `client_addref()` / `client_release()` before using a client
|
||||
outside `g_room->lock`; never modify `ref_count` directly
|
||||
3. Check SSH API return values (can be SSH_ERROR, SSH_AGAIN, or negative)
|
||||
4. UTF-8 chars are multi-byte - use utf8_* functions
|
||||
|
||||
## Adding Features
|
||||
|
||||
1. Add new command in `execute_command()` (ssh_server.c:190)
|
||||
2. Add new mode in `client_mode_t` enum (common.h:30)
|
||||
3. Add new vim key in `handle_key()` (ssh_server.c:220)
|
||||
1. Add interactive command metadata, usage text, and argument shape in
|
||||
`src/command_catalog.c`.
|
||||
2. Add interactive command behavior in `src/commands.c`.
|
||||
3. Add SSH exec metadata in `src/exec_catalog.c` and dispatch in `src/exec.c`
|
||||
only when the feature should be scriptable.
|
||||
4. Put shared localized strings in `src/i18n_text.c`.
|
||||
5. Add or update the narrowest unit/integration test for the behavior.
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
|||
|
||||
Specific version:
|
||||
```bash
|
||||
VERSION=v1.0.1 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
VERSION=vX.Y.Z curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
|
||||
## Manual Install
|
||||
|
|
@ -18,12 +18,12 @@ Download binary for your platform from [releases](https://github.com/m1ngsama/TN
|
|||
|
||||
```bash
|
||||
# Linux AMD64
|
||||
wget https://github.com/m1ngsama/TNT/releases/latest/download/tnt-linux-amd64
|
||||
curl -LO https://github.com/m1ngsama/TNT/releases/latest/download/tnt-linux-amd64
|
||||
chmod +x tnt-linux-amd64
|
||||
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
|
||||
|
||||
# macOS ARM64 (Apple Silicon)
|
||||
wget https://github.com/m1ngsama/TNT/releases/latest/download/tnt-darwin-arm64
|
||||
curl -LO https://github.com/m1ngsama/TNT/releases/latest/download/tnt-darwin-arm64
|
||||
chmod +x tnt-darwin-arm64
|
||||
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
|
||||
```
|
||||
|
|
@ -54,7 +54,7 @@ TNT_MAX_CONN_PER_IP=30
|
|||
TNT_MAX_CONN_RATE_PER_IP=60
|
||||
TNT_RATE_LIMIT=1
|
||||
TNT_SSH_LOG_LEVEL=0
|
||||
TNT_PUBLIC_HOST=chat.m1ng.space
|
||||
TNT_PUBLIC_HOST=chat.example.com
|
||||
EOF
|
||||
|
||||
sudo systemctl restart tnt
|
||||
|
|
@ -89,6 +89,37 @@ Recommended interpretation:
|
|||
- `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds
|
||||
- `TNT_RATE_LIMIT=0`: disables rate-based blocking and auth-failure IP blocking, but not the explicit capacity limits
|
||||
|
||||
## Edge Module Production Profile
|
||||
|
||||
Some deployments intentionally track the newest TNT builds and newest module
|
||||
integrations to exercise the full product surface. Treat these as edge
|
||||
production environments: user-facing, but optimized for fast integration and
|
||||
fast rollback.
|
||||
|
||||
For that profile:
|
||||
|
||||
- Deploy TNT and modules as separate artifacts so a module can be disabled
|
||||
without replacing the core server.
|
||||
- Keep module permissions explicit and minimal. Do not grant private-message
|
||||
access unless the module exists for that purpose.
|
||||
- Keep a known-good TNT binary and module manifest set on disk for immediate
|
||||
rollback.
|
||||
- Log module startup failures, invalid JSONL, protocol errors, and timeouts
|
||||
separately from chat history.
|
||||
- Prefer plain-text fallbacks for every module-created message, even when the
|
||||
module also targets richer terminal renderers.
|
||||
- Before promoting a module, test its manifest and JSONL handshake against the
|
||||
protocol in `docs/MODULE_PROTOCOL.md`.
|
||||
|
||||
Enable modules explicitly with `TNT_MODULE_PATHS`, using a colon-separated
|
||||
list of module directories:
|
||||
|
||||
```bash
|
||||
TNT_MODULE_PATHS=/opt/tnt-modules/echo-module:/opt/tnt-modules/other-module
|
||||
```
|
||||
|
||||
Unset `TNT_MODULE_PATHS` and restart TNT to return to the plain core server.
|
||||
|
||||
## MOTD (Message of the Day)
|
||||
|
||||
Place a `motd.txt` file in the state directory. TNT displays it to each user on connect; they press any key to enter the chat.
|
||||
|
|
@ -97,7 +128,7 @@ Place a `motd.txt` file in the state directory. TNT displays it to each user on
|
|||
# Systemd deployment (state dir is /var/lib/tnt)
|
||||
sudo tee /var/lib/tnt/motd.txt <<'EOF'
|
||||
Welcome! Be respectful. No spam.
|
||||
Type :help for available commands.
|
||||
Type :help for a concise manual, or ? for the full key reference.
|
||||
EOF
|
||||
sudo chown tnt:tnt /var/lib/tnt/motd.txt
|
||||
|
||||
|
|
@ -107,6 +138,34 @@ sudo rm /var/lib/tnt/motd.txt
|
|||
|
||||
No restart required — TNT reads the file on each new connection.
|
||||
|
||||
## Manual Log Maintenance
|
||||
|
||||
TNT stores public chat history in `messages.log` under the state directory.
|
||||
Use the maintenance script from a source checkout when the service is stopped
|
||||
or during a quiet maintenance window:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tnt
|
||||
sudo scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000
|
||||
sudo systemctl start tnt
|
||||
```
|
||||
|
||||
The arguments are `LOG_FILE MAX_SIZE_MB KEEP_LINES`. The script archives the
|
||||
full log, compacts the active log to the last `KEEP_LINES` records, compresses
|
||||
the archive when `gzip` is available, and keeps the newest five archives by
|
||||
default. Use `--dry-run` to preview actions, or `--keep-archives N` to change
|
||||
archive retention.
|
||||
|
||||
Before replacing a suspicious log, inspect and recover it offline:
|
||||
|
||||
```bash
|
||||
tnt --log-check /var/lib/tnt/messages.log
|
||||
tnt --log-recover /var/lib/tnt/messages.log > /var/lib/tnt/messages.recovered.log
|
||||
```
|
||||
|
||||
`--log-recover` writes valid records to stdout and reports skipped records to
|
||||
stderr. Review the recovered file before replacing the active log.
|
||||
|
||||
## Firewall
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ Complete guide for TNT developers and contributors.
|
|||
3. [Building and Testing](#building-and-testing)
|
||||
4. [Core Components](#core-components)
|
||||
5. [Adding Features](#adding-features)
|
||||
6. [Debugging](#debugging)
|
||||
7. [Performance Optimization](#performance-optimization)
|
||||
8. [Contributing Guidelines](#contributing-guidelines)
|
||||
6. [User-Facing Text and i18n](#user-facing-text-and-i18n)
|
||||
7. [Debugging](#debugging)
|
||||
8. [Performance Optimization](#performance-optimization)
|
||||
9. [Contributing Guidelines](#contributing-guidelines)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -54,10 +55,13 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
|
|||
|
||||
### Key Design Principles
|
||||
|
||||
1. **Fixed-size buffers** - No dynamic allocation in hot paths
|
||||
2. **Reader-writer locks** - Multiple readers, single writer
|
||||
3. **Reference counting** - Prevent use-after-free
|
||||
4. **Ring buffer** - Fixed-size message history (last 100 messages)
|
||||
1. **Fixed-size buffers** - Keep message, command, and UI buffers bounded
|
||||
2. **Reader-writer locks** - Multiple readers, single writer for room state
|
||||
3. **Per-client output ownership** - Each interactive session writes only to
|
||||
its own SSH channel
|
||||
4. **Reference counting** - Keep client objects alive across callbacks and
|
||||
cross-thread lookups
|
||||
5. **Ring buffer** - Fixed-size in-memory message history (last 100 messages)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -68,19 +72,32 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
|
|||
```
|
||||
src/
|
||||
├── main.c - CLI entry point and startup option parsing
|
||||
├── cli_text.c - Server CLI help and option diagnostics
|
||||
├── ssh_server.c - SSH listener setup and connection accept loop
|
||||
├── bootstrap.c - SSH authentication/session bootstrap
|
||||
├── input.c - Interactive session loop and key handling
|
||||
├── commands.c - COMMAND-mode command dispatch
|
||||
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
|
||||
├── exec_catalog.c - SSH exec command matching and help metadata
|
||||
├── exec.c - SSH exec command dispatch
|
||||
├── chat_room.c - Chat room logic and message broadcasting
|
||||
├── json_text.c - JSON string escaping and top-level string extraction
|
||||
├── input_buffer.c - Validated INSERT/COMMAND/paste buffer helpers
|
||||
├── module_protocol.c - External module JSONL protocol helpers
|
||||
├── module_runtime.c - Optional external module supervisor
|
||||
├── tntctl.c - Local wrapper around the SSH exec interface
|
||||
├── tntctl_text.c - tntctl local help and diagnostics
|
||||
├── chat_room.c - Chat room state, message ring, and update sequence
|
||||
├── message.c - Message persistence (RFC3339 format)
|
||||
├── message_log.c - messages.log v1 parsing and formatting
|
||||
├── message_log_tool.c - Offline messages.log check/recover CLI
|
||||
├── history_view.c - NORMAL-mode scroll window rules
|
||||
├── tui.c - Terminal UI rendering (ANSI escape codes)
|
||||
├── tui_status.c - Mode/status/input-line rendering
|
||||
├── i18n.c - Language selection and shared UI text
|
||||
├── help_text.c - Full-screen and command help text
|
||||
├── support_text.c - Quick support guide text
|
||||
├── i18n.c - UI language selection and locale parsing
|
||||
├── i18n_text.c - Shared UI text catalog
|
||||
├── help_text.c - Full-screen key reference text
|
||||
├── manual.c - Concise manual panel rendering
|
||||
├── manual_text.c - Concise manual text
|
||||
├── system_message.c - Localized join/leave/nick system messages
|
||||
├── ratelimit.c - Per-IP and global connection limits
|
||||
└── utf8.c - UTF-8 character handling
|
||||
|
|
@ -95,11 +112,24 @@ include/
|
|||
├── bootstrap.h - SSH session bootstrap interface
|
||||
├── chat_room.h - Chat room interface
|
||||
├── message.h - Message structure and persistence
|
||||
├── message_log.h - messages.log v1 parser/formatter interface
|
||||
├── message_log_tool.h - Offline log check/recover interface
|
||||
├── command_catalog.h - COMMAND-mode command metadata interface
|
||||
├── exec_catalog.h - SSH exec command metadata interface
|
||||
├── json_text.h - JSON text helper interface
|
||||
├── input_buffer.h - Terminal input buffer helper interface
|
||||
├── module_protocol.h - External module protocol helper interface
|
||||
├── module_runtime.h - External module supervisor interface
|
||||
├── cli_text.h - Server CLI text interface
|
||||
├── tntctl_text.h - tntctl text interface
|
||||
├── history_view.h - Scroll-state helpers
|
||||
├── tui.h - TUI rendering functions
|
||||
├── tui_status.h - TUI status/input-line rendering interface
|
||||
├── i18n.h - Language and shared text IDs
|
||||
├── help_text.h - Help text interface
|
||||
├── support_text.h - Support guide text interface
|
||||
├── help_text.h - Key reference text interface
|
||||
├── manual.h - Concise manual panel interface
|
||||
├── manual_text.h - Concise manual text interface
|
||||
├── system_message.h - Localized system message builders
|
||||
├── ratelimit.h - Connection limit interface
|
||||
└── utf8.h - UTF-8 utilities
|
||||
```
|
||||
|
|
@ -112,12 +142,16 @@ typedef struct client {
|
|||
ssh_session session;
|
||||
ssh_channel channel;
|
||||
char username[MAX_USERNAME_LEN];
|
||||
int width, height; // Terminal dimensions
|
||||
_Atomic int width, height; // Terminal dimensions
|
||||
client_mode_t mode; // INSERT/NORMAL/COMMAND
|
||||
int scroll_pos;
|
||||
bool connected;
|
||||
atomic_bool connected;
|
||||
char *outbox; // Bounded queued interactive output
|
||||
size_t outbox_len, outbox_pos;
|
||||
int ref_count; // Reference counting
|
||||
pthread_mutex_t ref_lock;
|
||||
pthread_mutex_t io_lock; // Own SSH channel writes only
|
||||
bool channel_callback_ref; // Ref held while callbacks are installed
|
||||
} client_t;
|
||||
```
|
||||
|
||||
|
|
@ -127,6 +161,7 @@ typedef struct {
|
|||
pthread_rwlock_t lock; // Reader-writer lock
|
||||
struct client **clients; // Dynamic array
|
||||
int client_count;
|
||||
uint64_t update_seq; // Bumped when message history changes
|
||||
message_t *messages; // Ring buffer
|
||||
int message_count;
|
||||
} chat_room_t;
|
||||
|
|
@ -182,6 +217,9 @@ make anonymous-access-test # Verify default anonymous login behavior
|
|||
make connection-limit-test # Verify per-IP concurrency and rate limits
|
||||
make security-test # Run security feature checks
|
||||
make stress-test # Run configurable concurrent-client stress test
|
||||
make soak-test # Run idle/reconnect/control-plane soak test
|
||||
make slow-client-test # Run slow interactive-client backpressure test
|
||||
make user-lifecycle-test # Run a two-user TUI lifecycle test
|
||||
make ci-test # Run the same checks as GitHub Actions
|
||||
|
||||
# Individual tests
|
||||
|
|
@ -190,6 +228,9 @@ cd tests
|
|||
./test_security_features.sh # Security checks
|
||||
./test_anonymous_access.sh # Anonymous access
|
||||
./test_stress.sh # Concurrent connections
|
||||
./test_soak.sh # Idle/reconnect soak
|
||||
./test_slow_client.sh # Slow-client backpressure
|
||||
./test_user_lifecycle.sh # Two-user TUI lifecycle
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
|
@ -198,6 +239,10 @@ cd tests
|
|||
- **Security**: RSA keys, env vars, UTF-8 validation, buffer overflow protection
|
||||
- **Anonymous**: Passwordless access, any username
|
||||
- **Stress**: 10 concurrent clients for 30 seconds
|
||||
- **Soak**: idle session, reconnect churn, health/stats/users/post/tail
|
||||
- **Slow client**: unread interactive SSH client cannot block control paths
|
||||
- **Lifecycle**: two-user TUI story covering help, history, search, private
|
||||
messages, nickname, action messages, and persistence boundaries
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -237,41 +282,48 @@ while ((!ctx->auth_success || ctx->channel == NULL || !ctx->channel_ready) && !t
|
|||
|
||||
### 2. Chat Room (chat_room.c)
|
||||
|
||||
**Thread-safe broadcasting:**
|
||||
**Thread-safe message publication:**
|
||||
```c
|
||||
void room_broadcast(chat_room_t *room, const message_t *msg) {
|
||||
pthread_rwlock_wrlock(&room->lock);
|
||||
|
||||
/* Copy client list with ref counting */
|
||||
client_t **clients_copy = calloc(...);
|
||||
for (int i = 0; i < count; i++) {
|
||||
clients_copy[i]->ref_count++;
|
||||
}
|
||||
room_add_message(room, msg);
|
||||
room->update_seq++;
|
||||
|
||||
pthread_rwlock_unlock(&room->lock); // Release lock early
|
||||
|
||||
/* Render outside lock (avoid deadlock) */
|
||||
for (int i = 0; i < count; i++) {
|
||||
tui_render_screen(clients_copy[i]);
|
||||
client_release(clients_copy[i]);
|
||||
}
|
||||
pthread_rwlock_unlock(&room->lock);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
- Copy client list while holding write lock
|
||||
- Increment reference counts
|
||||
- Release lock BEFORE rendering
|
||||
- Render to all clients outside lock
|
||||
- Decrement reference counts (may free clients)
|
||||
- Broadcast updates shared room state only; it does not render or write to
|
||||
any SSH channel.
|
||||
- Each interactive session tracks `room_get_update_seq()` in its own
|
||||
`input_run_session()` loop.
|
||||
- When the sequence changes, the client renders and flushes its own output.
|
||||
- This keeps slow SSH windows local to that client and prevents one recipient
|
||||
from blocking a sender or the whole room.
|
||||
- Cross-client lookups, such as mentions and private messages, must call
|
||||
`client_addref()` before using a client pointer outside `g_room->lock`, then
|
||||
`client_release()` when done. Do not increment `ref_count` directly.
|
||||
- Session callback lifetime is owned by `client.c`: `client_install_channel_callbacks()`
|
||||
takes the callback ref, and `client_release_session()` removes callbacks and
|
||||
releases both the callback ref and the session main ref.
|
||||
|
||||
### 3. Message Persistence (message.c)
|
||||
|
||||
See [MESSAGE_LOG.md](MESSAGE_LOG.md) for the stable TNT 1.x on-disk record
|
||||
contract.
|
||||
|
||||
**Log format:**
|
||||
```
|
||||
2024-01-13T10:30:45Z|username|message content
|
||||
```
|
||||
|
||||
Log replay and search use the same strict parser. A record is accepted only
|
||||
when it has exactly three fields, a strict UTC RFC3339 timestamp, valid UTF-8
|
||||
username/content, bounded field lengths, and a trailing newline. Unterminated
|
||||
last lines are treated as partial writes and skipped.
|
||||
|
||||
**Optimized loading** (backward scan):
|
||||
```c
|
||||
/* Scan backwards from file end */
|
||||
|
|
@ -351,29 +403,35 @@ void utf8_remove_last_word(char *str) {
|
|||
|
||||
### Adding a New Command
|
||||
|
||||
1. **For interactive COMMAND mode, add to `commands_dispatch()` in `src/commands.c`:**
|
||||
1. **For interactive COMMAND mode, add command metadata in `src/command_catalog.c`:**
|
||||
```c
|
||||
if (strcmp(cmd, "newcmd") == 0) {
|
||||
pos += snprintf(output + pos, sizeof(output) - pos,
|
||||
"New command output\n");
|
||||
{
|
||||
{TNT_COMMAND_NEWCMD, "newcmd", {"newcmd", NULL}, false},
|
||||
":newcmd", ":newcmd",
|
||||
"Show new output", "显示新输出",
|
||||
":newcmd", ":newcmd", 3
|
||||
}
|
||||
```
|
||||
|
||||
2. **For SSH exec mode, add the stable command path in `src/exec.c` if it should work non-interactively.**
|
||||
2. **Add interactive behavior in `src/commands.c` by switching on the command ID.**
|
||||
|
||||
3. **Move user-facing strings through `src/i18n.c` when they need localization or are reused.**
|
||||
3. **For SSH exec mode, add help metadata in `src/exec_catalog.c` and the stable command path in `src/exec.c` if it should work non-interactively.**
|
||||
|
||||
4. **Update help text in `src/help_text.c`:**
|
||||
```c
|
||||
"AVAILABLE COMMANDS:\n"
|
||||
" newcmd - Description of new command\n"
|
||||
```
|
||||
4. **Move shared user-facing strings through `src/i18n_text.c` when they need localization or are reused. Keep command syntax and metavariables ASCII.**
|
||||
|
||||
5. **Add tests in the narrowest target:**
|
||||
5. **Update user help surfaces through their catalogs. Avoid duplicating command rows by hand.**
|
||||
|
||||
6. **Add tests in the narrowest target:**
|
||||
```sh
|
||||
tests/test_exec_mode.sh # exec command behavior
|
||||
tests/test_interactive_input.sh # COMMAND-mode/TUI behavior
|
||||
tests/test_user_lifecycle.sh # end-to-end two-user TUI behavior
|
||||
tests/test_slow_client.sh # slow SSH reader/backpressure behavior
|
||||
tests/unit/test_i18n.c # localized shared text
|
||||
tests/unit/test_command_catalog.c # interactive command metadata
|
||||
tests/unit/test_exec_catalog.c # exec command help metadata
|
||||
tests/unit/test_tntctl_text.c # tntctl local help/diagnostic text
|
||||
tests/test_docs_help_surface.sh # active help/manual drift checks
|
||||
```
|
||||
|
||||
### Adding a New Keybinding
|
||||
|
|
@ -387,12 +445,82 @@ case MODE_INSERT:
|
|||
}
|
||||
```
|
||||
|
||||
2. **Update `src/help_text.c` and status hints in `src/i18n.c` / `src/tui_status.c` if the binding is user-visible.**
|
||||
2. **Update `src/help_text.c` and status hints in `src/i18n_text.c` /
|
||||
`src/tui_status.c` if the binding is user-visible.**
|
||||
|
||||
3. **Document in README.md**
|
||||
|
||||
---
|
||||
|
||||
## User-Facing Text and i18n
|
||||
|
||||
TNT should follow Unix/open-source conventions for user-facing text:
|
||||
English is the source language, command syntax is stable ASCII, and
|
||||
translations are presentation only. A localized interface must never create
|
||||
localized command names, localized option names, or localized configuration
|
||||
keys.
|
||||
|
||||
### Principles
|
||||
|
||||
1. **English-first source text**
|
||||
- Keep code identifiers, comments, command names, option names, and
|
||||
documentation source in English.
|
||||
- Treat English text as the canonical source text for future gettext-style
|
||||
catalogs.
|
||||
- Do not use translated text as a programmatic key.
|
||||
|
||||
2. **Stable language identifiers**
|
||||
- Interactive `:lang` accepts only stable language codes: `en` and `zh`.
|
||||
- Code should name this concept `ui_lang`, not `help_lang`; the same value
|
||||
controls prompts, status text, help, command output, MOTD chrome, and
|
||||
system messages.
|
||||
- Locale detection may accept locale-shaped values such as
|
||||
`en_US.UTF-8`, `zh_CN.UTF-8`, `C`, and `POSIX`.
|
||||
- Do not accept natural-language labels such as `english`, `chinese`,
|
||||
`中文`, or `英文` as command arguments.
|
||||
- If regional variants are added later, add explicit locale identifiers
|
||||
such as `zh_TW` instead of overloading `zh`.
|
||||
|
||||
3. **Concise writing**
|
||||
- Prefer imperative verbs: "Show", "Switch", "Disconnect".
|
||||
- Keep command descriptions noun-like or verb-like, not explanatory prose.
|
||||
- Avoid tutorial language in `:help`; put detailed behavior in `tnt(1)`.
|
||||
- Keep `:help` within one command-output screen. `?` is the full key
|
||||
reference.
|
||||
|
||||
4. **One behavior, one name**
|
||||
- Do not create parallel help commands for the same task.
|
||||
- Keep `:help` for the concise manual and `?` for the full key reference.
|
||||
- Keep SSH exec commands small, scriptable, and stable.
|
||||
|
||||
5. **Translation safety**
|
||||
- Use whole sentences or whole phrases; do not concatenate translated
|
||||
fragments.
|
||||
- Keep placeholders visible and stable, for example `%s`, `%d`,
|
||||
`<user>`, and `<message>`.
|
||||
- Use `I18N_STRING(en, zh)` for ordinary two-language entries. Use
|
||||
`I18N_STRING_MAP(I18N_EN(...), I18N_ZH(...))` when an entry needs
|
||||
language-keyed initialization so future languages can be added without
|
||||
changing every existing initializer.
|
||||
- Every new user-facing string needs tests for at least English fallback
|
||||
and Chinese output while this project has two UI languages.
|
||||
|
||||
### Current Limitations
|
||||
|
||||
The current `src/i18n_text.c` implementation is a small-project translation
|
||||
table implemented in C, not a full gettext catalog. It is acceptable for two
|
||||
languages because message lookup is already split from language parsing in
|
||||
`src/i18n.c`, and localized strings can now be initialized by language key.
|
||||
Adding many more languages should still move toward external catalog-like
|
||||
storage instead of adding ad hoc branches for every locale.
|
||||
|
||||
Relevant conventions:
|
||||
- POSIX locale variables: `LANG`, `LC_ALL`, `LC_MESSAGES`.
|
||||
- GNU gettext source preparation: decent English, whole sentences, and
|
||||
format placeholders rather than string concatenation.
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Verbose SSH Logging
|
||||
|
|
|
|||
|
|
@ -1,278 +1,236 @@
|
|||
# TNT 匿名聊天室 - 快速部署指南 / TNT Anonymous Chat - Quick Setup Guide
|
||||
# TNT Quick Setup
|
||||
|
||||
[中文](#中文) | [English](#english)
|
||||
This guide gets a TNT server running and explains the first user session.
|
||||
For the full reference, see [README.md](../README.md), [tnt(1)](../tnt.1),
|
||||
and [Deployment](DEPLOYMENT.md).
|
||||
|
||||
---
|
||||
## Install
|
||||
|
||||
## 中文
|
||||
|
||||
### 一键安装
|
||||
|
||||
```bash
|
||||
```sh
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
|
||||
### 启动服务器
|
||||
Or build from source:
|
||||
|
||||
```bash
|
||||
tnt # 监听 2222 端口
|
||||
```sh
|
||||
git clone https://github.com/m1ngsama/TNT.git
|
||||
cd TNT
|
||||
make
|
||||
sudo make install
|
||||
```
|
||||
|
||||
就这么简单!服务器已经运行了。
|
||||
## Start A Server
|
||||
|
||||
### 用户如何连接
|
||||
|
||||
用户只需要一个SSH客户端即可,无需任何配置:
|
||||
|
||||
```bash
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
```
|
||||
|
||||
**重要提示**:
|
||||
- ✅ 用户可以使用**任意用户名**连接
|
||||
- ✅ 用户可以输入**任意密码**(甚至直接按回车跳过)
|
||||
- ✅ **不需要SSH密钥**
|
||||
- ✅ 不需要提前注册账号
|
||||
- ✅ 完全匿名,零门槛
|
||||
|
||||
连接后,系统会提示输入显示名称(也可以留空使用默认名称)。
|
||||
|
||||
### 生产环境部署
|
||||
|
||||
使用 systemd 让服务器开机自启:
|
||||
|
||||
```bash
|
||||
# 1. 创建专用用户
|
||||
sudo useradd -r -s /bin/false tnt
|
||||
sudo mkdir -p /var/lib/tnt
|
||||
sudo chown tnt:tnt /var/lib/tnt
|
||||
|
||||
# 2. 安装服务
|
||||
sudo cp tnt.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable tnt
|
||||
sudo systemctl start tnt
|
||||
|
||||
# 3. 检查状态
|
||||
sudo systemctl status tnt
|
||||
```
|
||||
|
||||
### 防火墙设置
|
||||
|
||||
记得开放2222端口:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 2222/tcp
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo firewall-cmd --permanent --add-port=2222/tcp
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
### 可选配置
|
||||
|
||||
通过环境变量进行高级配置:
|
||||
|
||||
```bash
|
||||
# 修改端口
|
||||
PORT=3333 tnt
|
||||
|
||||
# 限制最大连接数
|
||||
TNT_MAX_CONNECTIONS=100 tnt
|
||||
|
||||
# 限制每个IP的最大连接数
|
||||
TNT_MAX_CONN_PER_IP=10 tnt
|
||||
|
||||
# 只允许本地访问
|
||||
TNT_BIND_ADDR=127.0.0.1 tnt
|
||||
|
||||
# 添加访问密码(所有用户共用一个密码)
|
||||
TNT_ACCESS_TOKEN="your_secret_password" tnt
|
||||
```
|
||||
|
||||
**注意**:设置 `TNT_ACCESS_TOKEN` 后,所有用户必须使用该密码才能连接,这会提高安全性但也会增加使用门槛。
|
||||
|
||||
### 特性
|
||||
|
||||
- 🚀 **零配置** - 开箱即用
|
||||
- 🔓 **完全匿名** - 无需注册,无需密钥
|
||||
- 🎨 **Vim风格界面** - 支持 INSERT/NORMAL/COMMAND 三种模式
|
||||
- 📜 **消息历史** - 自动保存聊天记录
|
||||
- 🌐 **UTF-8支持** - 完美支持中英文及其他语言
|
||||
- 🔒 **可选安全特性** - 支持限流、访问控制等
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### One-Line Installation
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
|
||||
### Start Server
|
||||
|
||||
```bash
|
||||
tnt # Listen on port 2222
|
||||
```
|
||||
|
||||
That's it! Your server is now running.
|
||||
|
||||
### How Users Connect
|
||||
|
||||
Users only need an SSH client, no configuration required:
|
||||
|
||||
```bash
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
```
|
||||
|
||||
**Important**:
|
||||
- ✅ Users can use **ANY username**
|
||||
- ✅ Users can enter **ANY password** (or just press Enter to skip)
|
||||
- ✅ **No SSH keys required**
|
||||
- ✅ No registration needed
|
||||
- ✅ Completely anonymous, zero barrier
|
||||
|
||||
After connecting, the system will prompt for a display name (can be left empty for default name).
|
||||
|
||||
### Production Deployment
|
||||
|
||||
Use systemd for auto-start on boot:
|
||||
|
||||
```bash
|
||||
# 1. Create dedicated user
|
||||
sudo useradd -r -s /bin/false tnt
|
||||
sudo mkdir -p /var/lib/tnt
|
||||
sudo chown tnt:tnt /var/lib/tnt
|
||||
|
||||
# 2. Install service
|
||||
sudo cp tnt.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable tnt
|
||||
sudo systemctl start tnt
|
||||
|
||||
# 3. Check status
|
||||
sudo systemctl status tnt
|
||||
```
|
||||
|
||||
### Firewall Configuration
|
||||
|
||||
Remember to open port 2222:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 2222/tcp
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo firewall-cmd --permanent --add-port=2222/tcp
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
Advanced configuration via environment variables:
|
||||
|
||||
```bash
|
||||
# Change port
|
||||
PORT=3333 tnt
|
||||
|
||||
# Limit max connections
|
||||
TNT_MAX_CONNECTIONS=100 tnt
|
||||
|
||||
# Limit concurrent sessions per IP
|
||||
TNT_MAX_CONN_PER_IP=10 tnt
|
||||
|
||||
# Limit new connections per IP per 60 seconds
|
||||
TNT_MAX_CONN_RATE_PER_IP=30 tnt
|
||||
|
||||
# Bind to localhost only
|
||||
TNT_BIND_ADDR=127.0.0.1 tnt
|
||||
|
||||
# Add password protection (shared password for all users)
|
||||
TNT_ACCESS_TOKEN="your_secret_password" tnt
|
||||
```
|
||||
|
||||
**Note**: Setting `TNT_ACCESS_TOKEN` requires all users to use that password to connect. This increases security but also raises the barrier to entry.
|
||||
|
||||
### Features
|
||||
|
||||
- 🚀 **Zero Configuration** - Works out of the box
|
||||
- 🔓 **Fully Anonymous** - No registration, no keys
|
||||
- 🎨 **Vim-Style Interface** - Supports INSERT/NORMAL/COMMAND modes
|
||||
- 📜 **Message History** - Automatic chat log persistence
|
||||
- 🌐 **UTF-8 Support** - Perfect for all languages
|
||||
- 🔒 **Optional Security** - Rate limiting, access control, etc.
|
||||
|
||||
---
|
||||
|
||||
## 使用示例 / Usage Examples
|
||||
|
||||
### 基本使用 / Basic Usage
|
||||
|
||||
```bash
|
||||
# 启动服务器
|
||||
```sh
|
||||
tnt
|
||||
|
||||
# 用户连接(从任何机器)
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
# 输入任意密码或直接回车
|
||||
# 输入显示名称或留空
|
||||
# 开始聊天!
|
||||
```
|
||||
|
||||
### Vim风格操作 / Vim-Style Operations
|
||||
By default TNT listens on port `2222`, stores `host_key` and `messages.log`
|
||||
in the current directory, and allows anonymous SSH login.
|
||||
|
||||
连接后:
|
||||
Use explicit state and port settings for a long-running server:
|
||||
|
||||
- **INSERT 模式**(默认):直接输入消息,按 Enter 发送
|
||||
- **NORMAL 模式**:按 `ESC` 进入,使用 `j/k` 滚动历史,`g/G` 跳转顶部/底部
|
||||
- **COMMAND 模式**:按 `:` 进入,输入 `:list` 查看在线用户,`:help` 查看帮助
|
||||
```sh
|
||||
tnt -p 2222 -d /var/lib/tnt
|
||||
```
|
||||
|
||||
### 故障排除 / Troubleshooting
|
||||
## Connect
|
||||
|
||||
#### 问题:端口已被占用
|
||||
```sh
|
||||
ssh -p 2222 localhost
|
||||
```
|
||||
|
||||
```bash
|
||||
# 更换端口
|
||||
For a deployed server, replace `localhost` with your public host.
|
||||
|
||||
Default access rules:
|
||||
|
||||
- Any SSH username is accepted.
|
||||
- Empty or arbitrary passwords are accepted.
|
||||
- SSH keys are not required.
|
||||
- TNT asks for a display name after the SSH session starts.
|
||||
|
||||
Set `TNT_ACCESS_TOKEN` when you want a shared password:
|
||||
|
||||
```sh
|
||||
TNT_ACCESS_TOKEN="change-this-password" tnt -p 2222 -d /var/lib/tnt
|
||||
```
|
||||
|
||||
## First Session
|
||||
|
||||
TNT opens in INSERT mode. Type a message and press `Enter`.
|
||||
|
||||
Common keys:
|
||||
|
||||
```text
|
||||
Esc enter NORMAL mode
|
||||
i return to INSERT mode
|
||||
: enter COMMAND mode
|
||||
? open the full key reference
|
||||
/ search message history
|
||||
G or End jump to latest messages
|
||||
Up/Down recall sent messages in INSERT mode
|
||||
Tab complete @mention in INSERT mode
|
||||
Ctrl+C disconnect from NORMAL mode
|
||||
```
|
||||
|
||||
Common commands:
|
||||
|
||||
```text
|
||||
:help concise manual
|
||||
:users online users
|
||||
:nick <name> change nickname
|
||||
:msg <user> <message> send private message
|
||||
:reply <message> reply to latest private message
|
||||
:inbox show private messages
|
||||
:inbox clear clear private messages
|
||||
:last [N] recent messages
|
||||
:search <keyword> search message history
|
||||
:lang en|zh switch UI language
|
||||
:q disconnect
|
||||
```
|
||||
|
||||
NORMAL mode opens at the latest messages and follows new messages until the
|
||||
user scrolls up. Use `G` or `End` to return to the live tail.
|
||||
|
||||
## Configure
|
||||
|
||||
```sh
|
||||
# Listening address and port
|
||||
TNT_BIND_ADDR=0.0.0.0 PORT=2222 tnt
|
||||
|
||||
# State directory
|
||||
TNT_STATE_DIR=/var/lib/tnt tnt
|
||||
|
||||
# Shared SSH password
|
||||
TNT_ACCESS_TOKEN="change-this-password" tnt
|
||||
|
||||
# Default UI language; unset means locale detection, then English fallback
|
||||
TNT_LANG=en tnt
|
||||
TNT_LANG=zh tnt
|
||||
|
||||
# Connection limits
|
||||
TNT_MAX_CONNECTIONS=200 tnt
|
||||
TNT_MAX_CONN_PER_IP=30 tnt
|
||||
TNT_MAX_CONN_RATE_PER_IP=60 tnt
|
||||
|
||||
# Idle timeout in seconds; 0 disables it
|
||||
TNT_IDLE_TIMEOUT=3600 tnt
|
||||
```
|
||||
|
||||
## Run Under systemd
|
||||
|
||||
```sh
|
||||
sudo useradd -r -s /bin/false tnt
|
||||
sudo mkdir -p /var/lib/tnt
|
||||
sudo chown tnt:tnt /var/lib/tnt
|
||||
sudo cp tnt.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now tnt
|
||||
sudo systemctl status tnt
|
||||
```
|
||||
|
||||
Put runtime overrides in `/etc/default/tnt`:
|
||||
|
||||
```sh
|
||||
PORT=2222
|
||||
TNT_BIND_ADDR=0.0.0.0
|
||||
TNT_STATE_DIR=/var/lib/tnt
|
||||
TNT_MAX_CONNECTIONS=200
|
||||
TNT_MAX_CONN_PER_IP=30
|
||||
TNT_MAX_CONN_RATE_PER_IP=60
|
||||
TNT_RATE_LIMIT=1
|
||||
TNT_SSH_LOG_LEVEL=1
|
||||
TNT_PUBLIC_HOST=chat.example.com
|
||||
```
|
||||
|
||||
Open the listening port in your firewall:
|
||||
|
||||
```sh
|
||||
sudo ufw allow 2222/tcp
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already In Use
|
||||
|
||||
```sh
|
||||
tnt -p 3333
|
||||
```
|
||||
|
||||
#### 问题:防火墙阻止连接
|
||||
### Cannot Connect
|
||||
|
||||
```bash
|
||||
# 检查防火墙状态
|
||||
Check the server process:
|
||||
|
||||
```sh
|
||||
systemctl status tnt
|
||||
sudo journalctl -u tnt -n 50 --no-pager
|
||||
```
|
||||
|
||||
Check the listening port:
|
||||
|
||||
```sh
|
||||
ss -ltnp | grep 2222
|
||||
```
|
||||
|
||||
Check the firewall:
|
||||
|
||||
```sh
|
||||
sudo ufw status
|
||||
sudo firewall-cmd --list-ports
|
||||
|
||||
# 确保已开放端口
|
||||
sudo ufw allow 2222/tcp
|
||||
```
|
||||
|
||||
#### 问题:连接超时
|
||||
### Connection Closes Immediately
|
||||
|
||||
```bash
|
||||
# 检查服务器是否运行
|
||||
ps aux | grep tnt
|
||||
The most common causes are per-IP connection limits, connection-rate limits,
|
||||
an auth-failure ban, a full room, or a closed firewall port. The server logs
|
||||
the rejection reason to stderr or the systemd journal.
|
||||
|
||||
# 检查端口监听
|
||||
sudo lsof -i:2222
|
||||
## Chinese Quick Notes
|
||||
|
||||
### 安装
|
||||
|
||||
```sh
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
|
||||
---
|
||||
### 启动
|
||||
|
||||
## 技术细节 / Technical Details
|
||||
```sh
|
||||
tnt
|
||||
```
|
||||
|
||||
- **语言**: C
|
||||
- **依赖**: libssh
|
||||
- **并发**: 多线程,支持数百个同时连接
|
||||
- **安全**: 可选限流、访问控制、密码保护
|
||||
- **存储**: 简单的文本日志(messages.log)
|
||||
- **配置**: 环境变量,无配置文件
|
||||
默认监听 `2222` 端口,并允许匿名 SSH 登录。
|
||||
|
||||
---
|
||||
### 连接
|
||||
|
||||
## 许可证 / License
|
||||
```sh
|
||||
ssh -p 2222 localhost
|
||||
```
|
||||
|
||||
MIT License - 自由使用、修改、分发
|
||||
部署到公网后,将 `localhost` 替换为你的域名。
|
||||
|
||||
默认情况下,任意 SSH 用户名和空密码都可以连接。进入后 TNT 会询问显示名称。
|
||||
|
||||
### 常用操作
|
||||
|
||||
```text
|
||||
Enter 发送消息
|
||||
Esc 进入 NORMAL 模式
|
||||
i 回到 INSERT 模式
|
||||
: 输入命令
|
||||
? 查看完整按键参考
|
||||
/ 搜索消息历史
|
||||
G 或 End 回到最新消息
|
||||
Up/Down 在 INSERT 模式调出已发送消息
|
||||
Tab 在 INSERT 模式补全 @mention
|
||||
:help 查看简明手册
|
||||
:lang en|zh 切换界面语言
|
||||
:q 断开连接
|
||||
```
|
||||
|
||||
### 常用配置
|
||||
|
||||
```sh
|
||||
TNT_ACCESS_TOKEN="change-this-password" tnt
|
||||
TNT_STATE_DIR=/var/lib/tnt tnt
|
||||
TNT_LANG=zh tnt
|
||||
```
|
||||
|
|
|
|||
194
docs/INTERFACE.md
Normal file
194
docs/INTERFACE.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# Interface Contract
|
||||
|
||||
This document defines the public surfaces that scripts, package tests, and
|
||||
operators may rely on.
|
||||
|
||||
For 1.x, the public binary names are stable:
|
||||
|
||||
- `tnt` is the server process and daemon entrypoint.
|
||||
- `tntctl` is a thin local wrapper around the SSH exec interface.
|
||||
|
||||
TNT will not introduce a separate `tntd` binary during 1.x. If the project
|
||||
ever splits the server into `tntd`, that change must ship with a major-version
|
||||
compatibility plan, package migration notes, and a transition period for the
|
||||
`tnt` command.
|
||||
|
||||
## Stability Scope
|
||||
|
||||
Stable:
|
||||
|
||||
- public binary names for 1.x: `tnt` and `tntctl`
|
||||
- documented command-line flags in `tnt(1)`
|
||||
- documented environment variables in `tnt(1)`
|
||||
- SSH exec command names and argument shapes listed below
|
||||
- SSH exec exit statuses
|
||||
- JSON field names and value types for documented `--json` commands
|
||||
- `messages.log` v1 record format documented in
|
||||
[MESSAGE_LOG.md](MESSAGE_LOG.md)
|
||||
|
||||
Not yet stable:
|
||||
|
||||
- exact human-readable diagnostic wording
|
||||
- interactive TUI layout
|
||||
- future storage migration tooling
|
||||
- internal module names and helper functions
|
||||
|
||||
## Exit Status
|
||||
|
||||
TNT process startup and SSH exec commands use these exit statuses:
|
||||
|
||||
| Code | Name | Meaning |
|
||||
|---:|---|---|
|
||||
| 0 | `TNT_EXIT_OK` | Success |
|
||||
| 1 | `TNT_EXIT_ERROR` | Runtime error, I/O error, allocation failure, persistence failure |
|
||||
| 64 | `TNT_EXIT_USAGE` | Unknown command, invalid option, invalid argument shape |
|
||||
| 69 | `TNT_EXIT_UNAVAILABLE` | Local `tntctl` SSH transport unavailable |
|
||||
| 78 | `TNT_EXIT_CONFIG` | Reserved for future local `tntctl` configuration errors |
|
||||
|
||||
`64` follows the common `sysexits(3)` usage-error convention.
|
||||
|
||||
## SSH Exec Commands
|
||||
|
||||
Exec commands are run through a standard SSH client:
|
||||
|
||||
```sh
|
||||
ssh -p 2222 chat.example.com health
|
||||
ssh -p 2222 chat.example.com stats --json
|
||||
ssh -p 2222 chat.example.com users --json
|
||||
ssh -p 2222 chat.example.com "tail -n 20"
|
||||
ssh -p 2222 chat.example.com "dump -n 100"
|
||||
ssh -p 2222 operator@chat.example.com post "service notice"
|
||||
```
|
||||
|
||||
The same commands can be run through `tntctl`:
|
||||
|
||||
```sh
|
||||
tntctl chat.example.com health
|
||||
tntctl -p 2222 chat.example.com stats --json
|
||||
tntctl -p 2222 chat.example.com dump -n 100
|
||||
tntctl -l operator chat.example.com post "service notice"
|
||||
tntctl --host-key-checking accept-new chat.example.com users
|
||||
```
|
||||
|
||||
### `health`
|
||||
|
||||
Prints:
|
||||
|
||||
```text
|
||||
ok
|
||||
```
|
||||
|
||||
Exit status: `0` when the daemon can accept and handle exec requests.
|
||||
|
||||
### `stats [--json]`
|
||||
|
||||
Text output is line-oriented key/value data:
|
||||
|
||||
```text
|
||||
status ok
|
||||
online_users 0
|
||||
message_count 0
|
||||
client_capacity 64
|
||||
active_connections 1
|
||||
uptime_seconds 12
|
||||
```
|
||||
|
||||
JSON output:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"online_users": 0,
|
||||
"message_count": 0,
|
||||
"client_capacity": 64,
|
||||
"active_connections": 1,
|
||||
"uptime_seconds": 12
|
||||
}
|
||||
```
|
||||
|
||||
Field names and scalar types are stable. New fields may be added in a minor
|
||||
release.
|
||||
|
||||
### `users [--json]`
|
||||
|
||||
Text output prints one username per line.
|
||||
|
||||
JSON output is an array of strings:
|
||||
|
||||
```json
|
||||
["alice", "bob"]
|
||||
```
|
||||
|
||||
### `tail [N]` / `tail -n N`
|
||||
|
||||
Prints recent in-memory messages as tab-separated lines:
|
||||
|
||||
```text
|
||||
2026-05-25T12:00:00Z alice hello
|
||||
```
|
||||
|
||||
The current upper bound is `MAX_MESSAGES`. This command reads the live
|
||||
in-memory room buffer, not the full persisted log.
|
||||
|
||||
### `dump [N]` / `dump -n N`
|
||||
|
||||
Exports valid persisted `messages.log` v1 records in chronological order:
|
||||
|
||||
```text
|
||||
2026-05-25T12:00:00Z|alice|hello
|
||||
```
|
||||
|
||||
Without `N`, `dump` exports all valid persisted records. With `N`, it exports
|
||||
the last `N` valid persisted records. Malformed, invalid UTF-8, oversized, or
|
||||
truncated records are skipped by the same strict parser used for replay and
|
||||
search.
|
||||
|
||||
This command reads the on-disk log, not the live in-memory room buffer. A
|
||||
missing log produces empty output and exit status `0`.
|
||||
|
||||
### `post MESSAGE`
|
||||
|
||||
Posts a message as the SSH login name and prints:
|
||||
|
||||
```text
|
||||
posted
|
||||
```
|
||||
|
||||
In anonymous-access mode, the SSH login name is not authenticated. Operators
|
||||
should configure `TNT_ACCESS_TOKEN` before relying on exec-post identity.
|
||||
|
||||
## Interactive Private Messages
|
||||
|
||||
`:msg user message` and its `:w` alias deliver private messages only to online
|
||||
interactive clients. `:reply message` and its `:r` alias send to the latest
|
||||
private-message peer in the current session. Private messages are not
|
||||
persisted to `messages.log` and are not included in exec `tail`, exec `dump`,
|
||||
`:last`, or `:search`.
|
||||
|
||||
Each participant keeps a bounded in-memory `:inbox` for the current session.
|
||||
Recipients see incoming private messages; senders see local sent-message
|
||||
copies. Unread incoming messages are marked with `*` until `:inbox` renders.
|
||||
`:inbox` displays newest messages first, shows a transient unread count, can
|
||||
be refreshed with `r`, and refreshes automatically while open when a new
|
||||
private message arrives.
|
||||
`:inbox clear` removes the current session's private messages, unread count,
|
||||
and reply target.
|
||||
|
||||
### `help`
|
||||
|
||||
Prints a localized human-readable command summary. It is intended for people,
|
||||
not parsers.
|
||||
|
||||
## `tntctl`
|
||||
|
||||
`tntctl` preserves the command names, exit statuses, and JSON schemas above.
|
||||
It invokes the local `ssh(1)` client without a local shell. OpenSSH transport
|
||||
failures are mapped to `TNT_EXIT_UNAVAILABLE` (`69`); remote TNT exec statuses
|
||||
are otherwise returned unchanged.
|
||||
|
||||
The wrapper intentionally does not accept arbitrary SSH options or a password
|
||||
option. It exposes only bounded host-key options:
|
||||
`--host-key-checking yes|accept-new|no` and `--known-hosts FILE`. Use normal
|
||||
SSH configuration for jump hosts, identity files, and authentication. If the
|
||||
server requires `TNT_ACCESS_TOKEN`, enter it through the normal SSH password
|
||||
prompt or use an SSH setup appropriate for the deployment.
|
||||
109
docs/MESSAGE_LOG.md
Normal file
109
docs/MESSAGE_LOG.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Message Log
|
||||
|
||||
This document defines the persisted chat-history format used by TNT 1.x.
|
||||
|
||||
## Format: `messages.log` v1
|
||||
|
||||
Each record is one UTF-8 line:
|
||||
|
||||
```text
|
||||
RFC3339_UTC|username|content\n
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
2026-05-27T12:34:56Z|alice|hello
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Timestamp is strict UTC RFC3339: `YYYY-MM-DDTHH:MM:SSZ`.
|
||||
- The separator is literal `|`.
|
||||
- A valid record has exactly three fields and exactly two separators.
|
||||
- `username` and `content` must be non-empty valid UTF-8.
|
||||
- `username` must fit `MAX_USERNAME_LEN`; `content` must fit
|
||||
`MAX_MESSAGE_LEN`.
|
||||
- Every complete record ends with `\n`.
|
||||
|
||||
The file has no header. The version is defined by this record contract so
|
||||
existing append-only logs remain readable.
|
||||
|
||||
## Write Behavior
|
||||
|
||||
`message_save()` sanitizes fields before appending:
|
||||
|
||||
- `|`, `\n`, and `\r` in usernames become `_`.
|
||||
- `|`, `\n`, and `\r` in content become spaces.
|
||||
- Timestamps are written in UTC.
|
||||
|
||||
Private messages are not written to `messages.log`. `:inbox` stores incoming
|
||||
and sent private-message copies only in each participant's live session memory,
|
||||
so inbox state is lost on disconnect and never appears in `tail`, `dump`,
|
||||
`:last`, or `:search`.
|
||||
|
||||
## Replay And Search
|
||||
|
||||
Replay and search use the same strict parser. TNT skips records that are:
|
||||
|
||||
- malformed or missing fields
|
||||
- invalid UTF-8
|
||||
- too long
|
||||
- outside the accepted timestamp window
|
||||
- terminated without a trailing newline
|
||||
- written with extra separators
|
||||
|
||||
Skipping a bad record is intentional recovery behavior. A truncated final
|
||||
line is treated as a partial append and ignored rather than replayed.
|
||||
|
||||
## Export
|
||||
|
||||
`dump [N]` and `dump -n N` export valid persisted records through the SSH exec
|
||||
interface and `tntctl`. The output format is exactly the v1 record format
|
||||
above. Without `N`, `dump` exports all valid records; with `N`, it exports the
|
||||
last `N` valid records.
|
||||
|
||||
## Maintenance
|
||||
|
||||
`scripts/logrotate.sh` is the manual archive and compaction tool for
|
||||
`messages.log`:
|
||||
|
||||
```sh
|
||||
scripts/logrotate.sh [--dry-run] [--keep-archives N] LOG_FILE MAX_SIZE_MB KEEP_LINES
|
||||
```
|
||||
|
||||
When the log exceeds `MAX_SIZE_MB`, the script archives the full file, compacts
|
||||
the active file to the last `KEEP_LINES` records, compresses the archive when
|
||||
`gzip` is available, and removes older archives beyond the retention limit.
|
||||
Run it while TNT is stopped or during a quiet maintenance window if strict log
|
||||
consistency matters.
|
||||
|
||||
## Recovery
|
||||
|
||||
Installed `tnt` binaries provide offline log checking and recovery:
|
||||
|
||||
```sh
|
||||
tnt --log-check LOG_FILE
|
||||
tnt --log-recover LOG_FILE > recovered.messages.log
|
||||
```
|
||||
|
||||
`--log-check` prints a summary:
|
||||
|
||||
```text
|
||||
path /var/lib/tnt/messages.log
|
||||
records_seen 120
|
||||
valid_records 119
|
||||
invalid_records 1
|
||||
first_invalid_line 120
|
||||
```
|
||||
|
||||
It exits `0` when every record is valid and `1` when invalid records are found
|
||||
or the log cannot be read. `--log-recover` writes only valid v1 records to
|
||||
stdout, prints the same summary to stderr, and also exits `1` if records were
|
||||
skipped. It never modifies the source log.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The v1 record format is stable for TNT 1.x. Future incompatible storage
|
||||
changes must document downgrade behavior in release notes and provide an
|
||||
operator-visible migration or export path.
|
||||
143
docs/MODULE_PROTOCOL.md
Normal file
143
docs/MODULE_PROTOCOL.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# TNT Module Protocol
|
||||
|
||||
This document defines the compatibility contract for external TNT modules.
|
||||
The first implementation target is external-process modules that exchange
|
||||
JSON Lines with TNT over stdin/stdout. Keeping modules out of the server
|
||||
address space makes the community extension surface easier to audit, restart,
|
||||
rate-limit, and disable.
|
||||
|
||||
The protocol is intentionally separate from `messages.log` v1. TNT 1.x keeps
|
||||
the persisted public history format stable. Module-generated content must
|
||||
always provide a plain-text fallback that can be stored and rendered by older
|
||||
or less capable clients.
|
||||
|
||||
TNT core should stay conservative: text-first, terminal-compatible, and easy
|
||||
to deploy over plain SSH. Modules are the extension surface for personalized
|
||||
workflow features, rich rendering, terminal-specific visuals, and other
|
||||
experience experiments. Integrating a module with TNT must not make plain
|
||||
terminal users lose the basic chat path.
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Protocol version: `tnt.module.v1`
|
||||
- Transport: UTF-8 JSON Lines
|
||||
- Framing: one complete JSON object per line
|
||||
- Direction: TNT sends events to module stdin; modules write responses to
|
||||
stdout
|
||||
- Error stream: modules should write diagnostics to stderr
|
||||
- License: module protocol examples and official community modules should use
|
||||
the same license as TNT unless a module states stricter terms
|
||||
|
||||
Modules are disabled unless `TNT_MODULE_PATHS` is set. The value is a
|
||||
colon-separated list of module directories, each containing `tnt-module.json`
|
||||
and the declared executable entrypoint.
|
||||
|
||||
TNT may add optional fields to existing messages. Modules must ignore unknown
|
||||
fields. TNT must ignore unknown response fields unless the response type
|
||||
explicitly requires them.
|
||||
|
||||
## Manifest
|
||||
|
||||
Each module directory should include `tnt-module.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "tnt.module.v1",
|
||||
"name": "echo",
|
||||
"version": "0.1.0",
|
||||
"description": "Echoes public messages for testing",
|
||||
"entrypoint": "./echo-module.sh",
|
||||
"permissions": ["message:read", "message:create"],
|
||||
"events": ["message.created"]
|
||||
}
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `protocol`: protocol compatibility string
|
||||
- `name`: stable module id, lowercase ASCII, `a-z`, `0-9`, and `-`
|
||||
- `version`: module version
|
||||
- `entrypoint`: executable path relative to the manifest directory
|
||||
- `permissions`: explicit capabilities requested by the module
|
||||
- `events`: event names the module wants to receive
|
||||
|
||||
## Handshake
|
||||
|
||||
TNT starts a module process and writes a handshake event:
|
||||
|
||||
```json
|
||||
{"type":"handshake","protocol":"tnt.module.v1","server":{"name":"tnt","version":"1.0.1"}}
|
||||
```
|
||||
|
||||
The module should answer:
|
||||
|
||||
```json
|
||||
{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"echo","version":"0.1.0"}}
|
||||
```
|
||||
|
||||
If the module cannot run, it should answer:
|
||||
|
||||
```json
|
||||
{"type":"error","code":"unsupported_protocol","message":"requires tnt.module.v2"}
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
Message-created event:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "message.created",
|
||||
"message": {
|
||||
"id": "local-00000001",
|
||||
"timestamp": "2026-06-04T12:00:00Z",
|
||||
"sender": "alice",
|
||||
"kind": "text",
|
||||
"plain_text": "hello",
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `plain_text` field is mandatory for every user-visible message. Future
|
||||
rich content, images, and terminal-specific render hints must be represented
|
||||
as optional metadata or attachment records with a plain-text fallback.
|
||||
|
||||
## Responses
|
||||
|
||||
Create a public message:
|
||||
|
||||
```json
|
||||
{"type":"message.create","plain_text":"echo: hello"}
|
||||
```
|
||||
|
||||
No-op acknowledgement:
|
||||
|
||||
```json
|
||||
{"type":"event.ok"}
|
||||
```
|
||||
|
||||
Module error:
|
||||
|
||||
```json
|
||||
{"type":"error","code":"bad_request","message":"missing plain_text"}
|
||||
```
|
||||
|
||||
## Security Rules
|
||||
|
||||
- Modules are untrusted external processes.
|
||||
- TNT should enforce per-module permissions before delivering events or
|
||||
accepting responses.
|
||||
- TNT should cap stdout line length, startup time, event handling time, and
|
||||
total queued output.
|
||||
- TNT should disable a module after repeated invalid JSON, protocol errors, or
|
||||
timeout failures.
|
||||
- Modules must never receive private messages unless they request and are
|
||||
granted an explicit private-message permission.
|
||||
|
||||
## Rendering Rules
|
||||
|
||||
Every module-created message must be renderable as plain text. Terminal image
|
||||
protocols such as Kitty graphics or Sixel are optional renderer capabilities,
|
||||
not message requirements. A module may provide attachment metadata later, but
|
||||
TNT must be able to fall back to a link, filename, digest, or short label.
|
||||
|
|
@ -15,6 +15,9 @@ TEST
|
|||
make connection-limit-test per-IP concurrency/rate-limit checks
|
||||
make security-test security feature checks
|
||||
make stress-test concurrent-client stress test
|
||||
make soak-test idle/reconnect/control-plane soak test
|
||||
make slow-client-test slow interactive-client backpressure test
|
||||
make user-lifecycle-test two-user TUI lifecycle test
|
||||
make ci-test same checks as GitHub Actions
|
||||
|
||||
DEBUG
|
||||
|
|
@ -25,13 +28,17 @@ DEBUG
|
|||
COMMANDS (COMMAND mode, prefix with :)
|
||||
list, users, who show online users
|
||||
nick <name> change nickname
|
||||
msg <user> <text> whisper to user
|
||||
msg <user> <message> send private message
|
||||
w <user> <text> alias for msg
|
||||
reply <text> reply to latest private message
|
||||
r <text> alias for reply
|
||||
inbox show private messages, newest first
|
||||
inbox clear clear private messages for this session
|
||||
last [N] last N messages from log (default 10, max 50)
|
||||
search <keyword> search full history (case-insensitive, 15 results)
|
||||
mute-joins toggle join/leave notifications
|
||||
support quick support guide
|
||||
help show all commands
|
||||
help concise manual
|
||||
lang [en|zh] show or switch UI language
|
||||
clear clear output
|
||||
q / quit / exit disconnect
|
||||
|
||||
|
|
@ -41,20 +48,49 @@ INSERT MODE
|
|||
paste multi-line paste stays in the input buffer
|
||||
limit 1023 bytes/message; over-limit input rings bell
|
||||
normal opens/follows latest; k/PgUp older, j/PgDn newer
|
||||
insert aliases i/a/o enter INSERT mode from NORMAL
|
||||
|
||||
EXEC COMMANDS
|
||||
health print service health
|
||||
stats [--json] print room statistics
|
||||
users [--json] list online users
|
||||
tail [N] / tail -n N recent in-memory room messages
|
||||
dump [N] / dump -n N persisted messages.log v1 records
|
||||
post <message> post as the SSH login name
|
||||
|
||||
MAINTENANCE
|
||||
scripts/logrotate.sh LOG_FILE MAX_SIZE_MB KEEP_LINES
|
||||
archive and compact messages.log
|
||||
scripts/logrotate.sh --dry-run ...
|
||||
preview log maintenance actions
|
||||
tnt --log-check LOG_FILE audit messages.log v1 records
|
||||
tnt --log-recover LOG_FILE > OUT
|
||||
write valid records to stdout
|
||||
|
||||
STRUCTURE
|
||||
src/main.c entry, signals
|
||||
src/cli_text.c startup CLI text
|
||||
src/tntctl_text.c tntctl local help and diagnostics
|
||||
src/command_catalog.c command metadata, usage, argument shape
|
||||
src/ssh_server.c SSH listener and server setup
|
||||
src/bootstrap.c SSH auth/session bootstrap
|
||||
src/chat_room.c broadcast and room state
|
||||
src/commands.c COMMAND-mode command dispatch
|
||||
src/exec_catalog.c SSH exec command matching, usage, argument shape
|
||||
src/exec.c SSH exec command dispatch
|
||||
src/json_text.c JSON string escape/extract helpers
|
||||
src/input_buffer.c validated INSERT/COMMAND/paste buffer helpers
|
||||
src/message.c persistence, search
|
||||
src/message_log.c messages.log v1 parsing and formatting
|
||||
src/message_log_tool.c offline messages.log check/recover CLI
|
||||
src/module_protocol.c external module JSONL protocol helpers
|
||||
src/module_runtime.c optional external module supervisor
|
||||
src/history_view.c message viewport / scroll state
|
||||
src/help_text.c full-screen and command help text
|
||||
src/support_text.c quick support guide content
|
||||
src/i18n.c language selection and shared text
|
||||
src/help_text.c full-screen key reference text
|
||||
src/manual.c concise manual panel rendering
|
||||
src/manual_text.c concise manual content
|
||||
src/i18n.c UI language and locale selection
|
||||
src/i18n_text.c shared UI text catalog
|
||||
src/ratelimit.c connection limits and rate limiting
|
||||
src/tui.c rendering
|
||||
src/tui_status.c status/input line rendering
|
||||
|
|
@ -66,7 +102,7 @@ LIMITS
|
|||
1024 bytes/message
|
||||
|
||||
FILES
|
||||
messages.log chat log (RFC3339)
|
||||
messages.log public chat log (RFC3339; excludes private messages)
|
||||
host_key SSH key (auto-generated)
|
||||
motd.txt message of the day (optional)
|
||||
CHANGELOG.md version history
|
||||
|
|
|
|||
104
docs/ROADMAP.md
104
docs/ROADMAP.md
|
|
@ -17,65 +17,100 @@ This roadmap is intentionally strict. Each stage should leave the project easier
|
|||
|
||||
Goal: make TNT predictable for operators, scripts, and package maintainers.
|
||||
|
||||
- split the current surface into `tntd` (daemon) and `tntctl` (control client)
|
||||
- keep SSH exec support, but treat it as a transport for stable commands rather than the primary API shape
|
||||
- define stable subcommands and exit codes for:
|
||||
- ✅ introduce `tntctl` as a thin control client over the stable SSH exec surface
|
||||
- keep SSH exec support, but treat it as a transport for stable commands rather
|
||||
than an ad hoc command surface
|
||||
- ✅ define stable subcommands and exit codes for:
|
||||
- `health`
|
||||
- `stats`
|
||||
- `users`
|
||||
- `tail`
|
||||
- `dump`
|
||||
- `post`
|
||||
- support text and JSON output modes where machine use is likely
|
||||
- normalize command parsing, help text, and error reporting
|
||||
- add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently
|
||||
- add a man page for `tntd` and `tntctl`
|
||||
- ✅ support text and JSON output modes where machine use is likely
|
||||
- ✅ normalize command parsing, help text, and error reporting
|
||||
- ✅ keep `tnt` as the 1.x server binary; reserve any future `tntd` split for a
|
||||
major-version compatibility plan
|
||||
- ✅ add `--bind`, `--port`, `--state-dir`, `--public-host`,
|
||||
`--max-connections`, and related long options consistently
|
||||
- ✅ add man pages for `tnt` and `tntctl`
|
||||
|
||||
## Stage 2: Runtime Model
|
||||
|
||||
Goal: make long-running operation boring and reliable.
|
||||
|
||||
- move client state to a clearer ownership model with one release path
|
||||
- finish replacing ad hoc cross-thread UI mutation with per-client event delivery
|
||||
- add bounded outbound queues so slow clients cannot stall other users
|
||||
- ✅ move session callback ownership into `client.c` and release sessions
|
||||
through one `client_release_session()` path
|
||||
- ✅ remove cross-client SSH channel writes from mention and private-message
|
||||
notifications
|
||||
- continue replacing ad hoc cross-thread UI mutation with per-client event
|
||||
delivery where new features need cross-client notifications
|
||||
- ✅ add bounded outbound queues so closed SSH windows cannot immediately stall
|
||||
interactive output writes
|
||||
- separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly
|
||||
- make room/client capacity fully runtime-configurable with no hidden compile-time ceiling
|
||||
- document hard guarantees and soft limits
|
||||
- ✅ make room/client capacity fully runtime-configurable with no hidden
|
||||
compile-time ceiling
|
||||
- ✅ document hard guarantees and soft limits
|
||||
|
||||
## Stage 3: Data and Persistence
|
||||
|
||||
Goal: make stored history durable, inspectable, and recoverable.
|
||||
|
||||
- formalize the message log format and version it
|
||||
- keep timestamps in a timezone-safe format throughout write and replay
|
||||
- validate persisted UTF-8 and record structure before replay
|
||||
- add log rotation and compaction tooling
|
||||
- provide an offline inspection/export command
|
||||
- define recovery behavior for truncated or partially corrupted logs
|
||||
- ✅ formalize the message log v1 format
|
||||
- ✅ keep persisted timestamps in UTC throughout write and replay
|
||||
- ✅ validate persisted UTF-8 and record structure before replay/search
|
||||
- ✅ provide an inspection/export command for persisted records
|
||||
- ✅ add log rotation and compaction tooling
|
||||
- ✅ define broader recovery tooling for truncated or partially corrupted logs
|
||||
|
||||
## Stage 4: Interactive UX
|
||||
|
||||
Goal: keep the interface efficient for terminal users without sacrificing simplicity.
|
||||
|
||||
- keep the current modal editing model, but make its behavior precise and documented
|
||||
- support resize, cursor movement, command history, and predictable paste behavior
|
||||
- ✅ keep the current modal editing model precise and documented
|
||||
- ✅ support resize, command history, pager navigation, and predictable paste
|
||||
behavior
|
||||
- add in-line cursor movement/editing only if it can stay simple and testable
|
||||
- add useful chat commands with clear semantics:
|
||||
- ✅ `:nick` / `:name` — nickname change with broadcast
|
||||
- ✅ `/me` — action messages
|
||||
- ✅ `:last N` — show last N messages from disk history
|
||||
- ✅ `:search <keyword>` — case-insensitive full-text search
|
||||
- ✅ `:mute-joins` — per-client join/leave notification toggle
|
||||
- improve discoverability of NORMAL and COMMAND mode actions
|
||||
- make status lines and help output concise enough for small terminals
|
||||
- ✅ improve discoverability of NORMAL and COMMAND mode actions
|
||||
- ✅ make status lines and help output concise enough for small terminals
|
||||
|
||||
## Stage 4.5: Module Foundation
|
||||
|
||||
Goal: let community features plug into TNT without coupling every user request
|
||||
to the core server binary.
|
||||
|
||||
- keep TNT core basic and broadly compatible; route personalized workflows,
|
||||
rich visuals, and terminal-specific experience upgrades through modules
|
||||
- define the external-process module protocol before loading any third-party
|
||||
code into production rooms
|
||||
- keep module messages compatible with plain terminal clients by requiring
|
||||
plain-text fallbacks for rich content and attachments
|
||||
- treat terminal image protocols as optional renderer capabilities, not as the
|
||||
core message format
|
||||
- prefer JSON Lines over stdin/stdout for early modules so TNT can supervise,
|
||||
restart, rate-limit, and disable modules independently
|
||||
- keep module permissions explicit: message read/create, command registration,
|
||||
private-message access, and future attachment access must be separate grants
|
||||
- publish official examples in a companion community repository that tracks
|
||||
TNT protocol versions and license terms
|
||||
|
||||
## Stage 5: Operations and Security
|
||||
|
||||
Goal: make public deployment manageable.
|
||||
|
||||
- provide clear distinction between concurrent session limits and connection-rate limits
|
||||
- ✅ provide clear distinction between concurrent session limits and
|
||||
connection-rate limits
|
||||
- add admin-only controls for read-only mode, mute, and ban
|
||||
- expose a minimal health and stats surface suitable for monitoring
|
||||
- ✅ expose a minimal health and stats surface suitable for monitoring
|
||||
- support systemd-friendly readiness and watchdog behavior
|
||||
- document recommended production defaults for public, private, and localhost-only deployments
|
||||
- ✅ document recommended production defaults for public, private, and
|
||||
localhost-only deployments
|
||||
- tighten CI around authentication, limits, and restart behavior
|
||||
|
||||
## Stage 6: Release Quality
|
||||
|
|
@ -84,7 +119,13 @@ Goal: make regressions harder to introduce.
|
|||
|
||||
- expand CI coverage across Linux and macOS for build and smoke tests
|
||||
- add sanitizer jobs and targeted fuzzing for UTF-8, log parsing, and command parsing
|
||||
- add soak tests for long-lived sessions and slow-client behavior
|
||||
- ✅ add a configurable soak test for idle sessions, reconnects, and control
|
||||
interface availability
|
||||
- ✅ add deeper slow-client coverage with a deliberately backpressured SSH
|
||||
client
|
||||
- ✅ verify staged package installs, systemd unit paths, packaging metadata,
|
||||
Debian source assembly, Homebrew service metadata, and installed log
|
||||
maintenance modes in release preflight
|
||||
- keep deployment and test docs aligned with actual runtime behavior
|
||||
- require every user-visible interface change to update docs and tests in the same change set
|
||||
|
||||
|
|
@ -92,8 +133,9 @@ Goal: make regressions harder to introduce.
|
|||
|
||||
These are the next changes that should happen before new feature work expands the surface area.
|
||||
|
||||
1. Introduce `tntctl` and move stable command handling behind it.
|
||||
2. Define exit codes and JSON schemas for `health`, `stats`, `users`, `tail`, and `post`.
|
||||
3. Add per-client outbound queues and finish untangling client-state ownership.
|
||||
4. Remove the remaining hidden runtime limits and make them explicit configuration.
|
||||
5. Add a long-running soak test that exercises idle sessions, reconnects, and slow consumers.
|
||||
1. Replace remaining source-archive checksum placeholders only after the
|
||||
explicit release source archive exists, then run `make package-publish-check`.
|
||||
2. Create or move the `vX.Y.Z` tag only when the release commit is final, then
|
||||
run `make release-check-strict` before pushing it.
|
||||
3. Decide whether admin-only moderation controls belong in 1.0.x or should
|
||||
wait for a later minor release.
|
||||
|
|
|
|||
67
docs/USER_LIFECYCLE.md
Normal file
67
docs/USER_LIFECYCLE.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# User Lifecycle
|
||||
|
||||
TNT solves one narrow problem: create a keyboard-first chat room that anyone
|
||||
with an SSH client can join without installing a custom client.
|
||||
|
||||
The product path should stay short:
|
||||
|
||||
1. Operator installs `tnt`, chooses a state directory, and starts the server.
|
||||
2. User connects with `ssh -p 2222 host`.
|
||||
3. User picks a display name or presses Enter for `anonymous`.
|
||||
4. User lands in INSERT mode at the live tail and can type immediately.
|
||||
5. User presses Esc to browse history with Vim-style movement.
|
||||
6. User uses `:help` for the concise manual or `?` for the full key reference.
|
||||
7. User searches from NORMAL with `/term`, or uses commands when needed:
|
||||
`:users`, `:msg`, `:reply`, `:inbox`, `:last`, `:search`, `:nick`,
|
||||
`:mute-joins`, and `:q`.
|
||||
8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
|
||||
`stats`, `users`, `tail`, `dump`, and `post`.
|
||||
|
||||
## TUI Experience Notes
|
||||
|
||||
- The first screen should make the product legible without reading external
|
||||
docs: this is an SSH chat room, not a shell.
|
||||
- INSERT mode is the default because most users arrive to send a message.
|
||||
- NORMAL mode opens at the latest messages, not the oldest history. Users can
|
||||
move upward for older context and use `G` or End to return to live chat.
|
||||
- NORMAL mode accepts `/` as the fast path for history search, matching a
|
||||
common terminal-reader habit while reusing the existing `:search` command.
|
||||
- INSERT mode keeps a small per-session sent-message history on Up/Down and
|
||||
completes trailing `@mention` prefixes with Tab.
|
||||
- `:help` is a compact manual, while `?` is a full key reference. Do not add
|
||||
parallel support commands for the same task.
|
||||
- Command syntax stays ASCII even in localized UI text. Translations explain;
|
||||
they do not change the command language.
|
||||
- Private messages are visible in each participant's in-memory `:inbox`:
|
||||
recipients see incoming messages, senders see local sent-message copies,
|
||||
newest first. They are not written to `messages.log` and do not survive a
|
||||
reconnect.
|
||||
- `:inbox` is live enough for normal chat use: it can be refreshed with `r`
|
||||
and refreshes automatically when a new private message arrives while the
|
||||
inbox is open. Incoming unread messages are marked with `*` and counted in
|
||||
the inbox title until the inbox renders them. `:inbox clear` removes private
|
||||
messages and the reply target for the current session.
|
||||
- `:reply` / `:r` keeps the private-message path keyboard-short: it answers
|
||||
the latest private-message peer in the current session without retyping a
|
||||
username.
|
||||
- Long command output uses a small pager so `:last` and `:search` are readable
|
||||
on small terminals.
|
||||
|
||||
## Regression Coverage
|
||||
|
||||
`make user-lifecycle-test` runs a two-user SSH TUI journey:
|
||||
|
||||
- second user joins and is visible through `users --json`
|
||||
- first user opens `?`, checks `:users`, sends a public message, scrolls, uses
|
||||
`:last` and `:search`
|
||||
- first user toggles `:mute-joins`, sends two `:msg` messages, receives a
|
||||
`:reply`, confirms private-message copies in `:inbox`, clears the inbox,
|
||||
changes nickname, sends `/me`, and exits
|
||||
- second user opens `:inbox` before the private messages arrive, sees it
|
||||
auto-refresh after delivery, newest first, and replies without retyping the
|
||||
sender's username
|
||||
- exec `tail` sees public messages
|
||||
- `messages.log` contains public history and excludes private-message content
|
||||
|
||||
This test is intentionally closer to a user story than a unit regression. Keep
|
||||
it focused on lifecycle guarantees, not every keybinding.
|
||||
|
|
@ -4,9 +4,11 @@
|
|||
#include "common.h"
|
||||
|
||||
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||
const char *program_name, help_lang_t lang);
|
||||
const char *cli_text_invalid_port_format(help_lang_t lang);
|
||||
const char *cli_text_unknown_option_format(help_lang_t lang);
|
||||
const char *cli_text_short_usage_format(help_lang_t lang);
|
||||
const char *program_name, ui_lang_t lang);
|
||||
const char *cli_text_invalid_port_format(ui_lang_t lang);
|
||||
const char *cli_text_invalid_value_format(ui_lang_t lang);
|
||||
const char *cli_text_option_requires_arg_format(ui_lang_t lang);
|
||||
const char *cli_text_unknown_option_format(ui_lang_t lang);
|
||||
const char *cli_text_short_usage_format(ui_lang_t lang);
|
||||
|
||||
#endif /* CLI_TEXT_H */
|
||||
|
|
|
|||
|
|
@ -3,11 +3,28 @@
|
|||
|
||||
#include "ssh_server.h" /* for client_t */
|
||||
|
||||
/* Send `len` bytes to the client over its SSH channel. Serialised on
|
||||
* client->io_lock so concurrent senders don't interleave. Returns 0 on
|
||||
* success, -1 if the channel is gone or a partial write fails. */
|
||||
/* Send `len` bytes to the client over its SSH channel.
|
||||
*
|
||||
* Exec sessions write synchronously so command output and exit status remain
|
||||
* ordered. Interactive sessions enqueue into a bounded per-client outbox and
|
||||
* flush opportunistically from the same client's session loop, so a closed SSH
|
||||
* window cannot block unrelated room activity. Returns -1 if the channel is
|
||||
* gone, a write fails, or the bounded outbox is full. */
|
||||
int client_send(client_t *client, const char *data, size_t len);
|
||||
|
||||
/* Flush queued interactive output for this client. Returns 0 when all
|
||||
* possible progress was made; queued bytes may remain if the remote SSH window
|
||||
* is currently closed. */
|
||||
int client_flush_output(client_t *client);
|
||||
|
||||
/* Queue an audible bell for the client's own session loop to send. This
|
||||
* avoids writing to another client's SSH channel from the sender's thread. */
|
||||
void client_queue_bell(client_t *client);
|
||||
|
||||
/* Send one queued bell, if present, from the client's own session loop.
|
||||
* Returns 0 when no bell was pending or it was written successfully. */
|
||||
int client_flush_pending_bells(client_t *client);
|
||||
|
||||
/* printf-style wrapper around client_send(). The formatted string must
|
||||
* fit in 2048 bytes; truncation or encoding errors return -1. */
|
||||
int client_printf(client_t *client, const char *fmt, ...);
|
||||
|
|
@ -15,20 +32,18 @@ int client_printf(client_t *client, const char *fmt, ...);
|
|||
/* Reference counting for safe cross-thread cleanup.
|
||||
*
|
||||
* Lifecycle: bootstrap_run() creates the client_t with ref_count = 1
|
||||
* (the "main" ref), then adds a second ref before installing the channel
|
||||
* callbacks (the "callback" ref) so the client outlives any in-flight
|
||||
* eof / close / window-change callback invocation. The interactive
|
||||
* session releases both refs in its cleanup path; the final release
|
||||
* frees the SSH session, channel, callback struct, and the client_t. */
|
||||
* (the "main" ref). client_install_channel_callbacks() takes a second
|
||||
* ref owned by client.c while channel callbacks are installed, so the
|
||||
* client outlives in-flight eof / close / window-change callbacks.
|
||||
* input_run_session() ends ownership with client_release_session(). */
|
||||
void client_addref(client_t *client);
|
||||
void client_release(client_t *client);
|
||||
void client_release_session(client_t *client);
|
||||
|
||||
/* Install the post-bootstrap channel callbacks (window-change, eof, close)
|
||||
* that target this client_t. Caller MUST have already added one
|
||||
* client_addref() to keep the client alive across in-flight callback
|
||||
* invocations; the matching client_release() happens during cleanup in
|
||||
* input_run_session(). Returns 0 on success, -1 on failure (in which
|
||||
* case the caller still owns both refs and must release them). */
|
||||
/* Install the post-bootstrap channel callbacks (window-change, eof, close).
|
||||
* On success this function takes the callback reference described above.
|
||||
* On failure no callback reference remains and the caller still owns only
|
||||
* its original main reference. */
|
||||
int client_install_channel_callbacks(client_t *client);
|
||||
|
||||
#endif /* CLIENT_H */
|
||||
|
|
|
|||
40
include/command_catalog.h
Normal file
40
include/command_catalog.h
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#ifndef COMMAND_CATALOG_H
|
||||
#define COMMAND_CATALOG_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
typedef enum {
|
||||
TNT_COMMAND_USERS,
|
||||
TNT_COMMAND_HELP,
|
||||
TNT_COMMAND_LANG,
|
||||
TNT_COMMAND_MSG,
|
||||
TNT_COMMAND_REPLY,
|
||||
TNT_COMMAND_INBOX,
|
||||
TNT_COMMAND_NICK,
|
||||
TNT_COMMAND_LAST,
|
||||
TNT_COMMAND_SEARCH,
|
||||
TNT_COMMAND_MUTE_JOINS,
|
||||
TNT_COMMAND_QUIT,
|
||||
TNT_COMMAND_CLEAR,
|
||||
TNT_COMMAND_COUNT
|
||||
} tnt_command_id_t;
|
||||
|
||||
typedef struct {
|
||||
tnt_command_id_t id;
|
||||
const char *canonical;
|
||||
const char *names[4];
|
||||
} tnt_command_spec_t;
|
||||
|
||||
const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id);
|
||||
bool command_catalog_match(const char *line, tnt_command_id_t *id,
|
||||
const char **args);
|
||||
bool command_catalog_args_valid(tnt_command_id_t id, const char *args);
|
||||
const char *command_catalog_suggest(const char *name);
|
||||
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang);
|
||||
void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang);
|
||||
void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||
tnt_command_id_t id, ui_lang_t lang);
|
||||
|
||||
#endif /* COMMAND_CATALOG_H */
|
||||
|
|
@ -15,9 +15,13 @@
|
|||
* - Toggles client->mute_joins on `:mute-joins`
|
||||
* - May broadcast a system rename message on `:nick`
|
||||
*
|
||||
* Reads g_room. Caller must already hold the channel I/O serialisation
|
||||
* established by handle_key() — this function calls back into client_send
|
||||
* (via tui_render_command_output) which acquires client->io_lock. */
|
||||
* Reads g_room. Renders command output through the normal client_send()
|
||||
* path; callers must not hold client->io_lock before dispatching. */
|
||||
void commands_dispatch(client_t *client);
|
||||
|
||||
/* Rebuild the currently visible command output when it is backed by live
|
||||
* client state, such as :inbox. Returns true if output changed and the caller
|
||||
* should render it again. */
|
||||
bool commands_refresh_active_output(client_t *client);
|
||||
|
||||
#endif /* COMMANDS_H */
|
||||
|
|
|
|||
|
|
@ -11,21 +11,38 @@
|
|||
#include <limits.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include "config_defaults.h"
|
||||
|
||||
/* Project Metadata */
|
||||
#define TNT_VERSION "1.0.1"
|
||||
|
||||
/* Public process/exec exit statuses. TNT follows the common sysexits(3)
|
||||
* convention for usage errors while keeping runtime failures portable. */
|
||||
#define TNT_EXIT_OK 0
|
||||
#define TNT_EXIT_ERROR 1
|
||||
#define TNT_EXIT_USAGE 64
|
||||
#define TNT_EXIT_UNAVAILABLE 69
|
||||
#define TNT_EXIT_CONFIG 78
|
||||
|
||||
/* Configuration constants */
|
||||
#define DEFAULT_PORT 2222
|
||||
#define MAX_MESSAGES 100
|
||||
#define MAX_USERNAME_LEN 64
|
||||
#define MAX_MESSAGE_LEN 1024
|
||||
#define MAX_EXEC_COMMAND_LEN 1024
|
||||
#define MAX_CLIENTS 64
|
||||
#define MAX_COMMAND_OUTPUT_LEN 8192
|
||||
#define CLIENT_OUTBOX_CAPACITY (128 * 1024)
|
||||
#define CLIENT_OUTBOX_FLUSH_BUDGET 32768
|
||||
#define LOG_FILE "messages.log"
|
||||
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
|
||||
#define HOST_KEY_FILE "host_key"
|
||||
#define TNT_DEFAULT_STATE_DIR "."
|
||||
#define DEFAULT_IDLE_TIMEOUT 1800 /* 30 minutes */
|
||||
|
||||
/* Backward-compatible names for older modules while config_defaults owns the
|
||||
* actual runtime defaults and accepted ranges. */
|
||||
#define DEFAULT_PORT TNT_DEFAULT_PORT
|
||||
#define DEFAULT_MAX_CLIENTS TNT_DEFAULT_MAX_CONNECTIONS
|
||||
#define MAX_CONFIGURED_CLIENTS TNT_MAX_CONFIGURED_CLIENTS
|
||||
#define DEFAULT_IDLE_TIMEOUT TNT_DEFAULT_IDLE_TIMEOUT
|
||||
|
||||
/* ANSI color codes */
|
||||
#define ANSI_RESET "\033[0m"
|
||||
|
|
@ -39,15 +56,15 @@
|
|||
typedef enum {
|
||||
MODE_INSERT,
|
||||
MODE_NORMAL,
|
||||
MODE_COMMAND,
|
||||
MODE_HELP
|
||||
MODE_COMMAND
|
||||
} client_mode_t;
|
||||
|
||||
/* Help language */
|
||||
/* UI language */
|
||||
typedef enum {
|
||||
LANG_EN,
|
||||
LANG_ZH
|
||||
} help_lang_t;
|
||||
UI_LANG_EN,
|
||||
UI_LANG_ZH,
|
||||
UI_LANG_COUNT
|
||||
} ui_lang_t;
|
||||
|
||||
/* Runtime helpers */
|
||||
const char* tnt_state_dir(void);
|
||||
|
|
|
|||
47
include/config_defaults.h
Normal file
47
include/config_defaults.h
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#ifndef CONFIG_DEFAULTS_H
|
||||
#define CONFIG_DEFAULTS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#define TNT_STRINGIFY_VALUE(value) #value
|
||||
#define TNT_STRINGIFY(value) TNT_STRINGIFY_VALUE(value)
|
||||
|
||||
#define TNT_DEFAULT_PORT 2222
|
||||
#define TNT_DEFAULT_PORT_TEXT TNT_STRINGIFY(TNT_DEFAULT_PORT)
|
||||
#define TNT_DEFAULT_MAX_CONNECTIONS 64
|
||||
#define TNT_DEFAULT_MAX_CONN_PER_IP 5
|
||||
#define TNT_DEFAULT_MAX_CONN_RATE_PER_IP 10
|
||||
#define TNT_DEFAULT_RATE_LIMIT_ENABLED 1
|
||||
#define TNT_DEFAULT_IDLE_TIMEOUT 1800
|
||||
|
||||
#define TNT_MIN_PORT 1
|
||||
#define TNT_MAX_PORT 65535
|
||||
#define TNT_MIN_CONFIGURED_CLIENTS 1
|
||||
#define TNT_MAX_CONFIGURED_CLIENTS 1024
|
||||
#define TNT_MIN_RATE_LIMIT_ENABLED 0
|
||||
#define TNT_MAX_RATE_LIMIT_ENABLED 1
|
||||
#define TNT_MIN_IDLE_TIMEOUT 0
|
||||
#define TNT_MAX_IDLE_TIMEOUT 86400
|
||||
#define TNT_MIN_SSH_LOG_LEVEL 0
|
||||
#define TNT_MAX_SSH_LOG_LEVEL 4
|
||||
|
||||
typedef struct {
|
||||
const char *env_name;
|
||||
int fallback;
|
||||
int min_value;
|
||||
int max_value;
|
||||
} tnt_int_config_spec_t;
|
||||
|
||||
extern const tnt_int_config_spec_t TNT_CONFIG_PORT;
|
||||
extern const tnt_int_config_spec_t TNT_CONFIG_MAX_CONNECTIONS;
|
||||
extern const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_PER_IP;
|
||||
extern const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_RATE_PER_IP;
|
||||
extern const tnt_int_config_spec_t TNT_CONFIG_RATE_LIMIT;
|
||||
extern const tnt_int_config_spec_t TNT_CONFIG_IDLE_TIMEOUT;
|
||||
extern const tnt_int_config_spec_t TNT_CONFIG_SSH_LOG_LEVEL;
|
||||
|
||||
int tnt_config_env_int(const tnt_int_config_spec_t *spec);
|
||||
bool tnt_config_parse_int(const char *value, const tnt_int_config_spec_t *spec,
|
||||
int *out);
|
||||
|
||||
#endif /* CONFIG_DEFAULTS_H */
|
||||
|
|
@ -6,9 +6,9 @@
|
|||
/* Dispatch the non-interactive SSH exec command stored in
|
||||
* client->exec_command. Returns the exit status to send back to the
|
||||
* SSH client:
|
||||
* 0 = success
|
||||
* 1 = runtime error (I/O, OOM, persistence failure)
|
||||
* 64 = usage error (unknown command, bad args)
|
||||
* TNT_EXIT_OK = success
|
||||
* TNT_EXIT_ERROR = runtime error (I/O, OOM, persistence failure)
|
||||
* TNT_EXIT_USAGE = usage error (unknown command, bad args)
|
||||
*
|
||||
* Reads g_room and shared client state. Safe to call once per
|
||||
* exec-mode session before the channel is closed. */
|
||||
|
|
|
|||
28
include/exec_catalog.h
Normal file
28
include/exec_catalog.h
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#ifndef EXEC_CATALOG_H
|
||||
#define EXEC_CATALOG_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
typedef enum {
|
||||
TNT_EXEC_COMMAND_HELP,
|
||||
TNT_EXEC_COMMAND_HEALTH,
|
||||
TNT_EXEC_COMMAND_USERS,
|
||||
TNT_EXEC_COMMAND_STATS,
|
||||
TNT_EXEC_COMMAND_TAIL,
|
||||
TNT_EXEC_COMMAND_DUMP,
|
||||
TNT_EXEC_COMMAND_POST,
|
||||
TNT_EXEC_COMMAND_EXIT,
|
||||
TNT_EXEC_COMMAND_COUNT
|
||||
} tnt_exec_command_id_t;
|
||||
|
||||
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
|
||||
const char **args);
|
||||
bool exec_catalog_args_valid(tnt_exec_command_id_t id, const char *args);
|
||||
void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang);
|
||||
void exec_catalog_append_command_list(char *buffer, size_t buf_size,
|
||||
size_t *pos);
|
||||
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||
tnt_exec_command_id_t id, ui_lang_t lang);
|
||||
|
||||
#endif /* EXEC_CATALOG_H */
|
||||
|
|
@ -3,8 +3,7 @@
|
|||
|
||||
#include "common.h"
|
||||
|
||||
const char *help_text_full(help_lang_t lang);
|
||||
void help_text_append_commands(char *output, size_t buf_size, size_t *pos,
|
||||
help_lang_t lang);
|
||||
void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang);
|
||||
|
||||
#endif /* HELP_TEXT_H */
|
||||
|
|
|
|||
|
|
@ -3,6 +3,17 @@
|
|||
|
||||
#include "common.h"
|
||||
|
||||
typedef struct {
|
||||
const char *text[UI_LANG_COUNT];
|
||||
} i18n_string_t;
|
||||
|
||||
#define I18N_LANG_TEXT(lang, value) [lang] = (value)
|
||||
#define I18N_EN(value) I18N_LANG_TEXT(UI_LANG_EN, value)
|
||||
#define I18N_ZH(value) I18N_LANG_TEXT(UI_LANG_ZH, value)
|
||||
#define I18N_STRING_MAP(...) {{ __VA_ARGS__ }}
|
||||
#define I18N_STRING(en_text, zh_text) \
|
||||
I18N_STRING_MAP(I18N_EN(en_text), I18N_ZH(zh_text))
|
||||
|
||||
typedef enum {
|
||||
I18N_USERNAME_PROMPT,
|
||||
I18N_INVALID_USERNAME,
|
||||
|
|
@ -17,31 +28,37 @@ typedef enum {
|
|||
I18N_HELP_TITLE,
|
||||
I18N_HELP_STATUS_FORMAT,
|
||||
I18N_COMMAND_OUTPUT_TITLE,
|
||||
I18N_COMMAND_OUTPUT_STATUS_FORMAT,
|
||||
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT,
|
||||
I18N_MOTD_TITLE,
|
||||
I18N_MOTD_CONTINUE_HINT,
|
||||
I18N_TITLE_ONLINE_FORMAT,
|
||||
I18N_TITLE_MUTED,
|
||||
I18N_TITLE_HELP_HINT,
|
||||
I18N_EMPTY_ROOM,
|
||||
I18N_EMPTY_FILTERED,
|
||||
I18N_IDLE_TIMEOUT_FORMAT,
|
||||
I18N_SYSTEM_USERNAME,
|
||||
I18N_SYSTEM_JOIN_FORMAT,
|
||||
I18N_SYSTEM_LEAVE_FORMAT,
|
||||
I18N_SYSTEM_NICK_FORMAT,
|
||||
I18N_USERS_TITLE,
|
||||
I18N_MSG_USAGE,
|
||||
I18N_MSG_SENT_FORMAT,
|
||||
I18N_MSG_USER_NOT_FOUND_FORMAT,
|
||||
I18N_REPLY_NO_TARGET,
|
||||
I18N_INBOX_TITLE,
|
||||
I18N_INBOX_EMPTY,
|
||||
I18N_NICK_USAGE,
|
||||
I18N_INBOX_SENT_TO_FORMAT,
|
||||
I18N_INBOX_CLEARED,
|
||||
I18N_INBOX_UNREAD_FORMAT,
|
||||
I18N_NICK_INVALID,
|
||||
I18N_NICK_TAKEN_FORMAT,
|
||||
I18N_NICK_UNCHANGED,
|
||||
I18N_NICK_CHANGED_FORMAT,
|
||||
I18N_LAST_USAGE,
|
||||
I18N_LAST_HEADER_FORMAT,
|
||||
I18N_SEARCH_USAGE,
|
||||
I18N_LAST_EMPTY,
|
||||
I18N_SEARCH_HEADER_FORMAT,
|
||||
I18N_SEARCH_EMPTY,
|
||||
I18N_MUTE_JOINS_FORMAT,
|
||||
I18N_MUTE_JOINS_MUTED,
|
||||
I18N_MUTE_JOINS_UNMUTED,
|
||||
|
|
@ -52,21 +69,33 @@ typedef enum {
|
|||
I18N_UNKNOWN_COMMAND_FORMAT,
|
||||
I18N_DID_YOU_MEAN_FORMAT,
|
||||
I18N_UNKNOWN_GUIDANCE,
|
||||
I18N_EXEC_HELP,
|
||||
I18N_EXEC_USERS_USAGE,
|
||||
I18N_EXEC_STATS_USAGE,
|
||||
I18N_EXEC_TAIL_USAGE,
|
||||
I18N_EXEC_POST_USAGE,
|
||||
I18N_EXEC_POST_EMPTY,
|
||||
I18N_EXEC_POST_INVALID_UTF8,
|
||||
I18N_EXEC_POST_TOO_LONG,
|
||||
I18N_EXEC_POST_PERSIST_FAILED,
|
||||
I18N_EXEC_COMMAND_TOO_LONG,
|
||||
I18N_EXEC_UNKNOWN_COMMAND_FORMAT,
|
||||
I18N_CONTINUE_PROMPT
|
||||
I18N_TEXT_COUNT
|
||||
} i18n_text_id_t;
|
||||
|
||||
bool i18n_try_parse_lang(const char *value, help_lang_t *lang);
|
||||
help_lang_t i18n_parse_lang(const char *value, help_lang_t fallback);
|
||||
help_lang_t i18n_default_lang(void);
|
||||
const char *i18n_lang_code(help_lang_t lang);
|
||||
const char *i18n_text(help_lang_t lang, i18n_text_id_t id);
|
||||
bool i18n_try_parse_ui_lang(const char *value, ui_lang_t *lang);
|
||||
ui_lang_t i18n_parse_ui_lang(const char *value, ui_lang_t fallback);
|
||||
ui_lang_t i18n_default_ui_lang(void);
|
||||
ui_lang_t i18n_next_ui_lang(ui_lang_t lang);
|
||||
const char *i18n_ui_lang_code(ui_lang_t lang);
|
||||
const char *i18n_text(ui_lang_t lang, i18n_text_id_t id);
|
||||
|
||||
static inline const char *i18n_string(i18n_string_t value, ui_lang_t lang) {
|
||||
if ((int)lang < 0 || lang >= UI_LANG_COUNT) {
|
||||
lang = UI_LANG_EN;
|
||||
}
|
||||
if (value.text[lang]) {
|
||||
return value.text[lang];
|
||||
}
|
||||
if (value.text[UI_LANG_EN]) {
|
||||
return value.text[UI_LANG_EN];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
#endif /* I18N_H */
|
||||
|
|
|
|||
35
include/input_buffer.h
Normal file
35
include/input_buffer.h
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#ifndef INPUT_BUFFER_H
|
||||
#define INPUT_BUFFER_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
typedef enum {
|
||||
TNT_INPUT_APPEND_OK = 0,
|
||||
TNT_INPUT_APPEND_IGNORED = 1 << 0,
|
||||
TNT_INPUT_APPEND_OVERFLOW = 1 << 1,
|
||||
TNT_INPUT_APPEND_INVALID_UTF8 = 1 << 2
|
||||
} tnt_input_append_status_t;
|
||||
|
||||
typedef struct {
|
||||
char bytes[4];
|
||||
int len;
|
||||
int expected_len;
|
||||
} tnt_input_utf8_state_t;
|
||||
|
||||
void tnt_input_utf8_state_reset(tnt_input_utf8_state_t *state);
|
||||
|
||||
int tnt_input_append_ascii(char *input, size_t input_size, unsigned char b);
|
||||
int tnt_input_append_utf8_sequence(char *input, size_t input_size,
|
||||
const char *bytes, int len);
|
||||
|
||||
/* Append one byte from a terminal stream, validating UTF-8 across calls.
|
||||
* In paste mode CR/LF/TAB are normalized to spaces so existing TNT 1.x
|
||||
* single-line message semantics are preserved. */
|
||||
int tnt_input_append_stream_byte(char *input, size_t input_size,
|
||||
tnt_input_utf8_state_t *state,
|
||||
unsigned char b, bool paste_mode);
|
||||
|
||||
/* Returns TNT_INPUT_APPEND_INVALID_UTF8 when the stream ended mid-codepoint. */
|
||||
int tnt_input_utf8_state_finish(tnt_input_utf8_state_t *state);
|
||||
|
||||
#endif /* INPUT_BUFFER_H */
|
||||
15
include/json_text.h
Normal file
15
include/json_text.h
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#ifndef JSON_TEXT_H
|
||||
#define JSON_TEXT_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void tnt_json_append_string(char *buffer, size_t buf_size, size_t *pos,
|
||||
const char *text);
|
||||
|
||||
/* Extract a top-level JSON string field from a single JSON object.
|
||||
* Returns false for malformed JSON, missing key, non-string value, or output
|
||||
* overflow. Unknown nested objects and arrays are skipped. */
|
||||
bool tnt_json_get_string_field(const char *json, const char *key,
|
||||
char *out, size_t out_size);
|
||||
|
||||
#endif /* JSON_TEXT_H */
|
||||
9
include/manual.h
Normal file
9
include/manual.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#ifndef MANUAL_H
|
||||
#define MANUAL_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void manual_append_interactive_panel(char *buffer, size_t buf_size,
|
||||
size_t *pos, ui_lang_t lang);
|
||||
|
||||
#endif /* MANUAL_H */
|
||||
9
include/manual_text.h
Normal file
9
include/manual_text.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#ifndef MANUAL_TEXT_H
|
||||
#define MANUAL_TEXT_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void manual_text_append_interactive(char *buffer, size_t buf_size,
|
||||
size_t *pos, ui_lang_t lang);
|
||||
|
||||
#endif /* MANUAL_TEXT_H */
|
||||
|
|
@ -26,4 +26,9 @@ void message_format(const message_t *msg, char *buffer, size_t buf_size, int wid
|
|||
* Returns the last max_results matches in chronological order; caller must free *results. */
|
||||
int message_search(const char *query, message_t **results, int max_results);
|
||||
|
||||
/* Export valid persisted log records in messages.log v1 format. max_records
|
||||
* 0 exports all valid records; positive values export the last max_records
|
||||
* valid records. Caller must free *output. */
|
||||
int message_dump_text(char **output, size_t *output_len, int max_records);
|
||||
|
||||
#endif /* MESSAGE_H */
|
||||
|
|
|
|||
21
include/message_log.h
Normal file
21
include/message_log.h
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#ifndef MESSAGE_LOG_H
|
||||
#define MESSAGE_LOG_H
|
||||
|
||||
#include "message.h"
|
||||
|
||||
#define MESSAGE_LOG_MAX_LINE 2048
|
||||
|
||||
void message_log_format_timestamp_utc(time_t ts, char *buffer,
|
||||
size_t buf_size);
|
||||
|
||||
/* Parse one complete messages.log v1 record. `now` is used to reject records
|
||||
* outside TNT's accepted replay window. */
|
||||
bool message_log_parse_record(const char *line, message_t *out, time_t now);
|
||||
|
||||
/* Format one messages.log v1 record. record_len receives the number of bytes
|
||||
* that would be written, excluding the trailing NUL. Passing NULL/0 for the
|
||||
* output buffer is allowed when only the length is needed. */
|
||||
int message_log_format_record(const message_t *msg, char *buffer,
|
||||
size_t buf_size, size_t *record_len);
|
||||
|
||||
#endif /* MESSAGE_LOG_H */
|
||||
9
include/message_log_tool.h
Normal file
9
include/message_log_tool.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#ifndef MESSAGE_LOG_TOOL_H
|
||||
#define MESSAGE_LOG_TOOL_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
int message_log_tool_check(const char *path);
|
||||
int message_log_tool_recover(const char *path);
|
||||
|
||||
#endif /* MESSAGE_LOG_TOOL_H */
|
||||
24
include/module_protocol.h
Normal file
24
include/module_protocol.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#ifndef MODULE_PROTOCOL_H
|
||||
#define MODULE_PROTOCOL_H
|
||||
|
||||
#include "message.h"
|
||||
|
||||
#define TNT_MODULE_PROTOCOL_VERSION "tnt.module.v1"
|
||||
#define TNT_MODULE_EVENT_MESSAGE_CREATED "message.created"
|
||||
#define TNT_MODULE_RESPONSE_MESSAGE_CREATE "message.create"
|
||||
|
||||
typedef struct {
|
||||
char plain_text[MAX_MESSAGE_LEN];
|
||||
} tnt_module_message_create_t;
|
||||
|
||||
int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos,
|
||||
const char *server_version);
|
||||
|
||||
int tnt_module_append_message_created(char *buffer, size_t buf_size,
|
||||
size_t *pos, const char *message_id,
|
||||
const message_t *msg);
|
||||
|
||||
bool tnt_module_parse_message_create(const char *line,
|
||||
tnt_module_message_create_t *out);
|
||||
|
||||
#endif /* MODULE_PROTOCOL_H */
|
||||
27
include/module_runtime.h
Normal file
27
include/module_runtime.h
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#ifndef MODULE_RUNTIME_H
|
||||
#define MODULE_RUNTIME_H
|
||||
|
||||
#include "message.h"
|
||||
|
||||
#define TNT_MAX_MODULES 8
|
||||
#define TNT_MODULE_QUEUE_LIMIT 128
|
||||
|
||||
typedef struct {
|
||||
char name[64];
|
||||
char entrypoint[PATH_MAX];
|
||||
bool wants_message_created;
|
||||
bool can_read_messages;
|
||||
bool can_create_messages;
|
||||
} tnt_module_manifest_t;
|
||||
|
||||
int tnt_module_manifest_load(const char *module_dir,
|
||||
tnt_module_manifest_t *out);
|
||||
|
||||
int tnt_module_runtime_init(void);
|
||||
void tnt_module_runtime_shutdown(void);
|
||||
|
||||
/* Queue a user/core-created public message for enabled modules. This is
|
||||
* intentionally fire-and-forget so basic chat never depends on module health. */
|
||||
void tnt_module_runtime_publish_message_created(const message_t *msg);
|
||||
|
||||
#endif /* MODULE_RUNTIME_H */
|
||||
|
|
@ -14,9 +14,18 @@
|
|||
typedef struct {
|
||||
time_t timestamp;
|
||||
char from[MAX_USERNAME_LEN];
|
||||
char to[MAX_USERNAME_LEN];
|
||||
char content[MAX_MESSAGE_LEN];
|
||||
bool outgoing;
|
||||
bool unread;
|
||||
} whisper_t;
|
||||
|
||||
typedef enum {
|
||||
TNT_COMMAND_OUTPUT_NONE,
|
||||
TNT_COMMAND_OUTPUT_GENERIC,
|
||||
TNT_COMMAND_OUTPUT_INBOX
|
||||
} tnt_command_output_kind_t;
|
||||
|
||||
/* Client connection structure */
|
||||
typedef struct client {
|
||||
ssh_session session; /* SSH session */
|
||||
|
|
@ -26,7 +35,7 @@ typedef struct client {
|
|||
_Atomic int width;
|
||||
_Atomic int height;
|
||||
client_mode_t mode;
|
||||
help_lang_t help_lang;
|
||||
ui_lang_t ui_lang;
|
||||
int scroll_pos;
|
||||
bool follow_tail; /* NORMAL stays pinned to latest until user scrolls up */
|
||||
int help_scroll_pos;
|
||||
|
|
@ -40,17 +49,28 @@ typedef struct client {
|
|||
char insert_history[16][MAX_MESSAGE_LEN];
|
||||
int insert_history_count;
|
||||
int insert_history_pos;
|
||||
char command_output[2048];
|
||||
char command_output[MAX_COMMAND_OUTPUT_LEN];
|
||||
int command_output_scroll;
|
||||
tnt_command_output_kind_t command_output_kind;
|
||||
bool show_motd; /* command_output holds MOTD text */
|
||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||
bool exec_command_too_long;
|
||||
char ssh_login[MAX_USERNAME_LEN];
|
||||
time_t connect_time;
|
||||
time_t last_active;
|
||||
atomic_bool redraw_pending;
|
||||
_Atomic int pending_bells; /* Bell nudges for this client's loop */
|
||||
_Atomic int unread_mentions; /* @-mentions received since last reset */
|
||||
_Atomic int unread_whispers; /* whispers received since last :inbox view */
|
||||
/* Per-client whisper inbox. Pushes serialise on io_lock; readers are
|
||||
* the client's own thread inside :inbox handling. */
|
||||
char last_whisper_peer[MAX_USERNAME_LEN]; /* Most recent private-message peer */
|
||||
char *outbox; /* Bounded queued output for interactive writes */
|
||||
size_t outbox_len;
|
||||
size_t outbox_pos;
|
||||
size_t outbox_capacity;
|
||||
char *render_buffer; /* Reused main-screen render buffer */
|
||||
size_t render_buffer_capacity;
|
||||
/* Per-client whisper inbox. Protected separately from SSH channel I/O
|
||||
* so slow writes do not block in-memory private-message delivery. */
|
||||
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
|
||||
int whisper_inbox_count;
|
||||
bool mute_joins;
|
||||
|
|
@ -59,6 +79,8 @@ typedef struct client {
|
|||
int ref_count; /* Reference count for safe cleanup */
|
||||
pthread_mutex_t ref_lock; /* Lock for ref_count */
|
||||
pthread_mutex_t io_lock; /* Serialize SSH channel writes */
|
||||
pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */
|
||||
bool channel_callback_ref; /* client.c owns one ref while callbacks are installed */
|
||||
struct ssh_channel_callbacks_struct *channel_cb;
|
||||
} client_t;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
#ifndef SUPPORT_H
|
||||
#define SUPPORT_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void support_append_interactive_panel(char *buffer, size_t buf_size,
|
||||
size_t *pos, help_lang_t lang);
|
||||
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos,
|
||||
help_lang_t lang);
|
||||
|
||||
#endif /* SUPPORT_H */
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
#ifndef SUPPORT_TEXT_H
|
||||
#define SUPPORT_TEXT_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
const char *support_text_interactive(help_lang_t lang);
|
||||
const char *support_text_exec(help_lang_t lang);
|
||||
|
||||
#endif /* SUPPORT_TEXT_H */
|
||||
|
|
@ -5,11 +5,11 @@
|
|||
#include "message.h"
|
||||
|
||||
void system_message_make_join(message_t *msg, const char *username,
|
||||
help_lang_t lang);
|
||||
ui_lang_t lang);
|
||||
void system_message_make_leave(message_t *msg, const char *username,
|
||||
help_lang_t lang);
|
||||
ui_lang_t lang);
|
||||
void system_message_make_nick(message_t *msg, const char *old_name,
|
||||
const char *new_name, help_lang_t lang);
|
||||
const char *new_name, ui_lang_t lang);
|
||||
|
||||
bool system_message_is_system(const message_t *msg);
|
||||
bool system_message_is_join_leave(const message_t *msg);
|
||||
|
|
|
|||
29
include/tntctl_text.h
Normal file
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 */
|
||||
|
|
@ -24,6 +24,9 @@ void tui_render_motd(struct client *client);
|
|||
/* Render the input line */
|
||||
void tui_render_input(struct client *client, const char *input);
|
||||
|
||||
/* Render only the command input/status line */
|
||||
void tui_render_command_input(struct client *client);
|
||||
|
||||
/* Clear the screen */
|
||||
void tui_clear_screen(struct client *client);
|
||||
|
||||
|
|
@ -32,5 +35,4 @@ void tui_clear_screen(struct client *client);
|
|||
* itself afterwards. */
|
||||
void tui_render_welcome(struct client *client);
|
||||
|
||||
/* Get help text based on language */
|
||||
#endif /* TUI_H */
|
||||
|
|
|
|||
92
install.sh
92
install.sh
|
|
@ -27,6 +27,34 @@ sha256_of() {
|
|||
fi
|
||||
}
|
||||
|
||||
warn_missing_libssh() {
|
||||
case "$OS" in
|
||||
linux)
|
||||
if command -v ldconfig >/dev/null 2>&1 &&
|
||||
ldconfig -p 2>/dev/null | grep -q 'libssh\.so'; then
|
||||
return
|
||||
fi
|
||||
for path in /usr/lib/libssh.so* /usr/lib64/libssh.so* \
|
||||
/lib/libssh.so* /lib64/libssh.so*; do
|
||||
[ -e "$path" ] && return
|
||||
done
|
||||
echo "WARNING: TNT requires the libssh runtime library."
|
||||
echo "Install it first, for example:"
|
||||
echo " Ubuntu/Debian: sudo apt install libssh-4"
|
||||
echo " Arch: sudo pacman -S libssh"
|
||||
;;
|
||||
darwin)
|
||||
if [ -e /opt/homebrew/opt/libssh/lib/libssh.dylib ] ||
|
||||
[ -e /usr/local/opt/libssh/lib/libssh.dylib ]; then
|
||||
return
|
||||
fi
|
||||
echo "WARNING: TNT requires the libssh runtime library."
|
||||
echo "Install it first:"
|
||||
echo " brew install libssh"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
need_cmd curl
|
||||
need_cmd awk
|
||||
|
||||
|
|
@ -45,13 +73,15 @@ case "$ARCH" in
|
|||
*) fail "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
BINARY="tnt-${OS}-${ARCH}"
|
||||
SERVER_BINARY="tnt-${OS}-${ARCH}"
|
||||
CTL_BINARY="tntctl-${OS}-${ARCH}"
|
||||
|
||||
echo "=== TNT Installer ==="
|
||||
echo "OS: $OS"
|
||||
echo "Arch: $ARCH"
|
||||
echo "Version: $VERSION"
|
||||
echo ""
|
||||
warn_missing_libssh
|
||||
|
||||
# Get latest version if not specified
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
|
|
@ -65,51 +95,81 @@ fi
|
|||
echo "Installing version: $VERSION"
|
||||
|
||||
# Download
|
||||
URL="https://github.com/$REPO/releases/download/$VERSION/$BINARY"
|
||||
SERVER_URL="https://github.com/$REPO/releases/download/$VERSION/$SERVER_BINARY"
|
||||
CTL_URL="https://github.com/$REPO/releases/download/$VERSION/$CTL_BINARY"
|
||||
CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt"
|
||||
echo "Downloading from: $URL"
|
||||
echo "Downloading from: $SERVER_URL"
|
||||
|
||||
TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
|
||||
SERVER_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
|
||||
CTL_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tntctl.XXXXXX")
|
||||
CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX")
|
||||
INSTALL_CTL=0
|
||||
cleanup() {
|
||||
rm -f "$TMP_FILE" "$CHECKSUM_FILE"
|
||||
rm -f "$SERVER_TMP_FILE" "$CTL_TMP_FILE" "$CHECKSUM_FILE"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
curl -fsSL -o "$TMP_FILE" "$URL" || fail "Failed to download $BINARY"
|
||||
curl -fsSL -o "$SERVER_TMP_FILE" "$SERVER_URL" ||
|
||||
fail "Failed to download $SERVER_BINARY"
|
||||
|
||||
echo "Downloading checksums from: $CHECKSUM_URL"
|
||||
curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" ||
|
||||
fail "Failed to download checksums.txt"
|
||||
|
||||
EXPECTED_SHA=$(awk -v name="$BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||
[ -n "$EXPECTED_SHA" ] || fail "No checksum entry found for $BINARY"
|
||||
EXPECTED_SERVER_SHA=$(awk -v name="$SERVER_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||
[ -n "$EXPECTED_SERVER_SHA" ] || fail "No checksum entry found for $SERVER_BINARY"
|
||||
EXPECTED_CTL_SHA=$(awk -v name="$CTL_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||
|
||||
ACTUAL_SHA=$(sha256_of "$TMP_FILE") ||
|
||||
ACTUAL_SERVER_SHA=$(sha256_of "$SERVER_TMP_FILE") ||
|
||||
fail "sha256sum or shasum is required for checksum verification"
|
||||
|
||||
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] ||
|
||||
fail "Checksum mismatch for $BINARY"
|
||||
[ "$ACTUAL_SERVER_SHA" = "$EXPECTED_SERVER_SHA" ] ||
|
||||
fail "Checksum mismatch for $SERVER_BINARY"
|
||||
|
||||
echo "Checksum verified: $ACTUAL_SHA"
|
||||
echo "Checksum verified: $SERVER_BINARY $ACTUAL_SERVER_SHA"
|
||||
if [ -n "$EXPECTED_CTL_SHA" ]; then
|
||||
echo "Downloading from: $CTL_URL"
|
||||
curl -fsSL -o "$CTL_TMP_FILE" "$CTL_URL" ||
|
||||
fail "Failed to download $CTL_BINARY"
|
||||
ACTUAL_CTL_SHA=$(sha256_of "$CTL_TMP_FILE") ||
|
||||
fail "sha256sum or shasum is required for checksum verification"
|
||||
[ "$ACTUAL_CTL_SHA" = "$EXPECTED_CTL_SHA" ] ||
|
||||
fail "Checksum mismatch for $CTL_BINARY"
|
||||
echo "Checksum verified: $CTL_BINARY $ACTUAL_CTL_SHA"
|
||||
INSTALL_CTL=1
|
||||
else
|
||||
echo "No checksum entry found for $CTL_BINARY; skipping tntctl for this release"
|
||||
fi
|
||||
|
||||
# Install
|
||||
chmod +x "$TMP_FILE"
|
||||
chmod +x "$SERVER_TMP_FILE"
|
||||
[ "$INSTALL_CTL" -eq 0 ] || chmod +x "$CTL_TMP_FILE"
|
||||
|
||||
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
|
||||
install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
[ "$INSTALL_CTL" -eq 0 ] || install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
|
||||
else
|
||||
echo "Need sudo for installation to $INSTALL_DIR"
|
||||
need_cmd sudo
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
sudo install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
[ "$INSTALL_CTL" -eq 0 ] || sudo install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "TNT installed successfully to $INSTALL_DIR/tnt"
|
||||
if [ "$INSTALL_CTL" -eq 1 ]; then
|
||||
echo "TNT installed successfully to $INSTALL_DIR/tnt and $INSTALL_DIR/tntctl"
|
||||
else
|
||||
echo "TNT installed successfully to $INSTALL_DIR/tnt"
|
||||
fi
|
||||
echo ""
|
||||
echo "Run with:"
|
||||
echo " tnt"
|
||||
echo ""
|
||||
echo "Or specify port:"
|
||||
echo " PORT=3333 tnt"
|
||||
if [ "$INSTALL_CTL" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "Control a server with:"
|
||||
echo " tntctl localhost health"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -10,12 +10,33 @@ any public registry.
|
|||
- `homebrew/` - Homebrew tap formula draft and maintainer notes.
|
||||
- `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata.
|
||||
|
||||
Package installs include both `tnt` and `tntctl`. `tnt` is the server process;
|
||||
`tntctl` is a thin wrapper around the documented SSH exec interface.
|
||||
|
||||
## CI governance
|
||||
|
||||
Package recipes are validated in stages:
|
||||
|
||||
- PR fast gate: `make release-check` verifies package metadata stays aligned
|
||||
with `TNT_VERSION`.
|
||||
- Extended CI: package syntax and Debian source-tree assembly run on `main` and
|
||||
`release/**` pushes, nightly, and manual workflow dispatch.
|
||||
- Release gate: the workflow builds an explicit release source archive, verifies
|
||||
it, and includes it in `checksums.txt`.
|
||||
- Publishing gate: after final source checksums are pinned, run
|
||||
`SOURCE_TARBALL=... make package-publish-check`.
|
||||
|
||||
All package-manager submissions remain manual. CI must not push to AUR, open or
|
||||
merge Homebrew tap updates, upload Debian/PPA packages, publish container
|
||||
images, or deploy production servers.
|
||||
|
||||
## Release checklist
|
||||
|
||||
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
|
||||
Also update package versions in Arch, Homebrew, and Debian drafts.
|
||||
2. Create a GitHub release tag such as `v1.0.1`.
|
||||
3. Build and upload release tarballs or rely on GitHub source archives.
|
||||
2. Create a GitHub release tag such as `vX.Y.Z`.
|
||||
3. Let the release workflow build the explicit release source archive and draft
|
||||
release assets.
|
||||
4. Replace placeholder checksums in package drafts.
|
||||
5. Verify package contents in an isolated directory:
|
||||
|
||||
|
|
@ -23,13 +44,23 @@ any public registry.
|
|||
make release-check
|
||||
```
|
||||
|
||||
6. Before submitting package recipes, replace checksum placeholders and run:
|
||||
6. Assemble a Debian/PPA source tree when preparing Ubuntu packaging:
|
||||
|
||||
```sh
|
||||
make release-check-strict
|
||||
make debian-source-package
|
||||
```
|
||||
|
||||
7. Submit packages manually:
|
||||
Use `scripts/package_debian_source.sh --build` on a Debian/Ubuntu system
|
||||
with `dpkg-buildpackage` installed to build the unsigned source package.
|
||||
|
||||
7. Before submitting package recipes, download the explicit release source archive,
|
||||
replace checksum placeholders, and run:
|
||||
|
||||
```sh
|
||||
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check
|
||||
```
|
||||
|
||||
8. Submit packages manually:
|
||||
- Arch: upload `PKGBUILD` and generated `.SRCINFO` to AUR.
|
||||
- Homebrew: open a PR to the project tap, or later Homebrew core if eligible.
|
||||
- Ubuntu: build Debian source packages and upload to a Launchpad PPA.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ pkgbase = tnt-chat
|
|||
makedepends = gcc
|
||||
makedepends = make
|
||||
depends = libssh
|
||||
source = tnt-chat-1.0.1.tar.gz::https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz
|
||||
source = tnt-chat-v1.0.1-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.0.1/tnt-chat-v1.0.1-source.tar.gz
|
||||
source = tnt-chat.sysusers
|
||||
sha256sums = SKIP
|
||||
sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed
|
||||
|
||||
pkgname = tnt-chat
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Maintainer: M1ng <REPLACE_WITH_EMAIL>
|
||||
# Maintainer: M1ng <contact@m1ng.space>
|
||||
|
||||
pkgname=tnt-chat
|
||||
pkgver=1.0.1
|
||||
|
|
@ -9,8 +9,10 @@ url='https://github.com/m1ngsama/TNT'
|
|||
license=('MIT')
|
||||
depends=('libssh')
|
||||
makedepends=('gcc' 'make')
|
||||
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
source=("${pkgname}-v${pkgver}-source.tar.gz::${url}/releases/download/v${pkgver}/${pkgname}-v${pkgver}-source.tar.gz"
|
||||
"${pkgname}.sysusers")
|
||||
sha256sums=('SKIP'
|
||||
'8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed')
|
||||
|
||||
build() {
|
||||
cd "TNT-${pkgver}"
|
||||
|
|
@ -21,5 +23,7 @@ package() {
|
|||
cd "TNT-${pkgver}"
|
||||
make DESTDIR="${pkgdir}" PREFIX=/usr install
|
||||
make DESTDIR="${pkgdir}" PREFIX=/usr install-systemd
|
||||
install -Dm644 "${srcdir}/${pkgname}.sysusers" \
|
||||
"${pkgdir}/usr/lib/sysusers.d/${pkgname}.conf"
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,10 +27,11 @@ makepkg --printsrcinfo > .SRCINFO
|
|||
```
|
||||
|
||||
Before AUR submission, replace `sha256sums=('SKIP')` with the real release
|
||||
archive checksum, then run the project-level strict check:
|
||||
source archive checksum, regenerate `.SRCINFO`, then run the package publish
|
||||
check:
|
||||
|
||||
```sh
|
||||
make release-check-strict
|
||||
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check
|
||||
```
|
||||
|
||||
## Manual AUR submission
|
||||
|
|
@ -40,7 +41,7 @@ git clone ssh://aur@aur.archlinux.org/tnt-chat.git aur-tnt-chat
|
|||
cp PKGBUILD .SRCINFO aur-tnt-chat/
|
||||
cd aur-tnt-chat
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to 1.0.1"
|
||||
git commit -m "Update to X.Y.Z"
|
||||
git push
|
||||
```
|
||||
|
||||
|
|
|
|||
1
packaging/arch/tnt-chat.sysusers
Normal file
1
packaging/arch/tnt-chat.sysusers
Normal file
|
|
@ -0,0 +1 @@
|
|||
u tnt - "TNT chat server" /var/lib/tnt -
|
||||
|
|
@ -6,18 +6,17 @@ the project has a stable release cadence.
|
|||
|
||||
## Draft metadata
|
||||
|
||||
The `debian/` directory in this folder is a packaging draft. To test it against
|
||||
an upstream release tree, copy it to the root of a clean source checkout:
|
||||
The `debian/` directory in this folder is a packaging draft. To assemble it
|
||||
against a clean source tree:
|
||||
|
||||
```sh
|
||||
cp -a packaging/debian/debian ./debian
|
||||
dpkg-buildpackage -us -uc
|
||||
make debian-source-package
|
||||
```
|
||||
|
||||
For PPA uploads, build a signed source package instead:
|
||||
For PPA uploads, build a source package on Debian/Ubuntu:
|
||||
|
||||
```sh
|
||||
debuild -S
|
||||
scripts/package_debian_source.sh --build
|
||||
```
|
||||
|
||||
## Recommended path
|
||||
|
|
@ -44,6 +43,8 @@ debuild -S
|
|||
## Package shape
|
||||
|
||||
- Binary package name: `tnt-chat`
|
||||
- Installed command: `/usr/bin/tnt`
|
||||
- Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl`
|
||||
- Runtime dependency: `libssh`
|
||||
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service`
|
||||
- System user: package maintainer scripts create `tnt:tnt`; the systemd unit
|
||||
owns `/var/lib/tnt` through `StateDirectory=tnt`
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ tnt-chat (1.0.1-1) unstable; urgency=medium
|
|||
|
||||
* Initial package draft.
|
||||
|
||||
-- M1ng <REPLACE_WITH_EMAIL> Thu, 21 May 2026 00:00:00 +0800
|
||||
-- M1ng <contact@m1ng.space> Thu, 21 May 2026 00:00:00 +0800
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
Source: tnt-chat
|
||||
Section: net
|
||||
Priority: optional
|
||||
Maintainer: M1ng <REPLACE_WITH_EMAIL>
|
||||
Maintainer: M1ng <contact@m1ng.space>
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
libssh-dev,
|
||||
|
|
@ -15,7 +15,8 @@ Package: tnt-chat
|
|||
Architecture: any
|
||||
Depends:
|
||||
${misc:Depends},
|
||||
${shlibs:Depends}
|
||||
${shlibs:Depends},
|
||||
adduser
|
||||
Description: SSH-native terminal chat server
|
||||
TNT is a minimalist terminal chat server accessed over SSH. It provides a
|
||||
Vim-style terminal interface, anonymous access by default, persistent message
|
||||
|
|
|
|||
10
packaging/debian/debian/postinst
Executable file
10
packaging/debian/debian/postinst
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ "$1" = "configure" ] && ! getent passwd tnt >/dev/null; then
|
||||
adduser --system --group --home /var/lib/tnt --no-create-home --gecos "TNT chat server" tnt
|
||||
fi
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
|
|
@ -6,6 +6,7 @@ project tap first, not Homebrew core:
|
|||
```sh
|
||||
brew tap m1ngsama/tnt
|
||||
brew install tnt-chat
|
||||
brew services start tnt-chat
|
||||
```
|
||||
|
||||
Homebrew core should wait until TNT has stable releases and broader usage.
|
||||
|
|
@ -18,6 +19,7 @@ From a tap repository:
|
|||
brew audit --strict --online tnt-chat
|
||||
brew install --build-from-source ./Formula/tnt-chat.rb
|
||||
brew test tnt-chat
|
||||
brew services run tnt-chat
|
||||
```
|
||||
|
||||
For local syntax-only validation from this repository:
|
||||
|
|
@ -28,20 +30,20 @@ ruby -c packaging/homebrew/tnt-chat.rb
|
|||
|
||||
## Updating the formula
|
||||
|
||||
1. Publish a GitHub release tag such as `v1.0.1`.
|
||||
1. Publish a GitHub release tag such as `vX.Y.Z`.
|
||||
2. Download or hash the release source archive:
|
||||
|
||||
```sh
|
||||
curl -L -o tnt-chat-1.0.1.tar.gz \
|
||||
https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz
|
||||
shasum -a 256 tnt-chat-1.0.1.tar.gz
|
||||
curl -L -o dist/tnt-chat-vX.Y.Z-source.tar.gz \
|
||||
https://github.com/m1ngsama/TNT/releases/download/vX.Y.Z/tnt-chat-vX.Y.Z-source.tar.gz
|
||||
shasum -a 256 dist/tnt-chat-vX.Y.Z-source.tar.gz
|
||||
```
|
||||
|
||||
3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`.
|
||||
4. Run:
|
||||
|
||||
```sh
|
||||
make release-check-strict
|
||||
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check
|
||||
```
|
||||
|
||||
5. Copy the formula into the tap repository and open a normal review PR.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
class TntChat < Formula
|
||||
desc "SSH-native terminal chat server with a Vim-style interface"
|
||||
homepage "https://github.com/m1ngsama/TNT"
|
||||
url "https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz"
|
||||
url "https://github.com/m1ngsama/TNT/releases/download/v1.0.1/tnt-chat-v1.0.1-source.tar.gz"
|
||||
sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256"
|
||||
license "MIT"
|
||||
|
||||
|
|
@ -12,10 +12,24 @@ class TntChat < Formula
|
|||
system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}"
|
||||
|
||||
bin.install "#{buildpath}/stage#{prefix}/bin/tnt"
|
||||
bin.install "#{buildpath}/stage#{prefix}/bin/tntctl"
|
||||
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1"
|
||||
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tntctl.1"
|
||||
|
||||
(var/"tnt").mkpath
|
||||
(var/"log").mkpath
|
||||
end
|
||||
|
||||
service do
|
||||
run [opt_bin/"tnt", "-d", var/"tnt"]
|
||||
keep_alive true
|
||||
working_dir var/"tnt"
|
||||
log_path var/"log/tnt.log"
|
||||
error_log_path var/"log/tnt.log"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/tnt --version")
|
||||
assert_match version.to_s, shell_output("#{bin}/tntctl --version")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
31
scripts/check_release_ref.sh
Executable file
31
scripts/check_release_ref.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/bin/sh
|
||||
# Verify that a release tag matches TNT_VERSION.
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
fail() {
|
||||
echo "release-ref-check: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ref=${1:-${GITHUB_REF_NAME:-}}
|
||||
[ -n "$ref" ] || fail "missing release ref; pass vX.Y.Z or set GITHUB_REF_NAME"
|
||||
|
||||
case "$ref" in
|
||||
refs/tags/*) tag=${ref#refs/tags/} ;;
|
||||
*) tag=$ref ;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' ||
|
||||
fail "release ref must be vMAJOR.MINOR.PATCH, got $tag"
|
||||
|
||||
version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
||||
[ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h"
|
||||
|
||||
[ "$tag" = "v$version" ] ||
|
||||
fail "release tag $tag does not match TNT_VERSION $version"
|
||||
|
||||
echo "release ref matches TNT_VERSION: $tag"
|
||||
|
|
@ -1,44 +1,174 @@
|
|||
#!/bin/bash
|
||||
# TNT Log Rotation Script
|
||||
# Keeps chat history manageable and prevents disk space issues
|
||||
#!/bin/sh
|
||||
# Compact and archive a TNT messages.log file.
|
||||
#
|
||||
# This is an operator-run maintenance tool. For strict consistency, stop TNT
|
||||
# or run it during a quiet maintenance window before compacting the active log.
|
||||
|
||||
LOG_FILE="${1:-/var/lib/tnt/messages.log}"
|
||||
MAX_SIZE_MB="${2:-100}"
|
||||
KEEP_LINES="${3:-10000}"
|
||||
set -eu
|
||||
|
||||
# Check if log file exists
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo "Log file $LOG_FILE does not exist"
|
||||
DRY_RUN=0
|
||||
KEEP_ARCHIVES=5
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/logrotate.sh [--dry-run] [--keep-archives N] [LOG_FILE [MAX_SIZE_MB [KEEP_LINES]]]
|
||||
|
||||
Defaults:
|
||||
LOG_FILE /var/lib/tnt/messages.log
|
||||
MAX_SIZE_MB 100
|
||||
KEEP_LINES 10000
|
||||
|
||||
Exit status:
|
||||
0 success, including missing log file
|
||||
1 runtime error
|
||||
64 invalid arguments
|
||||
USAGE
|
||||
}
|
||||
|
||||
fail_usage() {
|
||||
echo "logrotate: $*" >&2
|
||||
usage >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "logrotate: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
is_uint() {
|
||||
case "${1:-}" in
|
||||
''|*[!0-9]*)
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_positive_uint() {
|
||||
is_uint "$1" && [ "$1" -gt 0 ]
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
--keep-archives)
|
||||
[ "$#" -ge 2 ] || fail_usage "missing value for --keep-archives"
|
||||
is_uint "$2" || fail_usage "invalid archive count: $2"
|
||||
KEEP_ARCHIVES=$2
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
fail_usage "unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ "$#" -le 3 ] || fail_usage "too many arguments"
|
||||
|
||||
LOG_FILE=${1:-/var/lib/tnt/messages.log}
|
||||
MAX_SIZE_MB=${2:-100}
|
||||
KEEP_LINES=${3:-10000}
|
||||
|
||||
case "$LOG_FILE" in
|
||||
''|-*)
|
||||
fail_usage "invalid log path"
|
||||
;;
|
||||
esac
|
||||
is_uint "$MAX_SIZE_MB" || fail_usage "invalid max size: $MAX_SIZE_MB"
|
||||
is_positive_uint "$KEEP_LINES" || fail_usage "invalid keep lines: $KEEP_LINES"
|
||||
|
||||
if [ ! -e "$LOG_FILE" ]; then
|
||||
echo "logrotate: $LOG_FILE does not exist"
|
||||
exit 0
|
||||
fi
|
||||
[ -f "$LOG_FILE" ] || fail "$LOG_FILE is not a regular file"
|
||||
|
||||
# Get file size in MB
|
||||
FILE_SIZE=$(du -m "$LOG_FILE" | cut -f1)
|
||||
MAX_BYTES=$((MAX_SIZE_MB * 1024 * 1024))
|
||||
FILE_SIZE=$(wc -c < "$LOG_FILE" | tr -d ' ')
|
||||
[ -n "$FILE_SIZE" ] || fail "could not read log size"
|
||||
|
||||
# Rotate if file is too large
|
||||
if [ "$FILE_SIZE" -gt "$MAX_SIZE_MB" ]; then
|
||||
echo "Log file size: ${FILE_SIZE}MB, rotating..."
|
||||
compact_log() {
|
||||
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
backup="${LOG_FILE}.${timestamp}"
|
||||
suffix=1
|
||||
|
||||
# Create backup
|
||||
BACKUP="${LOG_FILE}.$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$LOG_FILE" "$BACKUP"
|
||||
while [ -e "$backup" ] || [ -e "${backup}.gz" ]; do
|
||||
backup="${LOG_FILE}.${timestamp}.${suffix}"
|
||||
suffix=$((suffix + 1))
|
||||
done
|
||||
|
||||
# Keep only last N lines
|
||||
tail -n "$KEEP_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp"
|
||||
mv "${LOG_FILE}.tmp" "$LOG_FILE"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "logrotate: would archive $LOG_FILE to $backup"
|
||||
echo "logrotate: would keep last $KEEP_LINES lines"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Compress old backup
|
||||
gzip "$BACKUP"
|
||||
tmp="${LOG_FILE}.tmp.$$"
|
||||
rm -f "$tmp"
|
||||
cp -p "$LOG_FILE" "$backup" || fail "failed to create archive"
|
||||
if ! tail -n "$KEEP_LINES" "$LOG_FILE" > "$tmp"; then
|
||||
rm -f "$tmp"
|
||||
fail "failed to compact log"
|
||||
fi
|
||||
if ! cat "$tmp" > "$LOG_FILE"; then
|
||||
rm -f "$tmp"
|
||||
fail "failed to replace log"
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
|
||||
echo "Log rotated. Backup: ${BACKUP}.gz"
|
||||
echo "Kept last $KEEP_LINES lines"
|
||||
if command -v gzip >/dev/null 2>&1; then
|
||||
gzip -f "$backup" || fail "failed to compress archive"
|
||||
backup="${backup}.gz"
|
||||
fi
|
||||
|
||||
echo "logrotate: archived $backup"
|
||||
echo "logrotate: kept last $KEEP_LINES lines"
|
||||
}
|
||||
|
||||
cleanup_archives() {
|
||||
[ "$KEEP_ARCHIVES" -ge 0 ] || return 0
|
||||
|
||||
archives=$(
|
||||
ls -1t "$LOG_FILE".*.gz "$LOG_FILE".[0-9]* 2>/dev/null || true
|
||||
)
|
||||
[ -n "$archives" ] || return 0
|
||||
|
||||
printf '%s\n' "$archives" |
|
||||
awk '!seen[$0]++' |
|
||||
awk -v keep="$KEEP_ARCHIVES" 'NR > keep' |
|
||||
while IFS= read -r old; do
|
||||
[ -n "$old" ] || continue
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "logrotate: would remove $old"
|
||||
else
|
||||
rm -f "$old"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$FILE_SIZE" -gt "$MAX_BYTES" ]; then
|
||||
echo "logrotate: size ${FILE_SIZE} bytes exceeds ${MAX_BYTES} bytes"
|
||||
compact_log
|
||||
else
|
||||
echo "Log file size: ${FILE_SIZE}MB (under ${MAX_SIZE_MB}MB limit)"
|
||||
echo "logrotate: size ${FILE_SIZE} bytes is within ${MAX_BYTES} bytes"
|
||||
fi
|
||||
|
||||
# Clean up old compressed logs (keep last 5)
|
||||
LOG_DIR=$(dirname "$LOG_FILE")
|
||||
cd "$LOG_DIR" || exit
|
||||
ls -t messages.log.*.gz 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null
|
||||
|
||||
echo "Log rotation complete"
|
||||
cleanup_archives
|
||||
echo "logrotate: complete"
|
||||
|
|
|
|||
79
scripts/package_debian_source.sh
Executable file
79
scripts/package_debian_source.sh
Executable file
|
|
@ -0,0 +1,79 @@
|
|||
#!/bin/sh
|
||||
# Assemble a Debian/Ubuntu source-package tree. This script never uploads.
|
||||
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/package_debian_source.sh [--build] [OUT_DIR]
|
||||
|
||||
Create OUT_DIR/tnt-chat-$TNT_VERSION from tracked source files and copy the
|
||||
draft Debian metadata to OUT_DIR/tnt-chat-$TNT_VERSION/debian.
|
||||
|
||||
Options:
|
||||
--build run dpkg-buildpackage -S -us -uc after assembly
|
||||
|
||||
Default OUT_DIR: dist/debian-source
|
||||
USAGE
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "package-debian-source: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
BUILD=0
|
||||
OUT_DIR=${TNT_DEBIAN_SOURCE_OUT:-dist/debian-source}
|
||||
OUT_SET=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--build)
|
||||
BUILD=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
fail "unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
[ "$OUT_SET" -eq 0 ] || fail "multiple output directories"
|
||||
OUT_DIR=$1
|
||||
OUT_SET=1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
VERSION=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
||||
[ -n "$VERSION" ] || fail "could not read TNT_VERSION"
|
||||
|
||||
SOURCE_NAME="tnt-chat-$VERSION"
|
||||
SOURCE_ROOT="$OUT_DIR/$SOURCE_NAME"
|
||||
|
||||
[ ! -e "$SOURCE_ROOT" ] || fail "$SOURCE_ROOT already exists"
|
||||
mkdir -p "$OUT_DIR"
|
||||
mkdir -p "$SOURCE_ROOT"
|
||||
|
||||
git ls-files -z | cpio -0 -pdm "$SOURCE_ROOT" >/dev/null 2>&1
|
||||
cp -R "$ROOT/packaging/debian/debian" "$SOURCE_ROOT/debian"
|
||||
|
||||
[ -f "$SOURCE_ROOT/debian/control" ] || fail "missing debian/control"
|
||||
[ -x "$SOURCE_ROOT/debian/rules" ] || fail "missing executable debian/rules"
|
||||
[ -x "$SOURCE_ROOT/debian/postinst" ] || fail "missing executable debian/postinst"
|
||||
|
||||
echo "Debian source tree assembled: $SOURCE_ROOT"
|
||||
|
||||
if [ "$BUILD" -eq 1 ]; then
|
||||
command -v dpkg-buildpackage >/dev/null 2>&1 ||
|
||||
fail "dpkg-buildpackage not found"
|
||||
(
|
||||
cd "$SOURCE_ROOT"
|
||||
dpkg-buildpackage -S -us -uc
|
||||
)
|
||||
fi
|
||||
89
scripts/package_publish_check.sh
Executable file
89
scripts/package_publish_check.sh
Executable file
|
|
@ -0,0 +1,89 @@
|
|||
#!/bin/sh
|
||||
# Verify package-manager recipes against a final release source archive.
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
fail() {
|
||||
echo "package-publish-check: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
else
|
||||
fail "sha256sum or shasum is required"
|
||||
fi
|
||||
}
|
||||
|
||||
require_archive_entry() {
|
||||
entry=$1
|
||||
label=$2
|
||||
|
||||
printf '%s\n' "$source_listing" |
|
||||
awk -v target="$entry" '$0 == target { found = 1 } END { exit found ? 0 : 1 }' ||
|
||||
fail "SOURCE_TARBALL is missing $label"
|
||||
}
|
||||
|
||||
version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
||||
[ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h"
|
||||
release_source="tnt-chat-v${version}-source.tar.gz"
|
||||
|
||||
source_tarball=${SOURCE_TARBALL:-${RELEASE_SOURCE_TARBALL:-}}
|
||||
[ -n "$source_tarball" ] ||
|
||||
fail "set SOURCE_TARBALL to the explicit release source archive"
|
||||
[ -f "$source_tarball" ] ||
|
||||
fail "SOURCE_TARBALL does not exist: $source_tarball"
|
||||
source_listing=$(tar -tzf "$source_tarball") ||
|
||||
fail "SOURCE_TARBALL is not a readable tar.gz archive"
|
||||
require_archive_entry "TNT-$version/LICENSE" "LICENSE"
|
||||
require_archive_entry "TNT-$version/packaging/README.md" "packaging/README.md"
|
||||
require_archive_entry "TNT-$version/src/tntctl.c" "src/tntctl.c"
|
||||
require_archive_entry "TNT-$version/tnt.1" "tnt.1"
|
||||
require_archive_entry "TNT-$version/tntctl.1" "tntctl.1"
|
||||
|
||||
! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
|
||||
fail "replace maintainer email placeholders before package publishing"
|
||||
|
||||
arch_sha=$(sed -n "s/^[[:space:]]*sha256sums=('\([^']*\)'.*/\1/p" \
|
||||
packaging/arch/PKGBUILD | head -n 1)
|
||||
srcinfo_sha=$(sed -n 's/^[[:space:]]*sha256sums = \([^[:space:]]*\).*/\1/p' \
|
||||
packaging/arch/.SRCINFO | head -n 1)
|
||||
brew_sha=$(sed -n 's/^[[:space:]]*sha256 "\([^"]*\)".*/\1/p' \
|
||||
packaging/homebrew/tnt-chat.rb | head -n 1)
|
||||
|
||||
[ -n "$arch_sha" ] || fail "could not read PKGBUILD source checksum"
|
||||
[ -n "$srcinfo_sha" ] || fail "could not read .SRCINFO source checksum"
|
||||
[ -n "$brew_sha" ] || fail "could not read Homebrew source checksum"
|
||||
[ "$arch_sha" != "SKIP" ] || fail "replace PKGBUILD sha256sums before publishing"
|
||||
[ "$srcinfo_sha" != "SKIP" ] || fail "replace .SRCINFO sha256sums before publishing"
|
||||
[ "$brew_sha" != "REPLACE_WITH_RELEASE_TARBALL_SHA256" ] ||
|
||||
fail "replace Homebrew sha256 before publishing"
|
||||
|
||||
expected_sha=$(sha256_of "$source_tarball")
|
||||
[ "$arch_sha" = "$expected_sha" ] ||
|
||||
fail "PKGBUILD source checksum does not match SOURCE_TARBALL"
|
||||
[ "$srcinfo_sha" = "$expected_sha" ] ||
|
||||
fail ".SRCINFO source checksum does not match SOURCE_TARBALL"
|
||||
[ "$brew_sha" = "$expected_sha" ] ||
|
||||
fail "Homebrew source checksum does not match SOURCE_TARBALL"
|
||||
|
||||
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
|
||||
fail "PKGBUILD pkgver does not match $version"
|
||||
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
|
||||
fail ".SRCINFO pkgver does not match $version"
|
||||
grep -q '${pkgname}-v${pkgver}-source.tar.gz' packaging/arch/PKGBUILD ||
|
||||
fail "PKGBUILD source must use the release source archive"
|
||||
grep -q "$release_source" packaging/arch/.SRCINFO ||
|
||||
fail ".SRCINFO source does not match $release_source"
|
||||
grep -q "$release_source" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "Homebrew URL does not match $release_source"
|
||||
grep -q "^tnt-chat (${version}-1)" packaging/debian/debian/changelog ||
|
||||
fail "Debian changelog version does not match $version"
|
||||
|
||||
echo "package recipes match SOURCE_TARBALL for $version: $expected_sha"
|
||||
165
scripts/package_release_assets.sh
Executable file
165
scripts/package_release_assets.sh
Executable file
|
|
@ -0,0 +1,165 @@
|
|||
#!/bin/sh
|
||||
# Collect release workflow artifacts into one flat, checksum-verified bundle.
|
||||
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/package_release_assets.sh ARTIFACT_ROOT [OUT_DIR]
|
||||
|
||||
ARTIFACT_ROOT is the directory produced by actions/download-artifact.
|
||||
OUT_DIR defaults to dist/release-assets.
|
||||
|
||||
The script validates the expected release asset names, verifies binary
|
||||
architecture labels, verifies the source archive shape, writes checksums.txt,
|
||||
and verifies that checksums.txt matches the assembled bundle. It never
|
||||
publishes a release.
|
||||
USAGE
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "release-artifact-gate: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
else
|
||||
fail "sha256sum or shasum is required"
|
||||
fi
|
||||
}
|
||||
|
||||
find_unique() {
|
||||
name=$1
|
||||
matches=$(find "$ARTIFACT_ROOT" -type f -name "$name" -print)
|
||||
count=$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l | tr -d ' ')
|
||||
[ "$count" = "1" ] ||
|
||||
fail "expected exactly one artifact named $name, found $count"
|
||||
printf '%s\n' "$matches"
|
||||
}
|
||||
|
||||
require_file_label() {
|
||||
path=$1
|
||||
pattern=$2
|
||||
label=$(file "$path")
|
||||
printf '%s\n' "$label" | grep -E "$pattern" >/dev/null ||
|
||||
fail "unexpected file type for $(basename "$path"): $label"
|
||||
}
|
||||
|
||||
archive_has_entry() {
|
||||
entry=$1
|
||||
|
||||
printf '%s\n' "$archive_listing" |
|
||||
awk -v target="$entry" '$0 == target { found = 1 } END { exit found ? 0 : 1 }'
|
||||
}
|
||||
|
||||
require_archive_entry() {
|
||||
entry=$1
|
||||
label=$2
|
||||
|
||||
archive_has_entry "$entry" ||
|
||||
fail "source archive is missing $label"
|
||||
}
|
||||
|
||||
verify_asset() {
|
||||
name=$1
|
||||
path=$2
|
||||
|
||||
[ -s "$path" ] || fail "empty artifact: $name"
|
||||
|
||||
case "$name" in
|
||||
tnt-linux-amd64|tntctl-linux-amd64)
|
||||
require_file_label "$path" 'ELF 64-bit.*x86-64'
|
||||
;;
|
||||
tnt-linux-arm64|tntctl-linux-arm64)
|
||||
require_file_label "$path" 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||
;;
|
||||
tnt-darwin-amd64|tntctl-darwin-amd64)
|
||||
require_file_label "$path" 'Mach-O 64-bit.*x86_64'
|
||||
;;
|
||||
tnt-darwin-arm64|tntctl-darwin-arm64)
|
||||
require_file_label "$path" 'Mach-O 64-bit.*arm64'
|
||||
;;
|
||||
tnt-chat-v*-source.tar.gz)
|
||||
archive_listing=$(tar -tzf "$path") ||
|
||||
fail "source archive is not a readable tar.gz: $name"
|
||||
require_archive_entry "TNT-$VERSION/LICENSE" "LICENSE"
|
||||
require_archive_entry "TNT-$VERSION/src/tntctl.c" "src/tntctl.c"
|
||||
require_archive_entry "TNT-$VERSION/packaging/README.md" "packaging/README.md"
|
||||
require_archive_entry "TNT-$VERSION/tnt.1" "tnt.1"
|
||||
require_archive_entry "TNT-$VERSION/tntctl.1" "tntctl.1"
|
||||
;;
|
||||
*)
|
||||
fail "unexpected release artifact: $name"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] || {
|
||||
usage
|
||||
exit 0
|
||||
}
|
||||
|
||||
ARTIFACT_ROOT=${1:-}
|
||||
OUT_DIR=${2:-dist/release-assets}
|
||||
|
||||
[ -n "$ARTIFACT_ROOT" ] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
[ -d "$ARTIFACT_ROOT" ] || fail "ARTIFACT_ROOT does not exist: $ARTIFACT_ROOT"
|
||||
ARTIFACT_ROOT=$(CDPATH= cd -- "$ARTIFACT_ROOT" && pwd)
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
case "$OUT_DIR" in
|
||||
/*) ;;
|
||||
*) OUT_DIR="$ROOT/$OUT_DIR" ;;
|
||||
esac
|
||||
|
||||
VERSION=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
||||
[ -n "$VERSION" ] || fail "could not read TNT_VERSION"
|
||||
|
||||
SOURCE_ASSET="tnt-chat-v$VERSION-source.tar.gz"
|
||||
EXPECTED_ASSETS="
|
||||
tnt-linux-amd64
|
||||
tntctl-linux-amd64
|
||||
tnt-linux-arm64
|
||||
tntctl-linux-arm64
|
||||
tnt-darwin-amd64
|
||||
tntctl-darwin-amd64
|
||||
tnt-darwin-arm64
|
||||
tntctl-darwin-arm64
|
||||
$SOURCE_ASSET
|
||||
"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
for name in $EXPECTED_ASSETS; do
|
||||
dst="$OUT_DIR/$name"
|
||||
[ ! -e "$dst" ] || fail "output already exists: $dst"
|
||||
src=$(find_unique "$name")
|
||||
verify_asset "$name" "$src"
|
||||
cp "$src" "$dst"
|
||||
done
|
||||
|
||||
(
|
||||
cd "$OUT_DIR"
|
||||
: > checksums.txt
|
||||
for name in $EXPECTED_ASSETS; do
|
||||
printf '%s %s\n' "$(sha256_of "$name")" "$name" >> checksums.txt
|
||||
done
|
||||
|
||||
while read -r expected name; do
|
||||
[ -n "$expected" ] || continue
|
||||
actual=$(sha256_of "$name")
|
||||
[ "$actual" = "$expected" ] ||
|
||||
fail "checksum mismatch for $name"
|
||||
done < checksums.txt
|
||||
)
|
||||
|
||||
echo "release artifact bundle ready: $OUT_DIR"
|
||||
89
scripts/package_source_archive.sh
Executable file
89
scripts/package_source_archive.sh
Executable file
|
|
@ -0,0 +1,89 @@
|
|||
#!/bin/sh
|
||||
# Build and validate the explicit release source archive.
|
||||
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/package_source_archive.sh REF [OUT_DIR]
|
||||
|
||||
REF is a git ref, commit, or release tag to archive. OUT_DIR defaults to dist.
|
||||
The archive name is tnt-chat-v$TNT_VERSION-source.tar.gz, and its top-level
|
||||
directory is TNT-$TNT_VERSION/.
|
||||
|
||||
When REF is a SemVer tag such as v1.2.3 or refs/tags/v1.2.3, the tag must match
|
||||
TNT_VERSION from that ref. This script only builds and validates the archive; it
|
||||
does not tag, publish, upload, or deploy.
|
||||
USAGE
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "source-archive: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_archive_entry() {
|
||||
entry=$1
|
||||
label=$2
|
||||
|
||||
printf '%s\n' "$archive_listing" |
|
||||
awk -v target="$entry" '$0 == target { found = 1 } END { exit found ? 0 : 1 }' ||
|
||||
fail "source archive is missing $label"
|
||||
}
|
||||
|
||||
[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] || {
|
||||
usage
|
||||
exit 0
|
||||
}
|
||||
|
||||
REF=${1:-}
|
||||
OUT_DIR=${2:-dist}
|
||||
|
||||
[ -n "$REF" ] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
commit=$(git rev-parse --verify "$REF^{commit}") ||
|
||||
fail "could not resolve git ref: $REF"
|
||||
|
||||
case "$OUT_DIR" in
|
||||
/*) ;;
|
||||
*) OUT_DIR="$ROOT/$OUT_DIR" ;;
|
||||
esac
|
||||
|
||||
version=$(git show "$commit:include/common.h" |
|
||||
sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p')
|
||||
[ -n "$version" ] || fail "could not read TNT_VERSION from $REF"
|
||||
|
||||
case "$REF" in
|
||||
refs/tags/v[0-9]*.[0-9]*.[0-9]*)
|
||||
tag=${REF#refs/tags/}
|
||||
[ "$tag" = "v$version" ] ||
|
||||
fail "release tag $tag does not match TNT_VERSION $version"
|
||||
;;
|
||||
v[0-9]*.[0-9]*.[0-9]*)
|
||||
[ "$REF" = "v$version" ] ||
|
||||
fail "release tag $REF does not match TNT_VERSION $version"
|
||||
;;
|
||||
esac
|
||||
|
||||
archive="$OUT_DIR/tnt-chat-v$version-source.tar.gz"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
[ ! -e "$archive" ] || fail "output already exists: $archive"
|
||||
|
||||
git archive --format=tar.gz --prefix="TNT-$version/" -o "$archive" "$commit"
|
||||
|
||||
archive_listing=$(tar -tzf "$archive") ||
|
||||
fail "archive is not a readable tar.gz: $archive"
|
||||
require_archive_entry "TNT-$version/LICENSE" "LICENSE"
|
||||
require_archive_entry "TNT-$version/src/tntctl.c" "src/tntctl.c"
|
||||
require_archive_entry "TNT-$version/packaging/README.md" "packaging/README.md"
|
||||
require_archive_entry "TNT-$version/tnt.1" "tnt.1"
|
||||
require_archive_entry "TNT-$version/tntctl.1" "tntctl.1"
|
||||
|
||||
echo "$archive"
|
||||
|
|
@ -13,6 +13,7 @@ Default checks:
|
|||
- version metadata alignment
|
||||
- clean build
|
||||
- unit tests
|
||||
- script tests
|
||||
- staged install layout with PREFIX=/usr and DESTDIR
|
||||
- installer shell syntax
|
||||
- Debian packaging metadata
|
||||
|
|
@ -20,9 +21,14 @@ Default checks:
|
|||
|
||||
Environment:
|
||||
RUN_INTEGRATION=1 also run full make test
|
||||
RUN_SOAK=1 also run the configurable soak test
|
||||
RUN_SLOW_CLIENT=1 also run the slow-client backpressure test
|
||||
PORT=12720 base port for integration tests
|
||||
|
||||
Strict checks additionally require real package checksums and a local vX.Y.Z tag.
|
||||
Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a
|
||||
matching changelog release section, non-placeholder maintainer metadata, and a
|
||||
build from the tagged source archive. Run `make package-publish-check` after
|
||||
the explicit release source archive exists to verify package checksums.
|
||||
USAGE
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +68,8 @@ version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
|||
step "checking version metadata for $version"
|
||||
grep -q "\"TNT $version\"" tnt.1 ||
|
||||
fail "tnt.1 does not mention TNT $version"
|
||||
grep -q "\"TNT $version\"" tntctl.1 ||
|
||||
fail "tntctl.1 does not mention TNT $version"
|
||||
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
|
||||
fail "packaging/arch/PKGBUILD pkgver does not match $version"
|
||||
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
|
||||
|
|
@ -70,8 +78,12 @@ grep -q "^pkgname=tnt-chat$" packaging/arch/PKGBUILD ||
|
|||
fail "packaging/arch/PKGBUILD pkgname is not tnt-chat"
|
||||
grep -q "^pkgname = tnt-chat$" packaging/arch/.SRCINFO ||
|
||||
fail "packaging/arch/.SRCINFO pkgname is not tnt-chat"
|
||||
grep -q "v${version}.tar.gz" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "packaging/homebrew/tnt-chat.rb URL does not match v$version"
|
||||
grep -q '${pkgname}-v${pkgver}-source.tar.gz' packaging/arch/PKGBUILD ||
|
||||
fail "packaging/arch/PKGBUILD source must use the release source archive"
|
||||
grep -q "tnt-chat-v${version}-source.tar.gz" packaging/arch/.SRCINFO ||
|
||||
fail "packaging/arch/.SRCINFO source does not match v$version release archive"
|
||||
grep -q "tnt-chat-v${version}-source.tar.gz" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "packaging/homebrew/tnt-chat.rb URL does not match v$version release archive"
|
||||
grep -q "^class TntChat < Formula$" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "packaging/homebrew/tnt-chat.rb formula class is not TntChat"
|
||||
grep -q 'depends_on "libssh"' packaging/homebrew/tnt-chat.rb ||
|
||||
|
|
@ -88,16 +100,50 @@ make
|
|||
actual_version=$(./tnt --version)
|
||||
[ "$actual_version" = "tnt $version" ] ||
|
||||
fail "binary version mismatch: expected 'tnt $version', got '$actual_version'"
|
||||
tntctl_version=$(./tntctl --version)
|
||||
[ "$tntctl_version" = "tntctl $version" ] ||
|
||||
fail "control binary version mismatch: expected 'tntctl $version', got '$tntctl_version'"
|
||||
|
||||
step "running unit tests"
|
||||
make -C tests/unit clean
|
||||
make -C tests/unit run
|
||||
|
||||
step "running script tests"
|
||||
make script-test
|
||||
|
||||
step "checking client I/O ownership boundaries"
|
||||
! grep -R "client_send(target" src include >/dev/null ||
|
||||
fail "cross-client target writes must be queued through client_queue_bell"
|
||||
! grep -R "client_send(targets" src include >/dev/null ||
|
||||
fail "cross-client target-array writes must be queued through client_queue_bell"
|
||||
! grep -n "pthread_mutex_lock(&.*->io_lock)" src/commands.c >/dev/null ||
|
||||
fail "commands.c must not use SSH io_lock for in-memory command state"
|
||||
! grep -n "client_addref(client)" src/bootstrap.c >/dev/null ||
|
||||
fail "bootstrap.c must let client_install_channel_callbacks own callback refs"
|
||||
grep -q "client_release_session(client)" src/input.c ||
|
||||
fail "input.c must release session ownership through client_release_session"
|
||||
if grep -R "ssh_channel_write" src include | grep -v "^src/client.c:" >/dev/null; then
|
||||
fail "raw SSH channel writes must stay inside src/client.c"
|
||||
fi
|
||||
|
||||
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
|
||||
step "running full integration tests"
|
||||
make test PORT="${PORT:-12720}"
|
||||
fi
|
||||
|
||||
if [ "${RUN_SOAK:-0}" = "1" ]; then
|
||||
step "running soak test"
|
||||
make soak-test PORT="$((${PORT:-12720} + 30))" \
|
||||
DURATION="${SOAK_DURATION:-8}" RECONNECTS="${SOAK_RECONNECTS:-5}"
|
||||
fi
|
||||
|
||||
if [ "${RUN_SLOW_CLIENT:-0}" = "1" ]; then
|
||||
step "running slow-client test"
|
||||
make slow-client-test PORT="$((${PORT:-12720} + 40))" \
|
||||
DURATION="${SLOW_CLIENT_DURATION:-8}" \
|
||||
BURST_CHARS="${SLOW_CLIENT_BURST_CHARS:-1600}"
|
||||
fi
|
||||
|
||||
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX")
|
||||
cleanup() {
|
||||
rm -rf "$tmpdir"
|
||||
|
|
@ -109,18 +155,90 @@ make DESTDIR="$tmpdir" PREFIX=/usr install
|
|||
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
|
||||
|
||||
[ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt"
|
||||
[ -x "$tmpdir/usr/bin/tntctl" ] || fail "missing executable: /usr/bin/tntctl"
|
||||
[ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1"
|
||||
[ -f "$tmpdir/usr/share/man/man1/tntctl.1" ] || fail "missing manpage: /usr/share/man/man1/tntctl.1"
|
||||
[ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] ||
|
||||
fail "missing systemd unit: /usr/lib/systemd/system/tnt.service"
|
||||
grep -q "^ExecStart=/usr/bin/tnt$" "$tmpdir/usr/lib/systemd/system/tnt.service" ||
|
||||
fail "systemd unit ExecStart does not match PREFIX=/usr install path"
|
||||
|
||||
step "checking installed log maintenance modes"
|
||||
log_smoke="$tmpdir/messages.log"
|
||||
recovered_log="$tmpdir/recovered.messages.log"
|
||||
recover_report="$tmpdir/recovered.report"
|
||||
smoke_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
cat > "$log_smoke" <<EOF
|
||||
$smoke_ts|alice|one
|
||||
$smoke_ts|mallory|extra|pipe
|
||||
$smoke_ts|bob|two
|
||||
EOF
|
||||
if "$tmpdir/usr/bin/tnt" --log-check "$log_smoke" >"$tmpdir/log-check.out" 2>&1; then
|
||||
fail "installed tnt --log-check should report invalid records"
|
||||
fi
|
||||
grep -q '^valid_records 2$' "$tmpdir/log-check.out" ||
|
||||
fail "installed tnt --log-check did not report valid records"
|
||||
grep -q '^invalid_records 1$' "$tmpdir/log-check.out" ||
|
||||
fail "installed tnt --log-check did not report invalid records"
|
||||
if "$tmpdir/usr/bin/tnt" --log-recover "$log_smoke" \
|
||||
>"$recovered_log" 2>"$recover_report"; then
|
||||
fail "installed tnt --log-recover should report invalid records"
|
||||
fi
|
||||
grep -q "$smoke_ts|alice|one" "$recovered_log" ||
|
||||
fail "installed tnt --log-recover missed alice record"
|
||||
grep -q "$smoke_ts|bob|two" "$recovered_log" ||
|
||||
fail "installed tnt --log-recover missed bob record"
|
||||
! grep -q 'mallory' "$recovered_log" ||
|
||||
fail "installed tnt --log-recover preserved invalid record"
|
||||
grep -q '^invalid_records 1$' "$recover_report" ||
|
||||
fail "installed tnt --log-recover did not report invalid records"
|
||||
|
||||
step "checking installer syntax"
|
||||
sh -n install.sh
|
||||
sh -n scripts/check_release_ref.sh
|
||||
sh -n scripts/package_publish_check.sh
|
||||
sh -n scripts/package_release_assets.sh
|
||||
sh -n scripts/package_source_archive.sh
|
||||
scripts/check_release_ref.sh "v$version"
|
||||
bad_ref=v0.0.0
|
||||
[ "$version" != "0.0.0" ] || bad_ref=v9.9.9
|
||||
if scripts/check_release_ref.sh "$bad_ref" >/dev/null 2>&1; then
|
||||
fail "release ref check accepted a mismatched tag"
|
||||
fi
|
||||
|
||||
step "checking Debian packaging metadata"
|
||||
[ -x packaging/debian/debian/rules ] ||
|
||||
fail "packaging/debian/debian/rules must be executable"
|
||||
[ -x packaging/debian/debian/postinst ] ||
|
||||
fail "packaging/debian/debian/postinst must be executable"
|
||||
grep -q "^3.0 (quilt)$" packaging/debian/debian/source/format ||
|
||||
fail "unsupported Debian source format"
|
||||
grep -q "adduser .* tnt" packaging/debian/debian/postinst ||
|
||||
fail "Debian postinst must create the tnt system user"
|
||||
grep -q " adduser" packaging/debian/debian/control ||
|
||||
fail "Debian package must depend on adduser for postinst user creation"
|
||||
|
||||
step "checking Debian source assembly"
|
||||
sh -n scripts/package_debian_source.sh
|
||||
scripts/package_debian_source.sh "$tmpdir/debian-source"
|
||||
[ -f "$tmpdir/debian-source/tnt-chat-$version/debian/control" ] ||
|
||||
fail "assembled Debian source tree is missing debian/control"
|
||||
[ -x "$tmpdir/debian-source/tnt-chat-$version/debian/rules" ] ||
|
||||
fail "assembled Debian source tree is missing executable debian/rules"
|
||||
[ -x "$tmpdir/debian-source/tnt-chat-$version/debian/postinst" ] ||
|
||||
fail "assembled Debian source tree is missing executable debian/postinst"
|
||||
|
||||
step "checking packaged system user metadata"
|
||||
grep -q '^u tnt ' packaging/arch/tnt-chat.sysusers ||
|
||||
fail "Arch sysusers file must create the tnt system user"
|
||||
grep -q 'usr/lib/sysusers.d' packaging/arch/PKGBUILD ||
|
||||
fail "PKGBUILD must install the sysusers.d file"
|
||||
|
||||
step "checking Homebrew service metadata"
|
||||
grep -q "service do" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "Homebrew formula must define a brew services entry"
|
||||
grep -q 'opt_bin/"tnt"' packaging/homebrew/tnt-chat.rb ||
|
||||
fail "Homebrew service must run the installed tnt binary"
|
||||
|
||||
step "checking packaging syntax"
|
||||
if command -v bash >/dev/null 2>&1; then
|
||||
|
|
@ -137,14 +255,54 @@ fi
|
|||
|
||||
if [ "$STRICT" -eq 1 ]; then
|
||||
step "checking strict release gates"
|
||||
! grep -q "sha256sums=('SKIP')" packaging/arch/PKGBUILD ||
|
||||
fail "replace PKGBUILD sha256sums before strict release"
|
||||
! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO ||
|
||||
fail "replace .SRCINFO sha256sums before strict release"
|
||||
! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "replace Homebrew sha256 before strict release"
|
||||
[ -z "$(git status --short)" ] ||
|
||||
fail "working tree must be clean for strict release"
|
||||
git rev-parse -q --verify "refs/tags/v$version" >/dev/null ||
|
||||
fail "missing local tag v$version"
|
||||
[ "$(git rev-parse "refs/tags/v$version^{}")" = "$(git rev-parse HEAD)" ] ||
|
||||
fail "local tag v$version does not point at HEAD"
|
||||
grep -q "^## $version " docs/CHANGELOG.md ||
|
||||
fail "docs/CHANGELOG.md does not contain a release section for $version"
|
||||
! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
|
||||
fail "replace maintainer email placeholders before strict release"
|
||||
|
||||
step "checking tagged source archive"
|
||||
archive="$tmpdir/tnt-chat-v$version-source.tar.gz"
|
||||
archive_extract="$tmpdir/source"
|
||||
archive_install="$tmpdir/source-install"
|
||||
archive_root="$archive_extract/TNT-$version"
|
||||
|
||||
scripts/package_source_archive.sh "refs/tags/v$version" "$tmpdir" >/dev/null
|
||||
mkdir -p "$archive_extract"
|
||||
tar -xzf "$archive" -C "$archive_extract"
|
||||
|
||||
[ -f "$archive_root/src/tntctl.c" ] ||
|
||||
fail "tagged source archive is missing src/tntctl.c"
|
||||
[ -f "$archive_root/tnt.1" ] ||
|
||||
fail "tagged source archive is missing tnt.1"
|
||||
[ -f "$archive_root/tntctl.1" ] ||
|
||||
fail "tagged source archive is missing tntctl.1"
|
||||
[ -f "$archive_root/LICENSE" ] ||
|
||||
fail "tagged source archive is missing LICENSE"
|
||||
|
||||
(
|
||||
cd "$archive_root"
|
||||
make
|
||||
make DESTDIR="$archive_install" PREFIX=/usr install
|
||||
make DESTDIR="$archive_install" PREFIX=/usr install-systemd
|
||||
)
|
||||
|
||||
[ -x "$archive_install/usr/bin/tnt" ] ||
|
||||
fail "tagged source install is missing /usr/bin/tnt"
|
||||
[ -x "$archive_install/usr/bin/tntctl" ] ||
|
||||
fail "tagged source install is missing /usr/bin/tntctl"
|
||||
[ -f "$archive_install/usr/share/man/man1/tnt.1" ] ||
|
||||
fail "tagged source install is missing tnt.1"
|
||||
[ -f "$archive_install/usr/share/man/man1/tntctl.1" ] ||
|
||||
fail "tagged source install is missing tntctl.1"
|
||||
grep -q "^ExecStart=/usr/bin/tnt$" \
|
||||
"$archive_install/usr/lib/systemd/system/tnt.service" ||
|
||||
fail "tagged source systemd unit ExecStart does not match /usr/bin/tnt"
|
||||
fi
|
||||
|
||||
step "release preflight passed"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ typedef struct {
|
|||
int pty_width;
|
||||
int pty_height;
|
||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||
bool exec_command_too_long;
|
||||
bool auth_success;
|
||||
int auth_attempts;
|
||||
bool channel_ready; /* Set when shell/exec request received */
|
||||
|
|
@ -294,8 +295,13 @@ static int channel_exec_request(ssh_session session, ssh_channel channel,
|
|||
|
||||
/* Store exec command */
|
||||
if (command) {
|
||||
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
|
||||
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
|
||||
if (strlen(command) >= sizeof(ctx->exec_command)) {
|
||||
ctx->exec_command_too_long = true;
|
||||
ctx->exec_command[0] = '\0';
|
||||
} else {
|
||||
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
|
||||
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* Mark channel as ready */
|
||||
|
|
@ -363,6 +369,7 @@ void *bootstrap_run(void *arg) {
|
|||
ctx->pty_width = 80;
|
||||
ctx->pty_height = 24;
|
||||
ctx->exec_command[0] = '\0';
|
||||
ctx->exec_command_too_long = false;
|
||||
ctx->requested_user[0] = '\0';
|
||||
ctx->auth_success = false;
|
||||
ctx->auth_attempts = 0;
|
||||
|
|
@ -451,6 +458,7 @@ void *bootstrap_run(void *arg) {
|
|||
client->ref_count = 1;
|
||||
pthread_mutex_init(&client->ref_lock, NULL);
|
||||
pthread_mutex_init(&client->io_lock, NULL);
|
||||
pthread_mutex_init(&client->whisper_lock, NULL);
|
||||
|
||||
if (ctx->requested_user[0] != '\0') {
|
||||
strncpy(client->ssh_login, ctx->requested_user,
|
||||
|
|
@ -466,18 +474,14 @@ void *bootstrap_run(void *arg) {
|
|||
sizeof(client->exec_command) - 1);
|
||||
client->exec_command[sizeof(client->exec_command) - 1] = '\0';
|
||||
}
|
||||
|
||||
/* Add a ref for the channel callbacks (eof/close/window_change) so the
|
||||
* client_t outlives any in-flight callback invocation. */
|
||||
client_addref(client);
|
||||
client->exec_command_too_long = ctx->exec_command_too_long;
|
||||
|
||||
if (client_install_channel_callbacks(client) < 0) {
|
||||
/* Nullify session/channel ownership so client_release won't
|
||||
* double-free what cleanup_failed_session is about to free. */
|
||||
client->session = NULL;
|
||||
client->channel = NULL;
|
||||
client_release(client); /* drop the callback ref (2 → 1) */
|
||||
client_release(client); /* drop the main ref (1 → 0, frees client) */
|
||||
client_release(client);
|
||||
cleanup_failed_session(session, ctx);
|
||||
return NULL;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
#include "chat_room.h"
|
||||
#include "config_defaults.h"
|
||||
|
||||
/* Global chat room instance */
|
||||
chat_room_t *g_room = NULL;
|
||||
|
||||
static int room_capacity_from_env(void) {
|
||||
const char *env = getenv("TNT_MAX_CONNECTIONS");
|
||||
|
||||
if (!env || env[0] == '\0') {
|
||||
return MAX_CLIENTS;
|
||||
}
|
||||
|
||||
char *end;
|
||||
long capacity = strtol(env, &end, 10);
|
||||
if (*end != '\0' || capacity < 1 || capacity > 1024) {
|
||||
return MAX_CLIENTS;
|
||||
}
|
||||
|
||||
return (int)capacity;
|
||||
return tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS);
|
||||
}
|
||||
|
||||
/* Initialize chat room */
|
||||
|
|
|
|||
137
src/cli_text.c
137
src/cli_text.c
|
|
@ -1,62 +1,103 @@
|
|||
#include "cli_text.h"
|
||||
|
||||
#include "config_defaults.h"
|
||||
#include "i18n.h"
|
||||
|
||||
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||
const char *program_name, help_lang_t lang) {
|
||||
const char *program_name, ui_lang_t lang) {
|
||||
static const i18n_string_t help_format = I18N_STRING(
|
||||
"tnt %s - anonymous SSH chat server\n\n"
|
||||
"Usage: %s [options]\n\n"
|
||||
"Options:\n"
|
||||
" -p, --port PORT Listen on PORT (default: %d)\n"
|
||||
" -d, --state-dir DIR Store host key and logs in DIR\n"
|
||||
" --bind ADDR Bind to ADDR (default: 0.0.0.0)\n"
|
||||
" --public-host HOST Show HOST in startup connection hints\n"
|
||||
" --max-connections N Global connection limit (default: %d)\n"
|
||||
" --max-conn-per-ip N Per-IP concurrent session limit\n"
|
||||
" --max-conn-rate-per-ip N Per-IP connection-rate limit\n"
|
||||
" --rate-limit 0|1 Disable/enable rate-based blocking\n"
|
||||
" --idle-timeout SECONDS Idle disconnect timeout\n"
|
||||
" --ssh-log-level LEVEL libssh log level 0..4\n"
|
||||
" --log-check FILE Check messages.log v1 records\n"
|
||||
" --log-recover FILE Write valid records to stdout\n"
|
||||
" -V, --version Show version\n"
|
||||
" -h, --help Show this help\n"
|
||||
"\n"
|
||||
"Environment:\n"
|
||||
" PORT Default listening port\n"
|
||||
" TNT_STATE_DIR State directory\n"
|
||||
" TNT_ACCESS_TOKEN Require this password for SSH auth\n"
|
||||
" TNT_LANG UI language: en or zh (default: locale)\n"
|
||||
" TNT_MAX_CONNECTIONS Global connection limit (default: %d)\n"
|
||||
" TNT_RATE_LIMIT Set to 0 to disable rate limiting\n"
|
||||
" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: %d)\n",
|
||||
"tnt %s - 匿名 SSH 聊天服务器\n\n"
|
||||
"用法: %s [options]\n\n"
|
||||
"选项:\n"
|
||||
" -p, --port PORT 监听 PORT (默认: %d)\n"
|
||||
" -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
|
||||
" --bind ADDR 绑定到 ADDR (默认: 0.0.0.0)\n"
|
||||
" --public-host HOST 在启动提示中显示 HOST\n"
|
||||
" --max-connections N 全局连接数限制 (默认: %d)\n"
|
||||
" --max-conn-per-ip N 单 IP 并发会话限制\n"
|
||||
" --max-conn-rate-per-ip N 单 IP 连接速率限制\n"
|
||||
" --rate-limit 0|1 禁用/启用速率封禁\n"
|
||||
" --idle-timeout SECONDS 空闲断开时间\n"
|
||||
" --ssh-log-level LEVEL libssh 日志级别 0..4\n"
|
||||
" --log-check FILE 检查 messages.log v1 记录\n"
|
||||
" --log-recover FILE 将有效记录写入 stdout\n"
|
||||
" -V, --version 显示版本\n"
|
||||
" -h, --help 显示此帮助\n"
|
||||
"\n"
|
||||
"环境变量:\n"
|
||||
" PORT 默认监听端口\n"
|
||||
" TNT_STATE_DIR 状态目录\n"
|
||||
" TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n"
|
||||
" TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n"
|
||||
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: %d)\n"
|
||||
" TNT_RATE_LIMIT 设为 0 可禁用速率限制\n"
|
||||
" TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: %d)\n"
|
||||
);
|
||||
const char *program = (program_name && program_name[0] != '\0')
|
||||
? program_name
|
||||
: "tnt";
|
||||
|
||||
if (lang == LANG_ZH) {
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"tnt %s - 匿名 SSH 聊天服务器\n\n"
|
||||
"用法: %s [选项]\n\n"
|
||||
"选项:\n"
|
||||
" -p, --port PORT 监听 PORT (默认: %d)\n"
|
||||
" -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
|
||||
" -V, --version 显示版本\n"
|
||||
" -h, --help 显示此帮助\n"
|
||||
"\n"
|
||||
"环境变量:\n"
|
||||
" PORT 默认监听端口\n"
|
||||
" TNT_STATE_DIR 状态目录\n"
|
||||
" TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n"
|
||||
" TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n"
|
||||
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: 64)\n"
|
||||
" TNT_RATE_LIMIT 设为 0 可禁用速率限制\n"
|
||||
" TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: 1800)\n",
|
||||
TNT_VERSION, program, DEFAULT_PORT);
|
||||
return;
|
||||
}
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"tnt %s - anonymous SSH chat server\n\n"
|
||||
"Usage: %s [options]\n\n"
|
||||
"Options:\n"
|
||||
" -p, --port PORT Listen on PORT (default: %d)\n"
|
||||
" -d, --state-dir DIR Store host key and logs in DIR\n"
|
||||
" -V, --version Show version\n"
|
||||
" -h, --help Show this help\n"
|
||||
"\n"
|
||||
"Environment:\n"
|
||||
" PORT Default listening port\n"
|
||||
" TNT_STATE_DIR State directory\n"
|
||||
" TNT_ACCESS_TOKEN Require this password for SSH auth\n"
|
||||
" TNT_LANG UI language: en or zh (default: locale)\n"
|
||||
" TNT_MAX_CONNECTIONS Global connection limit (default: 64)\n"
|
||||
" TNT_RATE_LIMIT Set to 0 to disable rate limiting\n"
|
||||
" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: 1800)\n",
|
||||
TNT_VERSION, program, DEFAULT_PORT);
|
||||
buffer_appendf(buffer, buf_size, pos, i18n_string(help_format, lang),
|
||||
TNT_VERSION, program, TNT_DEFAULT_PORT,
|
||||
TNT_DEFAULT_MAX_CONNECTIONS,
|
||||
TNT_DEFAULT_MAX_CONNECTIONS,
|
||||
TNT_DEFAULT_IDLE_TIMEOUT);
|
||||
}
|
||||
|
||||
const char *cli_text_invalid_port_format(help_lang_t lang) {
|
||||
return lang == LANG_ZH ? "端口无效: %s\n" : "Invalid port: %s\n";
|
||||
const char *cli_text_invalid_port_format(ui_lang_t lang) {
|
||||
static const i18n_string_t text =
|
||||
I18N_STRING("Invalid port: %s\n", "端口无效: %s\n");
|
||||
return i18n_string(text, lang);
|
||||
}
|
||||
|
||||
const char *cli_text_unknown_option_format(help_lang_t lang) {
|
||||
return lang == LANG_ZH ? "未知选项: %s\n" : "Unknown option: %s\n";
|
||||
const char *cli_text_invalid_value_format(ui_lang_t lang) {
|
||||
static const i18n_string_t text =
|
||||
I18N_STRING("Invalid %s: %s\n", "%s 无效: %s\n");
|
||||
return i18n_string(text, lang);
|
||||
}
|
||||
|
||||
const char *cli_text_short_usage_format(help_lang_t lang) {
|
||||
return lang == LANG_ZH ? "用法: %s [-p PORT] [-d DIR] [-h]\n"
|
||||
: "Usage: %s [-p PORT] [-d DIR] [-h]\n";
|
||||
const char *cli_text_option_requires_arg_format(ui_lang_t lang) {
|
||||
static const i18n_string_t text =
|
||||
I18N_STRING("Option requires argument: %s\n",
|
||||
"选项需要参数: %s\n");
|
||||
return i18n_string(text, lang);
|
||||
}
|
||||
|
||||
const char *cli_text_unknown_option_format(ui_lang_t lang) {
|
||||
static const i18n_string_t text =
|
||||
I18N_STRING("Unknown option: %s\n", "未知选项: %s\n");
|
||||
return i18n_string(text, lang);
|
||||
}
|
||||
|
||||
const char *cli_text_short_usage_format(ui_lang_t lang) {
|
||||
static const i18n_string_t text =
|
||||
I18N_STRING("Usage: %s [options]\n",
|
||||
"用法: %s [options]\n");
|
||||
return i18n_string(text, lang);
|
||||
}
|
||||
|
|
|
|||
219
src/client.c
219
src/client.c
|
|
@ -9,11 +9,139 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Send data to client via SSH channel */
|
||||
int client_send(client_t *client, const char *data, size_t len) {
|
||||
static int client_send_fail(client_t *client) {
|
||||
if (client) {
|
||||
client->connected = false;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static bool client_is_exec(const client_t *client) {
|
||||
return client && (client->exec_command[0] != '\0' ||
|
||||
client->exec_command_too_long);
|
||||
}
|
||||
|
||||
static int client_write_direct_locked(client_t *client, const char *data,
|
||||
size_t len, size_t budget,
|
||||
bool fail_on_closed_window) {
|
||||
size_t total = 0;
|
||||
|
||||
while (total < len) {
|
||||
size_t remaining = len - total;
|
||||
uint32_t window = ssh_channel_window_size(client->channel);
|
||||
|
||||
if (window == 0) {
|
||||
if (!fail_on_closed_window) {
|
||||
break;
|
||||
}
|
||||
return client_send_fail(client);
|
||||
}
|
||||
|
||||
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
|
||||
if (chunk > window) {
|
||||
chunk = window;
|
||||
}
|
||||
if (budget > 0 && chunk > budget) {
|
||||
chunk = (uint32_t)budget;
|
||||
}
|
||||
|
||||
int sent = ssh_channel_write(client->channel, data + total, chunk);
|
||||
if (sent <= 0) {
|
||||
return client_send_fail(client);
|
||||
}
|
||||
total += (size_t)sent;
|
||||
|
||||
if (budget > 0) {
|
||||
budget -= (size_t)sent;
|
||||
if (budget == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (int)total;
|
||||
}
|
||||
|
||||
static int client_flush_output_locked(client_t *client, size_t budget) {
|
||||
size_t pending;
|
||||
int sent;
|
||||
|
||||
if (!client->outbox || client->outbox_pos >= client->outbox_len) {
|
||||
if (client->outbox) {
|
||||
client->outbox_pos = 0;
|
||||
client->outbox_len = 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
pending = client->outbox_len - client->outbox_pos;
|
||||
sent = client_write_direct_locked(client, client->outbox + client->outbox_pos,
|
||||
pending, budget, false);
|
||||
if (sent < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
client->outbox_pos += (size_t)sent;
|
||||
if (client->outbox_pos >= client->outbox_len) {
|
||||
client->outbox_pos = 0;
|
||||
client->outbox_len = 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int client_compact_outbox(client_t *client) {
|
||||
if (!client->outbox || client->outbox_pos == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (client->outbox_pos < client->outbox_len) {
|
||||
memmove(client->outbox, client->outbox + client->outbox_pos,
|
||||
client->outbox_len - client->outbox_pos);
|
||||
client->outbox_len -= client->outbox_pos;
|
||||
} else {
|
||||
client->outbox_len = 0;
|
||||
}
|
||||
client->outbox_pos = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int client_enqueue_output_locked(client_t *client, const char *data,
|
||||
size_t len) {
|
||||
if (len == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (len > CLIENT_OUTBOX_CAPACITY) {
|
||||
return client_send_fail(client);
|
||||
}
|
||||
|
||||
if (!client->outbox) {
|
||||
client->outbox = malloc(CLIENT_OUTBOX_CAPACITY);
|
||||
if (!client->outbox) {
|
||||
return client_send_fail(client);
|
||||
}
|
||||
client->outbox_capacity = CLIENT_OUTBOX_CAPACITY;
|
||||
client->outbox_len = 0;
|
||||
client->outbox_pos = 0;
|
||||
}
|
||||
|
||||
client_compact_outbox(client);
|
||||
if (client->outbox_len + len > client->outbox_capacity) {
|
||||
return client_send_fail(client);
|
||||
}
|
||||
|
||||
memcpy(client->outbox + client->outbox_len, data, len);
|
||||
client->outbox_len += len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Send data to client via SSH channel */
|
||||
int client_send(client_t *client, const char *data, size_t len) {
|
||||
int rc = 0;
|
||||
|
||||
if (!client || !data) return -1;
|
||||
if (len == 0) return 0;
|
||||
|
||||
pthread_mutex_lock(&client->io_lock);
|
||||
|
||||
|
|
@ -22,23 +150,57 @@ int client_send(client_t *client, const char *data, size_t len) {
|
|||
return -1;
|
||||
}
|
||||
|
||||
while (total < len) {
|
||||
size_t remaining = len - total;
|
||||
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
|
||||
int sent = ssh_channel_write(client->channel, data + total, chunk);
|
||||
if (sent <= 0) {
|
||||
pthread_mutex_unlock(&client->io_lock);
|
||||
return -1;
|
||||
if (client_is_exec(client)) {
|
||||
rc = client_write_direct_locked(client, data, len, 0, true);
|
||||
if (rc >= 0 && (size_t)rc == len) {
|
||||
rc = 0;
|
||||
} else if (rc >= 0) {
|
||||
rc = client_send_fail(client);
|
||||
}
|
||||
total += (size_t)sent;
|
||||
}
|
||||
|
||||
if (client->exec_command[0] != '\0') {
|
||||
ssh_blocking_flush(client->session, 1000);
|
||||
} else {
|
||||
rc = client_enqueue_output_locked(client, data, len);
|
||||
if (rc == 0) {
|
||||
rc = client_flush_output_locked(client, CLIENT_OUTBOX_FLUSH_BUDGET);
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&client->io_lock);
|
||||
return 0;
|
||||
return rc;
|
||||
}
|
||||
|
||||
int client_flush_output(client_t *client) {
|
||||
int rc;
|
||||
|
||||
if (!client) return 0;
|
||||
|
||||
pthread_mutex_lock(&client->io_lock);
|
||||
|
||||
if (!client->connected || !client->channel) {
|
||||
pthread_mutex_unlock(&client->io_lock);
|
||||
return -1;
|
||||
}
|
||||
|
||||
rc = client_flush_output_locked(client, CLIENT_OUTBOX_FLUSH_BUDGET);
|
||||
pthread_mutex_unlock(&client->io_lock);
|
||||
return rc;
|
||||
}
|
||||
|
||||
void client_queue_bell(client_t *client) {
|
||||
if (!client) return;
|
||||
|
||||
atomic_store(&client->pending_bells, 1);
|
||||
client->redraw_pending = true;
|
||||
}
|
||||
|
||||
int client_flush_pending_bells(client_t *client) {
|
||||
if (!client) return 0;
|
||||
|
||||
if (atomic_exchange(&client->pending_bells, 0) <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return client_send(client, "\a", 1);
|
||||
}
|
||||
|
||||
void client_addref(client_t *client) {
|
||||
|
|
@ -74,12 +236,34 @@ void client_release(client_t *client) {
|
|||
if (client->channel_cb) {
|
||||
free(client->channel_cb);
|
||||
}
|
||||
free(client->outbox);
|
||||
free(client->render_buffer);
|
||||
pthread_mutex_destroy(&client->io_lock);
|
||||
pthread_mutex_destroy(&client->whisper_lock);
|
||||
pthread_mutex_destroy(&client->ref_lock);
|
||||
free(client);
|
||||
}
|
||||
}
|
||||
|
||||
void client_release_session(client_t *client) {
|
||||
if (!client) return;
|
||||
|
||||
if (client->channel && client->channel_cb) {
|
||||
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
|
||||
}
|
||||
if (client->channel_cb) {
|
||||
free(client->channel_cb);
|
||||
client->channel_cb = NULL;
|
||||
}
|
||||
|
||||
if (client->channel_callback_ref) {
|
||||
client->channel_callback_ref = false;
|
||||
client_release(client);
|
||||
}
|
||||
|
||||
client_release(client);
|
||||
}
|
||||
|
||||
/* Send formatted string to client */
|
||||
int client_printf(client_t *client, const char *fmt, ...) {
|
||||
char buffer[2048];
|
||||
|
|
@ -151,8 +335,13 @@ int client_install_channel_callbacks(client_t *client) {
|
|||
return -1;
|
||||
}
|
||||
|
||||
client_addref(client);
|
||||
client->channel_callback_ref = true;
|
||||
|
||||
client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct));
|
||||
if (!client->channel_cb) {
|
||||
client->channel_callback_ref = false;
|
||||
client_release(client);
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +355,8 @@ int client_install_channel_callbacks(client_t *client) {
|
|||
if (ssh_set_channel_callbacks(client->channel, client->channel_cb) != SSH_OK) {
|
||||
free(client->channel_cb);
|
||||
client->channel_cb = NULL;
|
||||
client->channel_callback_ref = false;
|
||||
client_release(client);
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
|
|
|||
326
src/command_catalog.c
Normal file
326
src/command_catalog.c
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
#include "command_catalog.h"
|
||||
|
||||
#include "i18n.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
tnt_command_spec_t spec;
|
||||
i18n_string_t full_usage;
|
||||
i18n_string_t summary;
|
||||
i18n_string_t manual_usage;
|
||||
i18n_string_t error_usage;
|
||||
int manual_group;
|
||||
bool no_args;
|
||||
bool requires_args;
|
||||
} command_catalog_entry_t;
|
||||
|
||||
static const command_catalog_entry_t entries[] = {
|
||||
{
|
||||
{TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}},
|
||||
I18N_STRING(":users, :list, :who", ":users, :list, :who"),
|
||||
I18N_STRING("Show online users", "显示在线用户"),
|
||||
I18N_STRING(":users", ":users"),
|
||||
I18N_STRING("Usage: users\n", "用法: users\n"),
|
||||
1, true, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}},
|
||||
I18N_STRING(":msg <user> <message>, :w <user> <message>",
|
||||
":msg <user> <message>, :w <user> <message>"),
|
||||
I18N_STRING("Send private message", "发送私信"),
|
||||
I18N_STRING(":msg <user> <message>", ":msg <user> <message>"),
|
||||
I18N_STRING("Usage: msg <user> <message>\n"
|
||||
" w <user> <message>\n",
|
||||
"用法: msg <user> <message>\n"
|
||||
" w <user> <message>\n"),
|
||||
2, false, true
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_REPLY, "reply", {"reply", "r", NULL}},
|
||||
I18N_STRING(":reply <message>, :r <message>",
|
||||
":reply <message>, :r <message>"),
|
||||
I18N_STRING("Reply to latest private message", "回复最近私信"),
|
||||
I18N_STRING(":reply <message>", ":reply <message>"),
|
||||
I18N_STRING("Usage: reply <message>\n"
|
||||
" r <message>\n",
|
||||
"用法: reply <message>\n"
|
||||
" r <message>\n"),
|
||||
2, false, true
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}},
|
||||
I18N_STRING(":inbox, :inbox clear", ":inbox, :inbox clear"),
|
||||
I18N_STRING("Show or clear private messages", "查看或清空私信"),
|
||||
I18N_STRING(":inbox", ":inbox"),
|
||||
I18N_STRING("Usage: inbox [clear]\n", "用法: inbox [clear]\n"),
|
||||
2, false, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}},
|
||||
I18N_STRING(":nick <name>, :name <name>",
|
||||
":nick <name>, :name <name>"),
|
||||
I18N_STRING("Change nickname", "更改昵称"),
|
||||
I18N_STRING(":nick <name>", ":nick <name>"),
|
||||
I18N_STRING("Usage: nick <name>\n", "用法: nick <name>\n"),
|
||||
2, false, true
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_LAST, "last", {"last", NULL}},
|
||||
I18N_STRING(":last [N]", ":last [N]"),
|
||||
I18N_STRING("Show last N messages (max 50)",
|
||||
"显示最后 N 条消息(最多50)"),
|
||||
I18N_STRING(":last [N]", ":last [N]"),
|
||||
I18N_STRING("Usage: last [N] (N: 1-50, default 10)\n",
|
||||
"用法: last [N] (N: 1-50,默认 10)\n"),
|
||||
1, false, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_SEARCH, "search", {"search", NULL}},
|
||||
I18N_STRING(":search <keyword>", ":search <keyword>"),
|
||||
I18N_STRING("Search message history", "搜索消息历史"),
|
||||
I18N_STRING(":search <keyword>", ":search <keyword>"),
|
||||
I18N_STRING("Usage: search <keyword>\n", "用法: search <keyword>\n"),
|
||||
1, false, true
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}},
|
||||
I18N_STRING(":mute-joins, :mute", ":mute-joins, :mute"),
|
||||
I18N_STRING("Toggle join/leave notices", "切换加入/离开提示"),
|
||||
I18N_STRING(":mute-joins", ":mute-joins"),
|
||||
I18N_STRING("Usage: mute-joins\n", "用法: mute-joins\n"),
|
||||
3, true, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_HELP, "help", {"help", NULL}},
|
||||
I18N_STRING(":help", ":help"),
|
||||
I18N_STRING("Show concise manual", "显示简明手册"),
|
||||
I18N_STRING(NULL, NULL),
|
||||
I18N_STRING("Usage: help\n", "用法: help\n"),
|
||||
0, true, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}},
|
||||
I18N_STRING(":lang <en|zh>", ":lang <en|zh>"),
|
||||
I18N_STRING("Switch UI language", "切换界面语言"),
|
||||
I18N_STRING(NULL, NULL),
|
||||
I18N_STRING("Usage: lang <en|zh>\n", "用法: lang <en|zh>\n"),
|
||||
0, false, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}},
|
||||
I18N_STRING(":clear, :cls", ":clear, :cls"),
|
||||
I18N_STRING("Clear command output", "清空命令输出"),
|
||||
I18N_STRING(":clear", ":clear"),
|
||||
I18N_STRING("Usage: clear\n", "用法: clear\n"),
|
||||
3, true, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}},
|
||||
I18N_STRING(":q, :quit, :exit", ":q, :quit, :exit"),
|
||||
I18N_STRING("Disconnect", "断开连接"),
|
||||
I18N_STRING(":q", ":q"),
|
||||
I18N_STRING("Usage: q\n", "用法: q\n"),
|
||||
3, true, false
|
||||
}
|
||||
};
|
||||
|
||||
static const command_catalog_entry_t *entry_for_id(tnt_command_id_t id) {
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
if (entries[i].spec.id == id) {
|
||||
return &entries[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static const char *skip_spaces(const char *value) {
|
||||
while (value && *value == ' ') {
|
||||
value++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool name_matches(const char *line, const char *name,
|
||||
const char **args) {
|
||||
size_t len;
|
||||
|
||||
if (!line || !name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
len = strlen(name);
|
||||
if (strncmp(line, name, len) != 0) {
|
||||
return false;
|
||||
}
|
||||
if (line[len] != '\0' && line[len] != ' ') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (args) {
|
||||
*args = skip_spaces(line + len);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static int min3(int a, int b, int c) {
|
||||
int m = a < b ? a : b;
|
||||
return m < c ? m : c;
|
||||
}
|
||||
|
||||
static int edit_distance(const char *a, const char *b) {
|
||||
size_t la = strlen(a);
|
||||
size_t lb = strlen(b);
|
||||
int prev[32];
|
||||
int curr[32];
|
||||
|
||||
if (la >= 32 || lb >= 32) {
|
||||
return 99;
|
||||
}
|
||||
|
||||
for (size_t j = 0; j <= lb; j++) {
|
||||
prev[j] = (int)j;
|
||||
}
|
||||
|
||||
for (size_t i = 1; i <= la; i++) {
|
||||
curr[0] = (int)i;
|
||||
for (size_t j = 1; j <= lb; j++) {
|
||||
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||
curr[j] = min3(prev[j] + 1, curr[j - 1] + 1,
|
||||
prev[j - 1] + cost);
|
||||
}
|
||||
for (size_t j = 0; j <= lb; j++) {
|
||||
prev[j] = curr[j];
|
||||
}
|
||||
}
|
||||
|
||||
return prev[lb];
|
||||
}
|
||||
|
||||
const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id) {
|
||||
const command_catalog_entry_t *entry = entry_for_id(id);
|
||||
return entry ? &entry->spec : NULL;
|
||||
}
|
||||
|
||||
bool command_catalog_match(const char *line, tnt_command_id_t *id,
|
||||
const char **args) {
|
||||
line = skip_spaces(line);
|
||||
if (!line || line[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const tnt_command_spec_t *spec = &entries[i].spec;
|
||||
for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) {
|
||||
const char *candidate_args = NULL;
|
||||
if (!spec->names[n]) {
|
||||
break;
|
||||
}
|
||||
if (!name_matches(line, spec->names[n], &candidate_args)) {
|
||||
continue;
|
||||
}
|
||||
if (id) {
|
||||
*id = spec->id;
|
||||
}
|
||||
if (args) {
|
||||
*args = candidate_args ? candidate_args : "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool command_catalog_args_valid(tnt_command_id_t id, const char *args) {
|
||||
const command_catalog_entry_t *entry = entry_for_id(id);
|
||||
args = skip_spaces(args);
|
||||
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (id == TNT_COMMAND_INBOX) {
|
||||
return !args || args[0] == '\0' || strcmp(args, "clear") == 0;
|
||||
}
|
||||
if (entry->no_args) {
|
||||
return !args || args[0] == '\0';
|
||||
}
|
||||
if (entry->requires_args) {
|
||||
return args && args[0] != '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *command_catalog_suggest(const char *name) {
|
||||
const char *best = NULL;
|
||||
int best_distance = 99;
|
||||
|
||||
if (!name || !*name) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const tnt_command_spec_t *spec = &entries[i].spec;
|
||||
for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) {
|
||||
int distance;
|
||||
if (!spec->names[n]) {
|
||||
break;
|
||||
}
|
||||
distance = edit_distance(name, spec->names[n]);
|
||||
if (distance < best_distance) {
|
||||
best_distance = distance;
|
||||
best = spec->canonical;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best_distance <= 2 ? best : NULL;
|
||||
}
|
||||
|
||||
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang) {
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const char *usage = i18n_string(entries[i].full_usage, lang);
|
||||
const char *summary = i18n_string(entries[i].summary, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, " %-40s - %s\n",
|
||||
usage, summary);
|
||||
}
|
||||
}
|
||||
|
||||
void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang) {
|
||||
for (int group = 1; group <= 3; group++) {
|
||||
bool first = true;
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, " ");
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const char *usage;
|
||||
if (entries[i].manual_group != group) {
|
||||
continue;
|
||||
}
|
||||
usage = i18n_string(entries[i].manual_usage, lang);
|
||||
if (!usage || usage[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
if (!first) {
|
||||
buffer_appendf(buffer, buf_size, pos, ", ");
|
||||
}
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", usage);
|
||||
first = false;
|
||||
}
|
||||
buffer_appendf(buffer, buf_size, pos, "\n");
|
||||
}
|
||||
}
|
||||
|
||||
void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||
tnt_command_id_t id, ui_lang_t lang) {
|
||||
const command_catalog_entry_t *entry = entry_for_id(id);
|
||||
const char *usage;
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
usage = i18n_string(entry->error_usage, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", usage);
|
||||
}
|
||||
538
src/commands.c
538
src/commands.c
|
|
@ -7,11 +7,11 @@
|
|||
#include "commands.h"
|
||||
#include "chat_room.h"
|
||||
#include "client.h"
|
||||
#include "command_catalog.h"
|
||||
#include "common.h"
|
||||
#include "help_text.h"
|
||||
#include "i18n.h"
|
||||
#include "manual.h"
|
||||
#include "message.h"
|
||||
#include "support.h"
|
||||
#include "system_message.h"
|
||||
#include "tui.h"
|
||||
#include "utf8.h"
|
||||
|
|
@ -47,63 +47,171 @@ static void append_highlighted(char *output, size_t buf_size, size_t *pos,
|
|||
}
|
||||
}
|
||||
|
||||
static int min3(int a, int b, int c) {
|
||||
int m = a < b ? a : b;
|
||||
return m < c ? m : c;
|
||||
static void append_command_usage(char *output, size_t buf_size, size_t *pos,
|
||||
tnt_command_id_t id, ui_lang_t lang) {
|
||||
command_catalog_append_usage(output, buf_size, pos, id, lang);
|
||||
}
|
||||
|
||||
static int command_edit_distance(const char *a, const char *b) {
|
||||
size_t la = strlen(a);
|
||||
size_t lb = strlen(b);
|
||||
int prev[32];
|
||||
int curr[32];
|
||||
|
||||
if (la >= 32 || lb >= 32) {
|
||||
return 99;
|
||||
}
|
||||
|
||||
for (size_t j = 0; j <= lb; j++) {
|
||||
prev[j] = (int)j;
|
||||
}
|
||||
|
||||
for (size_t i = 1; i <= la; i++) {
|
||||
curr[0] = (int)i;
|
||||
for (size_t j = 1; j <= lb; j++) {
|
||||
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||
curr[j] = min3(prev[j] + 1, curr[j - 1] + 1,
|
||||
prev[j - 1] + cost);
|
||||
}
|
||||
for (size_t j = 0; j <= lb; j++) {
|
||||
prev[j] = curr[j];
|
||||
}
|
||||
}
|
||||
|
||||
return prev[lb];
|
||||
static bool message_visible_for_client(const client_t *client,
|
||||
const message_t *msg) {
|
||||
return !client || !client->mute_joins ||
|
||||
!system_message_is_join_leave(msg);
|
||||
}
|
||||
|
||||
static const char *suggest_command(const char *cmd) {
|
||||
static const char *commands[] = {
|
||||
"list", "users", "who", "nick", "name", "msg", "w", "inbox",
|
||||
"last", "search", "mute-joins", "mute", "support", "guide",
|
||||
"lang", "language", "help", "commands", "clear", "cls",
|
||||
"q", "quit", "exit"
|
||||
};
|
||||
const char *best = NULL;
|
||||
int best_distance = 99;
|
||||
static void client_append_whisper(client_t *owner, const char *from,
|
||||
const char *to, const char *content,
|
||||
bool outgoing, bool count_unread) {
|
||||
if (!owner || !from || !to || !content) return;
|
||||
|
||||
if (!cmd || !*cmd) {
|
||||
return NULL;
|
||||
pthread_mutex_lock(&owner->whisper_lock);
|
||||
int slot;
|
||||
if (owner->whisper_inbox_count < WHISPER_INBOX_SIZE) {
|
||||
slot = owner->whisper_inbox_count++;
|
||||
} else {
|
||||
memmove(&owner->whisper_inbox[0],
|
||||
&owner->whisper_inbox[1],
|
||||
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
|
||||
slot = WHISPER_INBOX_SIZE - 1;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) {
|
||||
int distance = command_edit_distance(cmd, commands[i]);
|
||||
if (distance < best_distance) {
|
||||
best_distance = distance;
|
||||
best = commands[i];
|
||||
owner->whisper_inbox[slot].timestamp = time(NULL);
|
||||
snprintf(owner->whisper_inbox[slot].from,
|
||||
sizeof(owner->whisper_inbox[slot].from), "%s", from);
|
||||
snprintf(owner->whisper_inbox[slot].to,
|
||||
sizeof(owner->whisper_inbox[slot].to), "%s", to);
|
||||
snprintf(owner->whisper_inbox[slot].content,
|
||||
sizeof(owner->whisper_inbox[slot].content), "%s", content);
|
||||
owner->whisper_inbox[slot].outgoing = outgoing;
|
||||
owner->whisper_inbox[slot].unread = count_unread;
|
||||
snprintf(owner->last_whisper_peer, sizeof(owner->last_whisper_peer), "%s",
|
||||
outgoing ? to : from);
|
||||
if (count_unread) {
|
||||
owner->unread_whispers++;
|
||||
}
|
||||
pthread_mutex_unlock(&owner->whisper_lock);
|
||||
}
|
||||
|
||||
static void send_private_message(client_t *client, const char *target_name,
|
||||
const char *content, char *output,
|
||||
size_t buf_size, size_t *pos) {
|
||||
bool found = false;
|
||||
client_t *target = NULL;
|
||||
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
for (int i = 0; i < g_room->client_count; i++) {
|
||||
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
|
||||
target = g_room->clients[i];
|
||||
client_addref(target);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
return best_distance <= 2 ? best : NULL;
|
||||
if (target) {
|
||||
client_append_whisper(target, client->username, target_name,
|
||||
content, false, true);
|
||||
if (target != client) {
|
||||
client_append_whisper(client, client->username, target_name,
|
||||
content, true, false);
|
||||
}
|
||||
|
||||
/* Audible nudge: the title bar whisper counter carries the
|
||||
* persistent signal without cross-client SSH writes. */
|
||||
client_queue_bell(target);
|
||||
client_release(target);
|
||||
}
|
||||
|
||||
if (found) {
|
||||
buffer_appendf(output, buf_size, pos,
|
||||
i18n_text(client->ui_lang, I18N_MSG_SENT_FORMAT),
|
||||
target_name);
|
||||
} else {
|
||||
buffer_appendf(output, buf_size, pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_MSG_USER_NOT_FOUND_FORMAT),
|
||||
target_name);
|
||||
}
|
||||
}
|
||||
|
||||
static void append_inbox_output(client_t *client, char *output,
|
||||
size_t buf_size, size_t *pos) {
|
||||
whisper_t snapshot[WHISPER_INBOX_SIZE];
|
||||
int snap_count;
|
||||
int unread_count;
|
||||
|
||||
pthread_mutex_lock(&client->whisper_lock);
|
||||
snap_count = client->whisper_inbox_count;
|
||||
unread_count = client->unread_whispers;
|
||||
memcpy(snapshot, client->whisper_inbox,
|
||||
snap_count * sizeof(whisper_t));
|
||||
for (int i = 0; i < snap_count; i++) {
|
||||
client->whisper_inbox[i].unread = false;
|
||||
}
|
||||
client->unread_whispers = 0;
|
||||
pthread_mutex_unlock(&client->whisper_lock);
|
||||
|
||||
buffer_appendf(output, buf_size, pos,
|
||||
"\033[1;36m%s\033[0m \033[2;37m· %d",
|
||||
i18n_text(client->ui_lang, I18N_INBOX_TITLE),
|
||||
snap_count);
|
||||
if (unread_count > 0) {
|
||||
buffer_appendf(output, buf_size, pos,
|
||||
" · ");
|
||||
buffer_appendf(output, buf_size, pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_INBOX_UNREAD_FORMAT),
|
||||
unread_count);
|
||||
}
|
||||
buffer_appendf(output, buf_size, pos, "\033[0m\n");
|
||||
if (snap_count == 0) {
|
||||
buffer_appendf(output, buf_size, pos,
|
||||
" \033[2;37m%s\033[0m\n",
|
||||
i18n_text(client->ui_lang, I18N_INBOX_EMPTY));
|
||||
}
|
||||
for (int i = snap_count - 1; i >= 0; i--) {
|
||||
char ts[20];
|
||||
char peer[MAX_USERNAME_LEN + 16];
|
||||
const char *marker = snapshot[i].unread ? "\033[1;35m*\033[0m" : " ";
|
||||
struct tm tmi;
|
||||
localtime_r(&snapshot[i].timestamp, &tmi);
|
||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
||||
if (snapshot[i].outgoing) {
|
||||
snprintf(peer, sizeof(peer),
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_INBOX_SENT_TO_FORMAT),
|
||||
snapshot[i].to);
|
||||
} else {
|
||||
snprintf(peer, sizeof(peer), "%s", snapshot[i].from);
|
||||
}
|
||||
buffer_appendf(output, buf_size, pos,
|
||||
" %s \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
|
||||
marker, ts, peer, snapshot[i].content);
|
||||
}
|
||||
}
|
||||
|
||||
static void clear_inbox(client_t *client) {
|
||||
pthread_mutex_lock(&client->whisper_lock);
|
||||
memset(client->whisper_inbox, 0, sizeof(client->whisper_inbox));
|
||||
client->whisper_inbox_count = 0;
|
||||
client->unread_whispers = 0;
|
||||
client->last_whisper_peer[0] = '\0';
|
||||
pthread_mutex_unlock(&client->whisper_lock);
|
||||
}
|
||||
|
||||
bool commands_refresh_active_output(client_t *client) {
|
||||
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
|
||||
size_t pos = 0;
|
||||
|
||||
if (!client || client->command_output_kind != TNT_COMMAND_OUTPUT_INBOX) {
|
||||
return false;
|
||||
}
|
||||
|
||||
append_inbox_output(client, output, sizeof(output), &pos);
|
||||
snprintf(client->command_output, sizeof(client->command_output), "%s",
|
||||
output);
|
||||
client->command_output_scroll = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
void commands_dispatch(client_t *client) {
|
||||
|
|
@ -111,7 +219,8 @@ void commands_dispatch(client_t *client) {
|
|||
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
|
||||
cmd_buf[sizeof(cmd_buf) - 1] = '\0';
|
||||
char *cmd = cmd_buf;
|
||||
char output[2048] = {0};
|
||||
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
|
||||
tnt_command_output_kind_t output_kind = TNT_COMMAND_OUTPUT_GENERIC;
|
||||
size_t pos = 0;
|
||||
|
||||
/* Trim whitespace */
|
||||
|
|
@ -124,6 +233,10 @@ void commands_dispatch(client_t *client) {
|
|||
end--;
|
||||
}
|
||||
}
|
||||
if (cmd[0] == ':') {
|
||||
cmd++;
|
||||
while (*cmd == ' ') cmd++;
|
||||
}
|
||||
|
||||
/* Save to command history */
|
||||
if (cmd[0] != '\0') {
|
||||
|
|
@ -139,13 +252,45 @@ void commands_dispatch(client_t *client) {
|
|||
client->command_history_pos = client->command_history_count;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
|
||||
strcmp(cmd, "who") == 0) {
|
||||
if (cmd[0] == '\0') {
|
||||
/* Empty command */
|
||||
client->mode = MODE_NORMAL;
|
||||
client->command_input[0] = '\0';
|
||||
tui_render_screen(client);
|
||||
return;
|
||||
}
|
||||
|
||||
tnt_command_id_t command_id;
|
||||
const char *arg = "";
|
||||
if (!command_catalog_match(cmd, &command_id, &arg)) {
|
||||
const char *suggestion = command_catalog_suggest(cmd);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_UNKNOWN_COMMAND_FORMAT),
|
||||
cmd);
|
||||
if (suggestion) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_DID_YOU_MEAN_FORMAT),
|
||||
suggestion);
|
||||
}
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->ui_lang, I18N_UNKNOWN_GUIDANCE));
|
||||
goto cmd_done;
|
||||
}
|
||||
|
||||
if (!command_catalog_args_valid(command_id, arg)) {
|
||||
append_command_usage(output, sizeof(output), &pos, command_id,
|
||||
client->ui_lang);
|
||||
goto cmd_done;
|
||||
}
|
||||
|
||||
if (command_id == TNT_COMMAND_USERS) {
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
int total = g_room->client_count;
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
|
||||
i18n_text(client->help_lang, I18N_USERS_TITLE), total);
|
||||
i18n_text(client->ui_lang, I18N_USERS_TITLE), total);
|
||||
|
||||
time_t now = time(NULL);
|
||||
for (int i = 0; i < total; i++) {
|
||||
|
|
@ -168,47 +313,33 @@ void commands_dispatch(client_t *client) {
|
|||
}
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
} else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "commands") == 0) {
|
||||
help_text_append_commands(output, sizeof(output), &pos,
|
||||
client->help_lang);
|
||||
} else if (command_id == TNT_COMMAND_HELP) {
|
||||
manual_append_interactive_panel(output, sizeof(output), &pos,
|
||||
client->ui_lang);
|
||||
|
||||
} else if (strcmp(cmd, "support") == 0 || strcmp(cmd, "guide") == 0) {
|
||||
support_append_interactive_panel(output, sizeof(output), &pos,
|
||||
client->help_lang);
|
||||
|
||||
} else if (strcmp(cmd, "lang") == 0 || strcmp(cmd, "language") == 0 ||
|
||||
strncmp(cmd, "lang ", 5) == 0 ||
|
||||
strncmp(cmd, "language ", 9) == 0) {
|
||||
char *arg = NULL;
|
||||
help_lang_t next_lang;
|
||||
|
||||
if (strncmp(cmd, "lang ", 5) == 0) {
|
||||
arg = cmd + 5;
|
||||
} else if (strncmp(cmd, "language ", 9) == 0) {
|
||||
arg = cmd + 9;
|
||||
}
|
||||
} else if (command_id == TNT_COMMAND_LANG) {
|
||||
ui_lang_t next_lang;
|
||||
|
||||
if (!arg || arg[0] == '\0') {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_LANG_CURRENT_FORMAT),
|
||||
i18n_lang_code(client->help_lang));
|
||||
} else if (i18n_try_parse_lang(arg, &next_lang)) {
|
||||
client->help_lang = next_lang;
|
||||
i18n_ui_lang_code(client->ui_lang));
|
||||
} else if (i18n_try_parse_ui_lang(arg, &next_lang)) {
|
||||
client->ui_lang = next_lang;
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_LANG_SET_FORMAT),
|
||||
i18n_lang_code(client->help_lang));
|
||||
i18n_ui_lang_code(client->ui_lang));
|
||||
} else {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_LANG_UNSUPPORTED_FORMAT),
|
||||
arg);
|
||||
}
|
||||
|
||||
} else if (strcmp(cmd, "msg") == 0 || strcmp(cmd, "w") == 0 ||
|
||||
strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
|
||||
char *rest = (cmd[0] == 'w') ? cmd + 1 : cmd + 3;
|
||||
} else if (command_id == TNT_COMMAND_MSG) {
|
||||
const char *rest = arg;
|
||||
while (*rest == ' ') rest++;
|
||||
char target_name[MAX_USERNAME_LEN] = {0};
|
||||
int ti = 0;
|
||||
|
|
@ -218,108 +349,63 @@ void commands_dispatch(client_t *client) {
|
|||
while (*rest == ' ') rest++;
|
||||
|
||||
if (target_name[0] == '\0' || rest[0] == '\0') {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->help_lang, I18N_MSG_USAGE));
|
||||
append_command_usage(output, sizeof(output), &pos,
|
||||
TNT_COMMAND_MSG, client->ui_lang);
|
||||
} else {
|
||||
bool found = false;
|
||||
client_t *target = NULL;
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
for (int i = 0; i < g_room->client_count; i++) {
|
||||
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
|
||||
target = g_room->clients[i];
|
||||
client_addref(target);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
send_private_message(client, target_name, rest, output,
|
||||
sizeof(output), &pos);
|
||||
}
|
||||
|
||||
if (target) {
|
||||
/* Push into recipient's inbox. io_lock serialises so two
|
||||
* senders to the same recipient don't tear the ring. */
|
||||
pthread_mutex_lock(&target->io_lock);
|
||||
int slot;
|
||||
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
|
||||
slot = target->whisper_inbox_count++;
|
||||
} else {
|
||||
/* FIFO evict the oldest */
|
||||
memmove(&target->whisper_inbox[0],
|
||||
&target->whisper_inbox[1],
|
||||
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
|
||||
slot = WHISPER_INBOX_SIZE - 1;
|
||||
}
|
||||
target->whisper_inbox[slot].timestamp = time(NULL);
|
||||
snprintf(target->whisper_inbox[slot].from,
|
||||
sizeof(target->whisper_inbox[slot].from),
|
||||
"%s", client->username);
|
||||
snprintf(target->whisper_inbox[slot].content,
|
||||
sizeof(target->whisper_inbox[slot].content),
|
||||
"%s", rest);
|
||||
pthread_mutex_unlock(&target->io_lock);
|
||||
} else if (command_id == TNT_COMMAND_REPLY) {
|
||||
const char *message = arg;
|
||||
char target_name[MAX_USERNAME_LEN] = {0};
|
||||
|
||||
target->unread_whispers++;
|
||||
target->redraw_pending = true;
|
||||
/* Audible nudge — the title bar ✉ counter (UX-11 style)
|
||||
* carries the persistent signal. */
|
||||
client_send(target, "\a", 1);
|
||||
client_release(target);
|
||||
}
|
||||
while (*message == ' ') message++;
|
||||
if (message[0] == '\0') {
|
||||
append_command_usage(output, sizeof(output), &pos,
|
||||
TNT_COMMAND_REPLY, client->ui_lang);
|
||||
} else {
|
||||
pthread_mutex_lock(&client->whisper_lock);
|
||||
snprintf(target_name, sizeof(target_name), "%s",
|
||||
client->last_whisper_peer);
|
||||
pthread_mutex_unlock(&client->whisper_lock);
|
||||
|
||||
if (found) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
I18N_MSG_SENT_FORMAT),
|
||||
target_name);
|
||||
if (target_name[0] == '\0') {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_REPLY_NO_TARGET));
|
||||
} else {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
I18N_MSG_USER_NOT_FOUND_FORMAT),
|
||||
target_name);
|
||||
send_private_message(client, target_name, message, output,
|
||||
sizeof(output), &pos);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (strcmp(cmd, "inbox") == 0) {
|
||||
/* Snapshot the inbox under io_lock so a concurrent sender doesn't
|
||||
* tear what we're rendering. Counter reset happens after copy. */
|
||||
whisper_t snapshot[WHISPER_INBOX_SIZE];
|
||||
int snap_count;
|
||||
pthread_mutex_lock(&client->io_lock);
|
||||
snap_count = client->whisper_inbox_count;
|
||||
memcpy(snapshot, client->whisper_inbox,
|
||||
snap_count * sizeof(whisper_t));
|
||||
pthread_mutex_unlock(&client->io_lock);
|
||||
client->unread_whispers = 0;
|
||||
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
|
||||
i18n_text(client->help_lang, I18N_INBOX_TITLE),
|
||||
snap_count);
|
||||
if (snap_count == 0) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
" \033[2;37m%s\033[0m\n",
|
||||
i18n_text(client->help_lang, I18N_INBOX_EMPTY));
|
||||
}
|
||||
for (int i = 0; i < snap_count; i++) {
|
||||
char ts[20];
|
||||
struct tm tmi;
|
||||
localtime_r(&snapshot[i].timestamp, &tmi);
|
||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
" \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
|
||||
ts, snapshot[i].from, snapshot[i].content);
|
||||
} else if (command_id == TNT_COMMAND_INBOX) {
|
||||
const char *inbox_arg = arg;
|
||||
while (*inbox_arg == ' ') inbox_arg++;
|
||||
if (strcmp(inbox_arg, "clear") == 0) {
|
||||
clear_inbox(client);
|
||||
output_kind = TNT_COMMAND_OUTPUT_INBOX;
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_INBOX_CLEARED));
|
||||
buffer_appendf(output, sizeof(output), &pos, "\n");
|
||||
append_inbox_output(client, output, sizeof(output), &pos);
|
||||
} else {
|
||||
output_kind = TNT_COMMAND_OUTPUT_INBOX;
|
||||
append_inbox_output(client, output, sizeof(output), &pos);
|
||||
}
|
||||
|
||||
} else if (strcmp(cmd, "nick") == 0 || strcmp(cmd, "name") == 0 ||
|
||||
strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) {
|
||||
char *new_name = cmd + 4;
|
||||
} else if (command_id == TNT_COMMAND_NICK) {
|
||||
const char *new_name = arg;
|
||||
while (*new_name == ' ') new_name++;
|
||||
|
||||
if (new_name[0] == '\0') {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->help_lang, I18N_NICK_USAGE));
|
||||
append_command_usage(output, sizeof(output), &pos,
|
||||
TNT_COMMAND_NICK, client->ui_lang);
|
||||
} else if (!is_valid_username(new_name)) {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->help_lang, I18N_NICK_INVALID));
|
||||
i18n_text(client->ui_lang, I18N_NICK_INVALID));
|
||||
} else {
|
||||
char validated_name[MAX_USERNAME_LEN];
|
||||
snprintf(validated_name, sizeof(validated_name), "%s", new_name);
|
||||
|
|
@ -351,133 +437,137 @@ void commands_dispatch(client_t *client) {
|
|||
|
||||
if (taken) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_NICK_TAKEN_FORMAT),
|
||||
validated_name);
|
||||
} else if (strcmp(validated_name, old_name) == 0) {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_NICK_UNCHANGED));
|
||||
} else {
|
||||
message_t nick_msg;
|
||||
system_message_make_nick(&nick_msg, old_name,
|
||||
client->username, client->help_lang);
|
||||
client->username, client->ui_lang);
|
||||
room_broadcast(g_room, &nick_msg);
|
||||
message_save(&nick_msg);
|
||||
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_NICK_CHANGED_FORMAT),
|
||||
old_name, client->username);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (strncmp(cmd, "last", 4) == 0 && (cmd[4] == ' ' || cmd[4] == '\0')) {
|
||||
char *arg = cmd + 4;
|
||||
} else if (command_id == TNT_COMMAND_LAST) {
|
||||
while (*arg == ' ') arg++;
|
||||
int n = 10;
|
||||
if (*arg != '\0') {
|
||||
char *endp;
|
||||
long val = strtol(arg, &endp, 10);
|
||||
if (*endp != '\0' || val < 1 || val > 50) {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->help_lang, I18N_LAST_USAGE));
|
||||
append_command_usage(output, sizeof(output), &pos,
|
||||
TNT_COMMAND_LAST, client->ui_lang);
|
||||
goto cmd_done;
|
||||
}
|
||||
n = (int)val;
|
||||
}
|
||||
|
||||
message_t *last_msgs = NULL;
|
||||
int last_count = message_load(&last_msgs, n);
|
||||
int load_count = message_load(&last_msgs,
|
||||
client->mute_joins ? MAX_MESSAGES : n);
|
||||
int visible_count = 0;
|
||||
for (int i = 0; i < load_count; i++) {
|
||||
if (message_visible_for_client(client, &last_msgs[i])) {
|
||||
last_msgs[visible_count++] = last_msgs[i];
|
||||
}
|
||||
}
|
||||
int start = visible_count > n ? visible_count - n : 0;
|
||||
int last_count = visible_count - start;
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang, I18N_LAST_HEADER_FORMAT),
|
||||
i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT),
|
||||
last_count);
|
||||
if (last_count == 0) {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->ui_lang, I18N_LAST_EMPTY));
|
||||
}
|
||||
for (int i = 0; i < last_count; i++) {
|
||||
message_t *msg = &last_msgs[start + i];
|
||||
char ts[20];
|
||||
struct tm tmi;
|
||||
localtime_r(&last_msgs[i].timestamp, &tmi);
|
||||
localtime_r(&msg->timestamp, &tmi);
|
||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"[%s] %s: %s\n", ts, last_msgs[i].username, last_msgs[i].content);
|
||||
"[%s] %s: %s\n", ts, msg->username, msg->content);
|
||||
}
|
||||
free(last_msgs);
|
||||
|
||||
} else if (strcmp(cmd, "search") == 0 || strncmp(cmd, "search ", 7) == 0) {
|
||||
char *query = cmd + 6;
|
||||
} else if (command_id == TNT_COMMAND_SEARCH) {
|
||||
const char *query = arg;
|
||||
while (*query == ' ') query++;
|
||||
if (*query == '\0') {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->help_lang, I18N_SEARCH_USAGE));
|
||||
append_command_usage(output, sizeof(output), &pos,
|
||||
TNT_COMMAND_SEARCH, client->ui_lang);
|
||||
} else {
|
||||
message_t *found = NULL;
|
||||
int found_count = message_search(query, &found, 15);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
I18N_SEARCH_HEADER_FORMAT),
|
||||
query, found_count);
|
||||
int search_limit = client->mute_joins ? MAX_MESSAGES : 15;
|
||||
int found_count = message_search(query, &found, search_limit);
|
||||
int visible_count = 0;
|
||||
for (int i = 0; i < found_count; i++) {
|
||||
if (message_visible_for_client(client, &found[i])) {
|
||||
found[visible_count++] = found[i];
|
||||
}
|
||||
}
|
||||
int start = visible_count > 15 ? visible_count - 15 : 0;
|
||||
int display_count = visible_count - start;
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_SEARCH_HEADER_FORMAT),
|
||||
query, display_count);
|
||||
if (display_count == 0) {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_SEARCH_EMPTY));
|
||||
}
|
||||
for (int i = 0; i < display_count; i++) {
|
||||
message_t *msg = &found[start + i];
|
||||
char ts[20];
|
||||
struct tm tmi;
|
||||
localtime_r(&found[i].timestamp, &tmi);
|
||||
localtime_r(&msg->timestamp, &tmi);
|
||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"[%s] ", ts);
|
||||
append_highlighted(output, sizeof(output), &pos,
|
||||
found[i].username, query);
|
||||
msg->username, query);
|
||||
buffer_appendf(output, sizeof(output), &pos, ": ");
|
||||
append_highlighted(output, sizeof(output), &pos,
|
||||
found[i].content, query);
|
||||
msg->content, query);
|
||||
buffer_appendf(output, sizeof(output), &pos, "\n");
|
||||
}
|
||||
free(found);
|
||||
}
|
||||
|
||||
} else if (strcmp(cmd, "mute-joins") == 0 || strcmp(cmd, "mute") == 0) {
|
||||
} else if (command_id == TNT_COMMAND_MUTE_JOINS) {
|
||||
client->mute_joins = !client->mute_joins;
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang, I18N_MUTE_JOINS_FORMAT),
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang, I18N_MUTE_JOINS_FORMAT),
|
||||
i18n_text(client->ui_lang,
|
||||
client->mute_joins ?
|
||||
I18N_MUTE_JOINS_MUTED :
|
||||
I18N_MUTE_JOINS_UNMUTED));
|
||||
|
||||
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
|
||||
strcmp(cmd, "exit") == 0) {
|
||||
} else if (command_id == TNT_COMMAND_QUIT) {
|
||||
client->connected = false;
|
||||
return;
|
||||
|
||||
} else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) {
|
||||
} else if (command_id == TNT_COMMAND_CLEAR) {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->help_lang, I18N_CLEAR_DONE));
|
||||
|
||||
} else if (cmd[0] == '\0') {
|
||||
/* Empty command */
|
||||
client->mode = MODE_NORMAL;
|
||||
client->command_input[0] = '\0';
|
||||
tui_render_screen(client);
|
||||
return;
|
||||
|
||||
} else {
|
||||
const char *suggestion = suggest_command(cmd);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
I18N_UNKNOWN_COMMAND_FORMAT),
|
||||
cmd);
|
||||
if (suggestion) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->help_lang,
|
||||
I18N_DID_YOU_MEAN_FORMAT),
|
||||
suggestion);
|
||||
}
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->help_lang, I18N_UNKNOWN_GUIDANCE));
|
||||
i18n_text(client->ui_lang, I18N_CLEAR_DONE));
|
||||
}
|
||||
|
||||
cmd_done:
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->help_lang, I18N_CONTINUE_PROMPT));
|
||||
|
||||
snprintf(client->command_output, sizeof(client->command_output), "%s", output);
|
||||
client->command_output_scroll = 0;
|
||||
client->command_output_kind = output_kind;
|
||||
client->command_input[0] = '\0';
|
||||
tui_render_command_output(client);
|
||||
}
|
||||
|
|
|
|||
80
src/config_defaults.c
Normal file
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;
|
||||
}
|
||||
288
src/exec.c
288
src/exec.c
|
|
@ -2,11 +2,13 @@
|
|||
#include "chat_room.h"
|
||||
#include "client.h"
|
||||
#include "common.h"
|
||||
#include "exec_catalog.h"
|
||||
#include "i18n.h"
|
||||
#include "input.h"
|
||||
#include "json_text.h"
|
||||
#include "message.h"
|
||||
#include "module_runtime.h"
|
||||
#include "ratelimit.h"
|
||||
#include "support.h"
|
||||
#include "utf8.h"
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
|
|
@ -56,48 +58,6 @@ static void trim_ascii_whitespace(char *text) {
|
|||
}
|
||||
}
|
||||
|
||||
static void json_append_string(char *buffer, size_t buf_size, size_t *pos,
|
||||
const char *text) {
|
||||
const unsigned char *p = (const unsigned char *)(text ? text : "");
|
||||
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
|
||||
|
||||
while (*p && *pos < buf_size - 1) {
|
||||
char escaped[7];
|
||||
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2);
|
||||
break;
|
||||
case '"':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2);
|
||||
break;
|
||||
case '\n':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\n", 2);
|
||||
break;
|
||||
case '\r':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\r", 2);
|
||||
break;
|
||||
case '\t':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\t", 2);
|
||||
break;
|
||||
default:
|
||||
if (*p < 0x20) {
|
||||
snprintf(escaped, sizeof(escaped), "\\u%04x", *p);
|
||||
buffer_append_bytes(buffer, buf_size, pos,
|
||||
escaped, strlen(escaped));
|
||||
} else {
|
||||
buffer_append_bytes(buffer, buf_size, pos,
|
||||
(const char *)p, 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
|
||||
}
|
||||
|
||||
static void resolve_exec_username(const client_t *client, char *buffer,
|
||||
size_t buf_size) {
|
||||
if (!buffer || buf_size == 0) {
|
||||
|
|
@ -117,22 +77,31 @@ static void resolve_exec_username(const client_t *client, char *buffer,
|
|||
}
|
||||
|
||||
static int exec_command_help(client_t *client) {
|
||||
const char *help_text = i18n_text(client->help_lang, I18N_EXEC_HELP);
|
||||
|
||||
return client_send(client, help_text, strlen(help_text)) == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
static int exec_command_support(client_t *client) {
|
||||
char output[2048] = {0};
|
||||
char help_text[1024];
|
||||
size_t pos = 0;
|
||||
|
||||
support_append_exec_panel(output, sizeof(output), &pos, client->help_lang);
|
||||
return client_send(client, output, pos) == 0 ? 0 : 1;
|
||||
help_text[0] = '\0';
|
||||
exec_catalog_append_help(help_text, sizeof(help_text), &pos,
|
||||
client->ui_lang);
|
||||
return client_send(client, help_text, pos) == 0 ? TNT_EXIT_OK
|
||||
: TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
|
||||
char usage[128];
|
||||
size_t pos = 0;
|
||||
|
||||
usage[0] = '\0';
|
||||
exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
|
||||
client->ui_lang);
|
||||
client_printf(client, "%s", usage);
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
||||
static int exec_command_health(client_t *client) {
|
||||
static const char ok[] = "ok\n";
|
||||
return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1;
|
||||
return client_send(client, ok, sizeof(ok) - 1) == 0 ? TNT_EXIT_OK
|
||||
: TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
static int exec_command_users(client_t *client, bool json) {
|
||||
|
|
@ -150,7 +119,7 @@ static int exec_command_users(client_t *client, bool json) {
|
|||
if (!usernames) {
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
client_printf(client, "users: out of memory\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
|
|
@ -170,7 +139,7 @@ static int exec_command_users(client_t *client, bool json) {
|
|||
if (!output) {
|
||||
free(usernames);
|
||||
client_printf(client, "users: out of memory\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
if (json) {
|
||||
|
|
@ -179,7 +148,7 @@ static int exec_command_users(client_t *client, bool json) {
|
|||
if (i > 0) {
|
||||
buffer_append_bytes(output, output_size, &pos, ",", 1);
|
||||
}
|
||||
json_append_string(output, output_size, &pos, usernames[i]);
|
||||
tnt_json_append_string(output, output_size, &pos, usernames[i]);
|
||||
}
|
||||
buffer_append_bytes(output, output_size, &pos, "]\n", 2);
|
||||
} else {
|
||||
|
|
@ -188,7 +157,7 @@ static int exec_command_users(client_t *client, bool json) {
|
|||
}
|
||||
}
|
||||
|
||||
rc = client_send(client, output, pos) == 0 ? 0 : 1;
|
||||
rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
|
||||
free(output);
|
||||
free(usernames);
|
||||
return rc;
|
||||
|
|
@ -236,10 +205,11 @@ static int exec_command_stats(client_t *client, bool json) {
|
|||
|
||||
if (len < 0 || len >= (int)sizeof(buffer)) {
|
||||
client_printf(client, "stats: output overflow\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
return client_send(client, buffer, (size_t)len) == 0 ? 0 : 1;
|
||||
return client_send(client, buffer, (size_t)len) == 0 ? TNT_EXIT_OK
|
||||
: TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
static int parse_tail_count(const char *args, int *count) {
|
||||
|
|
@ -281,6 +251,45 @@ static int parse_tail_count(const char *args, int *count) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
static int parse_dump_count(const char *args, int *count) {
|
||||
char *end = NULL;
|
||||
long value;
|
||||
|
||||
if (!count) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
*count = 0;
|
||||
if (!args || args[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strncmp(args, "-n", 2) == 0) {
|
||||
args += 2;
|
||||
while (*args && isspace((unsigned char)*args)) {
|
||||
args++;
|
||||
}
|
||||
}
|
||||
|
||||
value = strtol(args, &end, 10);
|
||||
if (end == args) {
|
||||
return -1;
|
||||
}
|
||||
while (*end) {
|
||||
if (!isspace((unsigned char)*end)) {
|
||||
return -1;
|
||||
}
|
||||
end++;
|
||||
}
|
||||
|
||||
if (value < 1 || value > 10000) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
*count = (int)value;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int exec_command_tail(client_t *client, const char *args) {
|
||||
int requested = 20;
|
||||
int total_messages;
|
||||
|
|
@ -293,9 +302,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
|||
int rc;
|
||||
|
||||
if (parse_tail_count(args, &requested) < 0) {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->help_lang, I18N_EXEC_TAIL_USAGE));
|
||||
return 64;
|
||||
return exec_command_usage(client, TNT_EXEC_COMMAND_TAIL);
|
||||
}
|
||||
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
|
|
@ -311,7 +318,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
|||
if (!snapshot) {
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
client_printf(client, "tail: out of memory\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t));
|
||||
}
|
||||
|
|
@ -323,7 +330,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
|||
if (!output) {
|
||||
free(snapshot);
|
||||
client_printf(client, "tail: out of memory\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
|
|
@ -333,12 +340,33 @@ static int exec_command_tail(client_t *client, const char *args) {
|
|||
timestamp, snapshot[i].username, snapshot[i].content);
|
||||
}
|
||||
|
||||
rc = client_send(client, output, pos) == 0 ? 0 : 1;
|
||||
rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
|
||||
free(output);
|
||||
free(snapshot);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int exec_command_dump(client_t *client, const char *args) {
|
||||
int requested = 0;
|
||||
char *output = NULL;
|
||||
size_t output_len = 0;
|
||||
int rc;
|
||||
|
||||
if (parse_dump_count(args, &requested) < 0) {
|
||||
return exec_command_usage(client, TNT_EXEC_COMMAND_DUMP);
|
||||
}
|
||||
|
||||
if (message_dump_text(&output, &output_len, requested) < 0) {
|
||||
client_printf(client, "dump: failed to read message log\n");
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
rc = client_send(client, output, output_len) == 0 ? TNT_EXIT_OK
|
||||
: TNT_EXIT_ERROR;
|
||||
free(output);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int exec_command_post(client_t *client, const char *args) {
|
||||
char content[MAX_MESSAGE_LEN];
|
||||
char username[MAX_USERNAME_LEN];
|
||||
|
|
@ -347,9 +375,13 @@ static int exec_command_post(client_t *client, const char *args) {
|
|||
};
|
||||
|
||||
if (!args || args[0] == '\0') {
|
||||
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
|
||||
}
|
||||
|
||||
if (strlen(args) >= sizeof(content)) {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->help_lang, I18N_EXEC_POST_USAGE));
|
||||
return 64;
|
||||
i18n_text(client->ui_lang, I18N_EXEC_POST_TOO_LONG));
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
||||
strncpy(content, args, sizeof(content) - 1);
|
||||
|
|
@ -358,15 +390,15 @@ static int exec_command_post(client_t *client, const char *args) {
|
|||
|
||||
if (content[0] == '\0') {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->help_lang, I18N_EXEC_POST_EMPTY));
|
||||
return 64;
|
||||
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
||||
if (!utf8_is_valid_string(content)) {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_EXEC_POST_INVALID_UTF8));
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
resolve_exec_username(client, username, sizeof(username));
|
||||
|
|
@ -385,87 +417,81 @@ static int exec_command_post(client_t *client, const char *args) {
|
|||
msg.content[sizeof(msg.content) - 1] = '\0';
|
||||
}
|
||||
|
||||
room_broadcast(g_room, &msg);
|
||||
if (client_send(client, "posted\n", 7) != 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
notify_mentions(msg.content, client);
|
||||
if (message_save(&msg) < 0) {
|
||||
fprintf(stderr, "post: failed to persist message\n");
|
||||
return 1;
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_EXEC_POST_PERSIST_FAILED));
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
return 0;
|
||||
room_broadcast(g_room, &msg);
|
||||
notify_mentions(msg.content, client);
|
||||
tnt_module_runtime_publish_message_created(&msg);
|
||||
|
||||
if (client_send(client, "posted\n", 7) != 0) {
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
return TNT_EXIT_OK;
|
||||
}
|
||||
|
||||
int exec_dispatch(client_t *client) {
|
||||
char command_copy[MAX_EXEC_COMMAND_LEN];
|
||||
char *cmd;
|
||||
char *args;
|
||||
tnt_exec_command_id_t command_id;
|
||||
const char *args = NULL;
|
||||
|
||||
if (client->exec_command_too_long) {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_EXEC_COMMAND_TOO_LONG));
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
||||
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
|
||||
command_copy[sizeof(command_copy) - 1] = '\0';
|
||||
trim_ascii_whitespace(command_copy);
|
||||
|
||||
cmd = command_copy;
|
||||
if (*cmd == '\0') {
|
||||
if (command_copy[0] == '\0') {
|
||||
return exec_command_help(client);
|
||||
}
|
||||
|
||||
args = cmd;
|
||||
while (*args && !isspace((unsigned char)*args)) {
|
||||
args++;
|
||||
}
|
||||
if (*args) {
|
||||
*args++ = '\0';
|
||||
while (*args && isspace((unsigned char)*args)) {
|
||||
args++;
|
||||
if (exec_catalog_match(command_copy, &command_id, &args)) {
|
||||
if (!exec_catalog_args_valid(command_id, args)) {
|
||||
return exec_command_usage(client, command_id);
|
||||
}
|
||||
|
||||
switch (command_id) {
|
||||
case TNT_EXEC_COMMAND_HELP:
|
||||
return exec_command_help(client);
|
||||
case TNT_EXEC_COMMAND_HEALTH:
|
||||
return exec_command_health(client);
|
||||
case TNT_EXEC_COMMAND_USERS:
|
||||
return exec_command_users(client, args != NULL);
|
||||
case TNT_EXEC_COMMAND_STATS:
|
||||
return exec_command_stats(client, args != NULL);
|
||||
case TNT_EXEC_COMMAND_TAIL:
|
||||
return exec_command_tail(client, args);
|
||||
case TNT_EXEC_COMMAND_DUMP:
|
||||
return exec_command_dump(client, args);
|
||||
case TNT_EXEC_COMMAND_POST:
|
||||
return exec_command_post(client, args);
|
||||
case TNT_EXEC_COMMAND_EXIT:
|
||||
return TNT_EXIT_OK;
|
||||
case TNT_EXEC_COMMAND_COUNT:
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
args = NULL;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0) {
|
||||
return exec_command_help(client);
|
||||
}
|
||||
if (strcmp(cmd, "support") == 0 || strcmp(cmd, "guide") == 0) {
|
||||
return exec_command_support(client);
|
||||
}
|
||||
if (strcmp(cmd, "health") == 0) {
|
||||
return exec_command_health(client);
|
||||
}
|
||||
if (strcmp(cmd, "users") == 0) {
|
||||
if (args && strcmp(args, "--json") != 0) {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->help_lang,
|
||||
I18N_EXEC_USERS_USAGE));
|
||||
return 64;
|
||||
for (char *p = command_copy; *p; p++) {
|
||||
if (isspace((unsigned char)*p)) {
|
||||
*p = '\0';
|
||||
break;
|
||||
}
|
||||
return exec_command_users(client, args != NULL);
|
||||
}
|
||||
if (strcmp(cmd, "stats") == 0) {
|
||||
if (args && strcmp(args, "--json") != 0) {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->help_lang,
|
||||
I18N_EXEC_STATS_USAGE));
|
||||
return 64;
|
||||
}
|
||||
return exec_command_stats(client, args != NULL);
|
||||
}
|
||||
if (strcmp(cmd, "tail") == 0) {
|
||||
return exec_command_tail(client, args);
|
||||
}
|
||||
if (strcmp(cmd, "post") == 0) {
|
||||
return exec_command_post(client, args);
|
||||
}
|
||||
if (strcmp(cmd, "exit") == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
client_printf(client,
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||
cmd);
|
||||
return 64;
|
||||
command_copy);
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
|
|
|||
189
src/exec_catalog.c
Normal file
189
src/exec_catalog.c
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
#include "exec_catalog.h"
|
||||
|
||||
#include "i18n.h"
|
||||
|
||||
typedef struct {
|
||||
tnt_exec_command_id_t id;
|
||||
const char *name;
|
||||
const char *alias;
|
||||
const char *usage;
|
||||
const char *usage_syntax;
|
||||
i18n_string_t summary;
|
||||
bool no_args;
|
||||
bool optional_json;
|
||||
bool requires_args;
|
||||
} exec_catalog_entry_t;
|
||||
|
||||
static const exec_catalog_entry_t entries[] = {
|
||||
{TNT_EXEC_COMMAND_HELP, "help", "--help",
|
||||
"help", "help", I18N_STRING("Show this help", "显示此帮助"),
|
||||
true, false, false},
|
||||
{TNT_EXEC_COMMAND_HEALTH, "health", NULL,
|
||||
"health", "health",
|
||||
I18N_STRING("Print service health", "输出服务健康状态"),
|
||||
true, false, false},
|
||||
{TNT_EXEC_COMMAND_USERS, "users", NULL,
|
||||
"users [--json]", "users [--json]",
|
||||
I18N_STRING("List online users", "列出在线用户"),
|
||||
false, true, false},
|
||||
{TNT_EXEC_COMMAND_STATS, "stats", NULL,
|
||||
"stats [--json]", "stats [--json]",
|
||||
I18N_STRING("Print room statistics", "输出房间统计"),
|
||||
false, true, false},
|
||||
{TNT_EXEC_COMMAND_TAIL, "tail", NULL,
|
||||
"tail [N]", "tail [N] | tail -n N",
|
||||
I18N_STRING("Print recent messages", "输出最近消息"),
|
||||
false, false, false},
|
||||
{TNT_EXEC_COMMAND_TAIL, "tail", NULL,
|
||||
"tail -n N", "tail [N] | tail -n N",
|
||||
I18N_STRING("Print recent messages", "输出最近消息"),
|
||||
false, false, false},
|
||||
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
|
||||
"dump [N]", "dump [N] | dump -n N",
|
||||
I18N_STRING("Export persisted messages", "导出持久化消息"),
|
||||
false, false, false},
|
||||
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
|
||||
"dump -n N", "dump [N] | dump -n N",
|
||||
I18N_STRING("Export persisted messages", "导出持久化消息"),
|
||||
false, false, false},
|
||||
{TNT_EXEC_COMMAND_POST, "post", NULL,
|
||||
"post MESSAGE", "post MESSAGE",
|
||||
I18N_STRING("Post a message non-interactively", "非交互发送消息"),
|
||||
false, false, true},
|
||||
{TNT_EXEC_COMMAND_POST, "post", NULL,
|
||||
"post \"/me act\"", "post MESSAGE",
|
||||
I18N_STRING("Post an action message", "发送动作消息"),
|
||||
false, false, true},
|
||||
{TNT_EXEC_COMMAND_EXIT, "exit", NULL,
|
||||
"exit", "exit", I18N_STRING("Exit successfully", "成功退出"),
|
||||
true, false, false}
|
||||
};
|
||||
|
||||
static const exec_catalog_entry_t *entry_for_id(tnt_exec_command_id_t id) {
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
if (entries[i].id == id) {
|
||||
return &entries[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static const char *skip_spaces(const char *value) {
|
||||
while (value && *value && (*value == ' ' || *value == '\t')) {
|
||||
value++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool name_matches(const char *line, const char *name,
|
||||
const char **args) {
|
||||
size_t len;
|
||||
|
||||
if (!line || !name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
len = strlen(name);
|
||||
if (strncmp(line, name, len) != 0) {
|
||||
return false;
|
||||
}
|
||||
if (line[len] != '\0' && line[len] != ' ' && line[len] != '\t') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (args) {
|
||||
const char *candidate_args = skip_spaces(line + len);
|
||||
*args = candidate_args && candidate_args[0] != '\0'
|
||||
? candidate_args
|
||||
: NULL;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
|
||||
const char **args) {
|
||||
line = skip_spaces(line);
|
||||
if (!line || line[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
if (!name_matches(line, entries[i].name, args) &&
|
||||
!name_matches(line, entries[i].alias, args)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
*id = entries[i].id;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool exec_catalog_args_valid(tnt_exec_command_id_t id, const char *args) {
|
||||
const exec_catalog_entry_t *entry = entry_for_id(id);
|
||||
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (entry->no_args) {
|
||||
return !args || args[0] == '\0';
|
||||
}
|
||||
if (entry->optional_json) {
|
||||
return !args || strcmp(args, "--json") == 0;
|
||||
}
|
||||
if (entry->requires_args) {
|
||||
return args && args[0] != '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang) {
|
||||
static const i18n_string_t header =
|
||||
I18N_STRING("TNT exec interface\nCommands:\n",
|
||||
"TNT exec 接口\n命令:\n");
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", i18n_string(header, lang));
|
||||
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const char *summary = i18n_string(entries[i].summary, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, " %-15s %s\n",
|
||||
entries[i].usage, summary);
|
||||
}
|
||||
}
|
||||
|
||||
void exec_catalog_append_command_list(char *buffer, size_t buf_size,
|
||||
size_t *pos) {
|
||||
bool seen[TNT_EXEC_COMMAND_COUNT] = {0};
|
||||
size_t count = 0;
|
||||
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
tnt_exec_command_id_t id = entries[i].id;
|
||||
|
||||
if (id < 0 || id >= TNT_EXEC_COMMAND_COUNT || seen[id]) {
|
||||
continue;
|
||||
}
|
||||
if (count > 0) {
|
||||
buffer_appendf(buffer, buf_size, pos, ", ");
|
||||
}
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", entries[i].name);
|
||||
seen[id] = true;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||
tnt_exec_command_id_t id, ui_lang_t lang) {
|
||||
const exec_catalog_entry_t *entry = entry_for_id(id);
|
||||
static const i18n_string_t usage_format =
|
||||
I18N_STRING("%s: usage: %s\n", "%s: 用法: %s\n");
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
buffer_appendf(buffer, buf_size, pos, i18n_string(usage_format, lang),
|
||||
entry->name, entry->usage_syntax);
|
||||
}
|
||||
295
src/help_text.c
295
src/help_text.c
|
|
@ -1,167 +1,136 @@
|
|||
#include "help_text.h"
|
||||
|
||||
void help_text_append_commands(char *output, size_t buf_size, size_t *pos,
|
||||
help_lang_t lang) {
|
||||
if (lang == LANG_ZH) {
|
||||
buffer_appendf(output, buf_size, pos,
|
||||
"========================================\n"
|
||||
" 可用命令\n"
|
||||
"========================================\n"
|
||||
"list, users, who - 显示在线用户\n"
|
||||
"nick/name <name> - 修改昵称\n"
|
||||
"msg/w <user> <text> - 私聊用户\n"
|
||||
"inbox - 查看私聊历史\n"
|
||||
"last [N] - 查看最近 N 条消息\n"
|
||||
"search <keyword> - 搜索消息历史\n"
|
||||
"mute-joins - 切换加入/离开提示\n"
|
||||
"support - 显示快速支持指南\n"
|
||||
"lang [en|zh] - 查看或切换界面语言\n"
|
||||
"help, commands - 显示此帮助\n"
|
||||
"clear, cls - 清空命令输出\n"
|
||||
"q, quit, exit - 断开连接\n"
|
||||
"上/下方向键 - 命令历史\n"
|
||||
"========================================\n"
|
||||
"INSERT 模式:\n"
|
||||
" /me <action> - 发送动作消息\n"
|
||||
" @username - 提及用户并响铃提示\n"
|
||||
"========================================\n");
|
||||
return;
|
||||
}
|
||||
#include "command_catalog.h"
|
||||
#include "i18n.h"
|
||||
|
||||
buffer_appendf(output, buf_size, pos,
|
||||
"========================================\n"
|
||||
" Available Commands\n"
|
||||
"========================================\n"
|
||||
"list, users, who - Show online users\n"
|
||||
"nick/name <name> - Change nickname\n"
|
||||
"msg/w <user> <text> - Whisper to user (private)\n"
|
||||
"inbox - Show whisper history\n"
|
||||
"last [N] - Show last N messages\n"
|
||||
"search <keyword> - Search message history\n"
|
||||
"mute-joins - Toggle join/leave notices\n"
|
||||
"support - Show quick support guide\n"
|
||||
"lang [en|zh] - Show or switch UI language\n"
|
||||
"help, commands - Show this help\n"
|
||||
"clear, cls - Clear command output\n"
|
||||
"q, quit, exit - Disconnect\n"
|
||||
"Up/Down arrows - Command history\n"
|
||||
"========================================\n"
|
||||
"In INSERT mode:\n"
|
||||
" /me <action> - Send action message\n"
|
||||
" @username - Mention (bell notify)\n"
|
||||
"========================================\n");
|
||||
}
|
||||
|
||||
const char *help_text_full(help_lang_t lang) {
|
||||
if (lang == LANG_EN) {
|
||||
return "TERMINAL CHAT ROOM - HELP\n"
|
||||
"\n"
|
||||
"OPERATING MODES:\n"
|
||||
" INSERT - Type and send messages (default)\n"
|
||||
" NORMAL - Browse message history\n"
|
||||
" COMMAND - Execute commands\n"
|
||||
"\n"
|
||||
"INSERT MODE KEYS:\n"
|
||||
" ESC - Enter NORMAL mode\n"
|
||||
" Enter - Send message\n"
|
||||
" Backspace - Delete character\n"
|
||||
" Ctrl+W - Delete last word\n"
|
||||
" Ctrl+U - Delete line\n"
|
||||
" Ctrl+C - Enter NORMAL mode\n"
|
||||
"\n"
|
||||
"NORMAL MODE KEYS:\n"
|
||||
" Opens at latest messages\n"
|
||||
" Follows latest until you scroll up\n"
|
||||
" i - Return to INSERT mode\n"
|
||||
" : - Enter COMMAND mode\n"
|
||||
" j/k - Scroll down/up one line\n"
|
||||
" Ctrl+D/U - Scroll half page down/up\n"
|
||||
" Ctrl+F/B - Scroll full page down/up\n"
|
||||
" PgDn/PgUp - Scroll full page down/up\n"
|
||||
" End/Home - Jump to bottom/top\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
" ? - Show this help\n"
|
||||
" Ctrl+C - Exit chat\n"
|
||||
"\n"
|
||||
"AVAILABLE COMMANDS:\n"
|
||||
" :list, :users - Show online users\n"
|
||||
" :nick <name> - Change nickname\n"
|
||||
" :msg <user> <text> - Whisper to user\n"
|
||||
" :w <user> <text> - Short alias for :msg\n"
|
||||
" :last [N] - Show last N messages (max 50)\n"
|
||||
" :search <keyword> - Search message history\n"
|
||||
" :mute-joins - Toggle join/leave notices\n"
|
||||
" :support - Show quick support guide\n"
|
||||
" :lang <en|zh> - Switch UI language\n"
|
||||
" :help - Show available commands\n"
|
||||
" :clear - Clear command output\n"
|
||||
" :q, :quit, :exit - Disconnect\n"
|
||||
"\n"
|
||||
"SPECIAL MESSAGES:\n"
|
||||
" /me <action> - Send action (e.g. /me waves)\n"
|
||||
" @username - Mention user (bell + highlight)\n"
|
||||
"\n"
|
||||
"HELP SCREEN KEYS:\n"
|
||||
" q, ESC - Close help\n"
|
||||
" j/k - Scroll down/up\n"
|
||||
" Ctrl+D/U - Scroll half page down/up\n"
|
||||
" Ctrl+F/B - Scroll full page down/up\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
" e/z - Switch English/Chinese\n";
|
||||
}
|
||||
|
||||
return "终端聊天室 - 帮助\n"
|
||||
"\n"
|
||||
"操作模式:\n"
|
||||
" INSERT - 输入和发送消息(默认)\n"
|
||||
" NORMAL - 浏览消息历史\n"
|
||||
" COMMAND - 执行命令\n"
|
||||
"\n"
|
||||
"INSERT 模式按键:\n"
|
||||
" ESC - 进入 NORMAL 模式\n"
|
||||
" Enter - 发送消息\n"
|
||||
" Backspace - 删除字符\n"
|
||||
" Ctrl+W - 删除上个单词\n"
|
||||
" Ctrl+U - 删除整行\n"
|
||||
" Ctrl+C - 进入 NORMAL 模式\n"
|
||||
"\n"
|
||||
"NORMAL 模式按键:\n"
|
||||
" 默认停在最新消息\n"
|
||||
" 未向上翻阅时自动跟随最新消息\n"
|
||||
" i - 返回 INSERT 模式\n"
|
||||
" : - 进入 COMMAND 模式\n"
|
||||
" j/k - 向下/上滚动一行\n"
|
||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||
" PgDn/PgUp - 向下/上滚动整页\n"
|
||||
" End/Home - 跳到底部/顶部\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
" ? - 显示此帮助\n"
|
||||
" Ctrl+C - 退出聊天\n"
|
||||
"\n"
|
||||
"可用命令:\n"
|
||||
" :list, :users - 显示在线用户\n"
|
||||
" :nick <名字> - 更改昵称\n"
|
||||
" :msg <用户> <文本> - 私聊\n"
|
||||
" :w <用户> <文本> - :msg 的简写\n"
|
||||
" :last [N] - 显示最后 N 条消息(最多50)\n"
|
||||
" :search <关键词> - 搜索消息历史\n"
|
||||
" :mute-joins - 切换加入/离开提示\n"
|
||||
" :support - 显示快速支持指南\n"
|
||||
" :lang <en|zh> - 切换界面语言\n"
|
||||
" :help - 显示可用命令\n"
|
||||
" :clear - 清空命令输出\n"
|
||||
" :q, :quit, :exit - 断开连接\n"
|
||||
"\n"
|
||||
"特殊消息:\n"
|
||||
" /me <动作> - 发送动作 (如 /me 挥手)\n"
|
||||
" @用户名 - 提及用户 (响铃+高亮)\n"
|
||||
"\n"
|
||||
"帮助界面按键:\n"
|
||||
" q, ESC - 关闭帮助\n"
|
||||
" j/k - 向下/上滚动\n"
|
||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
" e/z - 切换英文/中文\n";
|
||||
void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang) {
|
||||
static const i18n_string_t before_commands = I18N_STRING(
|
||||
"TNT KEY REFERENCE\n"
|
||||
"\n"
|
||||
"OPERATING MODES:\n"
|
||||
" INSERT - Type and send messages (default)\n"
|
||||
" NORMAL - Browse message history\n"
|
||||
" COMMAND - Execute commands\n"
|
||||
"\n"
|
||||
"INSERT MODE KEYS:\n"
|
||||
" ESC - Enter NORMAL mode\n"
|
||||
" Enter - Send message\n"
|
||||
" Backspace - Delete character\n"
|
||||
" Ctrl+W - Delete last word\n"
|
||||
" Ctrl+U - Delete line\n"
|
||||
" Up/Down - Recall sent messages\n"
|
||||
" Tab - Complete @mention\n"
|
||||
" Ctrl+C - Enter NORMAL mode\n"
|
||||
"\n"
|
||||
"NORMAL MODE KEYS:\n"
|
||||
" Opens at latest messages\n"
|
||||
" Follows latest until you scroll up\n"
|
||||
" i/a/o - Return to INSERT mode\n"
|
||||
" : - Enter COMMAND mode\n"
|
||||
" / - Search message history\n"
|
||||
" j/k - Scroll down/up one line\n"
|
||||
" Ctrl+D/U - Scroll half page down/up\n"
|
||||
" Ctrl+F/B - Scroll full page down/up\n"
|
||||
" PgDn/PgUp - Scroll full page down/up\n"
|
||||
" End/Home - Jump to bottom/top\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
" ? - Show full key reference\n"
|
||||
" Ctrl+C - Exit chat\n"
|
||||
"\n"
|
||||
"AVAILABLE COMMANDS:\n",
|
||||
"TNT 按键参考\n"
|
||||
"\n"
|
||||
"操作模式:\n"
|
||||
" INSERT - 输入和发送消息(默认)\n"
|
||||
" NORMAL - 浏览消息历史\n"
|
||||
" COMMAND - 执行命令\n"
|
||||
"\n"
|
||||
"INSERT 模式按键:\n"
|
||||
" ESC - 进入 NORMAL 模式\n"
|
||||
" Enter - 发送消息\n"
|
||||
" Backspace - 删除字符\n"
|
||||
" Ctrl+W - 删除上个单词\n"
|
||||
" Ctrl+U - 删除整行\n"
|
||||
" Up/Down - 调出已发送消息\n"
|
||||
" Tab - 补全 @mention\n"
|
||||
" Ctrl+C - 进入 NORMAL 模式\n"
|
||||
"\n"
|
||||
"NORMAL 模式按键:\n"
|
||||
" 默认停在最新消息\n"
|
||||
" 未向上翻阅时自动跟随最新消息\n"
|
||||
" i/a/o - 返回 INSERT 模式\n"
|
||||
" : - 进入 COMMAND 模式\n"
|
||||
" / - 搜索消息历史\n"
|
||||
" j/k - 向下/上滚动一行\n"
|
||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||
" PgDn/PgUp - 向下/上滚动整页\n"
|
||||
" End/Home - 跳到底部/顶部\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
" ? - 显示完整按键参考\n"
|
||||
" Ctrl+C - 退出聊天\n"
|
||||
"\n"
|
||||
"可用命令:\n"
|
||||
);
|
||||
static const i18n_string_t after_commands = I18N_STRING(
|
||||
"\n"
|
||||
"COMMAND OUTPUT KEYS:\n"
|
||||
" q, ESC - Close output\n"
|
||||
" j/k, arrows - Scroll down/up\n"
|
||||
" Ctrl+D/U - Scroll half page down/up\n"
|
||||
" Ctrl+F/B - Scroll full page down/up\n"
|
||||
" Space/b - Scroll full page down/up\n"
|
||||
" PgDn/PgUp - Scroll full page down/up\n"
|
||||
" End/Home - Jump to bottom/top\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
" r - Refresh live output (:inbox)\n"
|
||||
"\n"
|
||||
"SPECIAL MESSAGES:\n"
|
||||
" /me <action> - Send action (e.g. /me waves)\n"
|
||||
" @username - Mention user (bell + highlight)\n"
|
||||
"\n"
|
||||
"HELP SCREEN KEYS:\n"
|
||||
" q, ESC - Close help\n"
|
||||
" j/k, arrows - Scroll down/up\n"
|
||||
" Ctrl+D/U - Scroll half page down/up\n"
|
||||
" Ctrl+F/B - Scroll full page down/up\n"
|
||||
" Space/b - Scroll full page down/up\n"
|
||||
" PgDn/PgUp - Scroll full page down/up\n"
|
||||
" End/Home - Jump to bottom/top\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
" l - Cycle UI language\n",
|
||||
"\n"
|
||||
"命令输出按键:\n"
|
||||
" q, ESC - 关闭输出\n"
|
||||
" j/k, arrows - 向下/上滚动\n"
|
||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||
" Space/b - 向下/上滚动整页\n"
|
||||
" PgDn/PgUp - 向下/上滚动整页\n"
|
||||
" End/Home - 跳到底部/顶部\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
" r - 刷新动态输出 (:inbox)\n"
|
||||
"\n"
|
||||
"特殊消息:\n"
|
||||
" /me <action> - 发送动作 (如 /me waves)\n"
|
||||
" @username - 提及用户 (响铃+高亮)\n"
|
||||
"\n"
|
||||
"帮助界面按键:\n"
|
||||
" q, ESC - 关闭帮助\n"
|
||||
" j/k, arrows - 向下/上滚动\n"
|
||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||
" Space/b - 向下/上滚动整页\n"
|
||||
" PgDn/PgUp - 向下/上滚动整页\n"
|
||||
" End/Home - 跳到底部/顶部\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
" l - 切换界面语言\n"
|
||||
);
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, "%s",
|
||||
i18n_string(before_commands, lang));
|
||||
command_catalog_append_full(buffer, buf_size, pos, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, "%s",
|
||||
i18n_string(after_commands, lang));
|
||||
}
|
||||
|
|
|
|||
327
src/i18n.c
327
src/i18n.c
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
#include <ctype.h>
|
||||
|
||||
typedef struct {
|
||||
ui_lang_t lang;
|
||||
const char *code;
|
||||
const char *prefixes[4];
|
||||
} ui_lang_definition_t;
|
||||
|
||||
static const ui_lang_definition_t ui_lang_defs[] = {
|
||||
{UI_LANG_EN, "en", {"en", "c", "posix", NULL}},
|
||||
{UI_LANG_ZH, "zh", {"zh", NULL}}
|
||||
};
|
||||
typedef char ui_lang_defs_must_cover_enum[
|
||||
sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]) == UI_LANG_COUNT ? 1 : -1];
|
||||
|
||||
static const char *skip_space(const char *value) {
|
||||
while (value && *value &&
|
||||
isspace((unsigned char)*value)) {
|
||||
|
|
@ -36,41 +49,37 @@ static bool starts_with_lang(const char *value, const char *prefix) {
|
|||
return is_lang_boundary(value);
|
||||
}
|
||||
|
||||
bool i18n_try_parse_lang(const char *value, help_lang_t *lang) {
|
||||
bool i18n_try_parse_ui_lang(const char *value, ui_lang_t *lang) {
|
||||
if (!value || value[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (starts_with_lang(value, "zh") ||
|
||||
starts_with_lang(value, "cn") ||
|
||||
starts_with_lang(value, "chinese")) {
|
||||
if (lang) *lang = LANG_ZH;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (starts_with_lang(value, "en") ||
|
||||
starts_with_lang(value, "english") ||
|
||||
starts_with_lang(value, "c") ||
|
||||
starts_with_lang(value, "posix")) {
|
||||
if (lang) *lang = LANG_EN;
|
||||
return true;
|
||||
for (size_t i = 0; i < sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]); i++) {
|
||||
for (size_t j = 0; ui_lang_defs[i].prefixes[j]; j++) {
|
||||
if (starts_with_lang(value, ui_lang_defs[i].prefixes[j])) {
|
||||
if (lang) {
|
||||
*lang = ui_lang_defs[i].lang;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
help_lang_t i18n_parse_lang(const char *value, help_lang_t fallback) {
|
||||
help_lang_t lang;
|
||||
if (i18n_try_parse_lang(value, &lang)) {
|
||||
ui_lang_t i18n_parse_ui_lang(const char *value, ui_lang_t fallback) {
|
||||
ui_lang_t lang;
|
||||
if (i18n_try_parse_ui_lang(value, &lang)) {
|
||||
return lang;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
help_lang_t i18n_default_lang(void) {
|
||||
ui_lang_t i18n_default_ui_lang(void) {
|
||||
const char *explicit_lang = getenv("TNT_LANG");
|
||||
if (explicit_lang && explicit_lang[0] != '\0') {
|
||||
return i18n_parse_lang(explicit_lang, LANG_EN);
|
||||
return i18n_parse_ui_lang(explicit_lang, UI_LANG_EN);
|
||||
}
|
||||
|
||||
const char *locale = getenv("LC_ALL");
|
||||
|
|
@ -81,277 +90,27 @@ help_lang_t i18n_default_lang(void) {
|
|||
locale = getenv("LANG");
|
||||
}
|
||||
|
||||
return i18n_parse_lang(locale, LANG_EN);
|
||||
return i18n_parse_ui_lang(locale, UI_LANG_EN);
|
||||
}
|
||||
|
||||
const char *i18n_lang_code(help_lang_t lang) {
|
||||
return lang == LANG_ZH ? "zh" : "en";
|
||||
}
|
||||
ui_lang_t i18n_next_ui_lang(ui_lang_t lang) {
|
||||
size_t count = sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]);
|
||||
|
||||
const char *i18n_text(help_lang_t lang, i18n_text_id_t id) {
|
||||
if (lang == LANG_ZH) {
|
||||
switch (id) {
|
||||
case I18N_USERNAME_PROMPT:
|
||||
return " 请输入用户名 (留空 anonymous): ";
|
||||
case I18N_INVALID_USERNAME:
|
||||
return "用户名无效,已改用 anonymous。\r\n";
|
||||
case I18N_ROOM_FULL:
|
||||
return "房间已满\r\n";
|
||||
case I18N_WELCOME_SUBTITLE:
|
||||
return "匿名聊天室 · SSH";
|
||||
case I18N_WELCOME_TAGLINE:
|
||||
return "键盘友好的终端交流";
|
||||
case I18N_WELCOME_FALLBACK_FORMAT:
|
||||
return "TNT %s - SSH 匿名聊天室\r\n\r\n";
|
||||
case I18N_INSERT_HINT_WIDE:
|
||||
return "Enter 发送 · Esc 浏览 · :support";
|
||||
case I18N_INSERT_HINT_NARROW:
|
||||
return "Enter · Esc · :support";
|
||||
case I18N_NORMAL_LATEST:
|
||||
return "G 最新";
|
||||
case I18N_NORMAL_NEW_MESSAGES:
|
||||
return "新消息";
|
||||
case I18N_HELP_TITLE:
|
||||
return " 帮助 ";
|
||||
case I18N_HELP_STATUS_FORMAT:
|
||||
return "-- 帮助 -- (%d/%d) j/k:滚动 g/G:首尾 e/z:语言 q:关闭";
|
||||
case I18N_COMMAND_OUTPUT_TITLE:
|
||||
return " 命令输出 ";
|
||||
case I18N_MOTD_TITLE:
|
||||
return " 公告 ";
|
||||
case I18N_MOTD_CONTINUE_HINT:
|
||||
return " 按任意键继续 ";
|
||||
case I18N_TITLE_ONLINE_FORMAT:
|
||||
return "在线 %d";
|
||||
case I18N_TITLE_MUTED:
|
||||
return "静音";
|
||||
case I18N_TITLE_HELP_HINT:
|
||||
return "? 帮助";
|
||||
case I18N_IDLE_TIMEOUT_FORMAT:
|
||||
return "\r\n\033[33m已断开: 空闲超时 (%d 分钟)\033[0m\r\n";
|
||||
case I18N_SYSTEM_USERNAME:
|
||||
return "系统";
|
||||
case I18N_SYSTEM_JOIN_FORMAT:
|
||||
return "%s 加入了聊天室";
|
||||
case I18N_SYSTEM_LEAVE_FORMAT:
|
||||
return "%s 离开了聊天室";
|
||||
case I18N_SYSTEM_NICK_FORMAT:
|
||||
return "%s 更名为 %s";
|
||||
case I18N_USERS_TITLE:
|
||||
return "在线用户";
|
||||
case I18N_MSG_USAGE:
|
||||
return "用法: msg <用户名> <消息>\n"
|
||||
" w <用户名> <消息>\n";
|
||||
case I18N_MSG_SENT_FORMAT:
|
||||
return "悄悄话已发送给 %s\n";
|
||||
case I18N_MSG_USER_NOT_FOUND_FORMAT:
|
||||
return "未找到用户 '%s'\n";
|
||||
case I18N_INBOX_TITLE:
|
||||
return "悄悄话";
|
||||
case I18N_INBOX_EMPTY:
|
||||
return "(空)";
|
||||
case I18N_NICK_USAGE:
|
||||
return "用法: nick <新用户名>\n";
|
||||
case I18N_NICK_INVALID:
|
||||
return "用户名无效\n";
|
||||
case I18N_NICK_TAKEN_FORMAT:
|
||||
return "昵称 '%s' 已被使用\n";
|
||||
case I18N_NICK_UNCHANGED:
|
||||
return "昵称未变化\n";
|
||||
case I18N_NICK_CHANGED_FORMAT:
|
||||
return "昵称已修改: %s -> %s\n";
|
||||
case I18N_LAST_USAGE:
|
||||
return "用法: last [N] (N: 1-50,默认 10)\n";
|
||||
case I18N_LAST_HEADER_FORMAT:
|
||||
return "--- 最近 %d 条消息 ---\n";
|
||||
case I18N_SEARCH_USAGE:
|
||||
return "用法: search <关键词>\n";
|
||||
case I18N_SEARCH_HEADER_FORMAT:
|
||||
return "--- 搜索: \"%s\" (%d 条匹配) ---\n";
|
||||
case I18N_MUTE_JOINS_FORMAT:
|
||||
return "加入/离开提示: %s\n";
|
||||
case I18N_MUTE_JOINS_MUTED:
|
||||
return "已静音";
|
||||
case I18N_MUTE_JOINS_UNMUTED:
|
||||
return "已开启";
|
||||
case I18N_CLEAR_DONE:
|
||||
return "命令输出已清空\n";
|
||||
case I18N_LANG_CURRENT_FORMAT:
|
||||
return "当前语言: %s\n"
|
||||
"用法: lang <en|zh>\n";
|
||||
case I18N_LANG_SET_FORMAT:
|
||||
return "语言已切换为: %s\n";
|
||||
case I18N_LANG_UNSUPPORTED_FORMAT:
|
||||
return "不支持的语言: %s\n"
|
||||
"用法: lang <en|zh>\n";
|
||||
case I18N_UNKNOWN_COMMAND_FORMAT:
|
||||
return "未知命令: %s\n";
|
||||
case I18N_DID_YOU_MEAN_FORMAT:
|
||||
return "你是想输入 :%s 吗?\n";
|
||||
case I18N_UNKNOWN_GUIDANCE:
|
||||
return "输入 :support 查看引导,或 :help 查看命令\n";
|
||||
case I18N_EXEC_HELP:
|
||||
return "TNT exec 接口\n"
|
||||
"命令:\n"
|
||||
" help 显示此帮助\n"
|
||||
" health 输出服务健康状态\n"
|
||||
" users [--json] 列出在线用户\n"
|
||||
" stats [--json] 输出房间统计\n"
|
||||
" tail [N] 输出最近消息\n"
|
||||
" tail -n N 输出最近消息\n"
|
||||
" post MESSAGE 非交互发送消息\n"
|
||||
" post \"/me act\" 发送动作消息\n"
|
||||
" support 显示快速支持指南\n"
|
||||
" exit 成功退出\n";
|
||||
case I18N_EXEC_USERS_USAGE:
|
||||
return "users: 用法: users [--json]\n";
|
||||
case I18N_EXEC_STATS_USAGE:
|
||||
return "stats: 用法: stats [--json]\n";
|
||||
case I18N_EXEC_TAIL_USAGE:
|
||||
return "tail: 用法: tail [N] | tail -n N\n";
|
||||
case I18N_EXEC_POST_USAGE:
|
||||
return "post: 用法: post MESSAGE\n";
|
||||
case I18N_EXEC_POST_EMPTY:
|
||||
return "post: 消息不能为空\n";
|
||||
case I18N_EXEC_POST_INVALID_UTF8:
|
||||
return "post: 输入不是有效 UTF-8\n";
|
||||
case I18N_EXEC_UNKNOWN_COMMAND_FORMAT:
|
||||
return "未知命令: %s\n";
|
||||
case I18N_CONTINUE_PROMPT:
|
||||
return "\n按任意键继续...";
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (ui_lang_defs[i].lang == lang) {
|
||||
return ui_lang_defs[(i + 1) % count].lang;
|
||||
}
|
||||
}
|
||||
|
||||
switch (id) {
|
||||
case I18N_USERNAME_PROMPT:
|
||||
return " Enter display name (blank for anonymous): ";
|
||||
case I18N_INVALID_USERNAME:
|
||||
return "Invalid username. Using 'anonymous' instead.\r\n";
|
||||
case I18N_ROOM_FULL:
|
||||
return "Room is full\r\n";
|
||||
case I18N_WELCOME_SUBTITLE:
|
||||
return "anonymous chat · SSH";
|
||||
case I18N_WELCOME_TAGLINE:
|
||||
return "keyboard-first terminal chat";
|
||||
case I18N_WELCOME_FALLBACK_FORMAT:
|
||||
return "TNT %s - anonymous chat over SSH\r\n\r\n";
|
||||
case I18N_INSERT_HINT_WIDE:
|
||||
return "Enter send · Esc browse · :support";
|
||||
case I18N_INSERT_HINT_NARROW:
|
||||
return "Enter · Esc · :support";
|
||||
case I18N_NORMAL_LATEST:
|
||||
return "G latest";
|
||||
case I18N_NORMAL_NEW_MESSAGES:
|
||||
return "new";
|
||||
case I18N_HELP_TITLE:
|
||||
return " HELP ";
|
||||
case I18N_HELP_STATUS_FORMAT:
|
||||
return "-- HELP -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close";
|
||||
case I18N_COMMAND_OUTPUT_TITLE:
|
||||
return " COMMAND OUTPUT ";
|
||||
case I18N_MOTD_TITLE:
|
||||
return " NOTICE ";
|
||||
case I18N_MOTD_CONTINUE_HINT:
|
||||
return " Press any key ";
|
||||
case I18N_TITLE_ONLINE_FORMAT:
|
||||
return "online %d";
|
||||
case I18N_TITLE_MUTED:
|
||||
return "muted";
|
||||
case I18N_TITLE_HELP_HINT:
|
||||
return "? help";
|
||||
case I18N_IDLE_TIMEOUT_FORMAT:
|
||||
return "\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n";
|
||||
case I18N_SYSTEM_USERNAME:
|
||||
return "system";
|
||||
case I18N_SYSTEM_JOIN_FORMAT:
|
||||
return "%s joined the room";
|
||||
case I18N_SYSTEM_LEAVE_FORMAT:
|
||||
return "%s left the room";
|
||||
case I18N_SYSTEM_NICK_FORMAT:
|
||||
return "%s renamed to %s";
|
||||
case I18N_USERS_TITLE:
|
||||
return "Online users";
|
||||
case I18N_MSG_USAGE:
|
||||
return "Usage: msg <username> <message>\n"
|
||||
" w <username> <message>\n";
|
||||
case I18N_MSG_SENT_FORMAT:
|
||||
return "Whisper sent to %s\n";
|
||||
case I18N_MSG_USER_NOT_FOUND_FORMAT:
|
||||
return "User '%s' not found\n";
|
||||
case I18N_INBOX_TITLE:
|
||||
return "Whispers";
|
||||
case I18N_INBOX_EMPTY:
|
||||
return "(empty)";
|
||||
case I18N_NICK_USAGE:
|
||||
return "Usage: nick <new_username>\n";
|
||||
case I18N_NICK_INVALID:
|
||||
return "Invalid username\n";
|
||||
case I18N_NICK_TAKEN_FORMAT:
|
||||
return "Nickname '%s' is already taken\n";
|
||||
case I18N_NICK_UNCHANGED:
|
||||
return "Nickname unchanged\n";
|
||||
case I18N_NICK_CHANGED_FORMAT:
|
||||
return "Nickname changed: %s -> %s\n";
|
||||
case I18N_LAST_USAGE:
|
||||
return "Usage: last [N] (N: 1-50, default 10)\n";
|
||||
case I18N_LAST_HEADER_FORMAT:
|
||||
return "--- Last %d message(s) ---\n";
|
||||
case I18N_SEARCH_USAGE:
|
||||
return "Usage: search <keyword>\n";
|
||||
case I18N_SEARCH_HEADER_FORMAT:
|
||||
return "--- Search: \"%s\" (%d match(es)) ---\n";
|
||||
case I18N_MUTE_JOINS_FORMAT:
|
||||
return "Join/leave notifications: %s\n";
|
||||
case I18N_MUTE_JOINS_MUTED:
|
||||
return "muted";
|
||||
case I18N_MUTE_JOINS_UNMUTED:
|
||||
return "unmuted";
|
||||
case I18N_CLEAR_DONE:
|
||||
return "Command output cleared\n";
|
||||
case I18N_LANG_CURRENT_FORMAT:
|
||||
return "Current language: %s\n"
|
||||
"Usage: lang <en|zh>\n";
|
||||
case I18N_LANG_SET_FORMAT:
|
||||
return "Language set to: %s\n";
|
||||
case I18N_LANG_UNSUPPORTED_FORMAT:
|
||||
return "Unsupported language: %s\n"
|
||||
"Usage: lang <en|zh>\n";
|
||||
case I18N_UNKNOWN_COMMAND_FORMAT:
|
||||
return "Unknown command: %s\n";
|
||||
case I18N_DID_YOU_MEAN_FORMAT:
|
||||
return "Did you mean :%s?\n";
|
||||
case I18N_UNKNOWN_GUIDANCE:
|
||||
return "Type :support for guidance or :help for commands\n";
|
||||
case I18N_EXEC_HELP:
|
||||
return "TNT exec interface\n"
|
||||
"Commands:\n"
|
||||
" help Show this help\n"
|
||||
" health Print service health\n"
|
||||
" users [--json] List online users\n"
|
||||
" stats [--json] Print room statistics\n"
|
||||
" tail [N] Print recent messages\n"
|
||||
" tail -n N Print recent messages\n"
|
||||
" post MESSAGE Post a message non-interactively\n"
|
||||
" post \"/me act\" Post an action message\n"
|
||||
" support Show quick support guide\n"
|
||||
" exit Exit successfully\n";
|
||||
case I18N_EXEC_USERS_USAGE:
|
||||
return "users: usage: users [--json]\n";
|
||||
case I18N_EXEC_STATS_USAGE:
|
||||
return "stats: usage: stats [--json]\n";
|
||||
case I18N_EXEC_TAIL_USAGE:
|
||||
return "tail: usage: tail [N] | tail -n N\n";
|
||||
case I18N_EXEC_POST_USAGE:
|
||||
return "post: usage: post MESSAGE\n";
|
||||
case I18N_EXEC_POST_EMPTY:
|
||||
return "post: message cannot be empty\n";
|
||||
case I18N_EXEC_POST_INVALID_UTF8:
|
||||
return "post: invalid UTF-8 input\n";
|
||||
case I18N_EXEC_UNKNOWN_COMMAND_FORMAT:
|
||||
return "Unknown command: %s\n";
|
||||
case I18N_CONTINUE_PROMPT:
|
||||
return "\nPress any key to continue...";
|
||||
return ui_lang_defs[0].lang;
|
||||
}
|
||||
|
||||
const char *i18n_ui_lang_code(ui_lang_t lang) {
|
||||
for (size_t i = 0; i < sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]); i++) {
|
||||
if (ui_lang_defs[i].lang == lang) {
|
||||
return ui_lang_defs[i].code;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
return ui_lang_defs[0].code;
|
||||
}
|
||||
|
|
|
|||
257
src/i18n_text.c
Normal file
257
src/i18n_text.c
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
#include "i18n.h"
|
||||
|
||||
static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
|
||||
[I18N_USERNAME_PROMPT] = I18N_STRING(
|
||||
" Enter display name (blank for anonymous): ",
|
||||
" 请输入用户名 (留空 anonymous): "
|
||||
),
|
||||
[I18N_INVALID_USERNAME] = I18N_STRING(
|
||||
"Invalid username. Using 'anonymous' instead.\r\n",
|
||||
"用户名无效,已改用 anonymous。\r\n"
|
||||
),
|
||||
[I18N_ROOM_FULL] = I18N_STRING(
|
||||
"Room is full\r\n",
|
||||
"房间已满\r\n"
|
||||
),
|
||||
[I18N_WELCOME_SUBTITLE] = I18N_STRING(
|
||||
"anonymous chat · SSH",
|
||||
"匿名聊天室 · SSH"
|
||||
),
|
||||
[I18N_WELCOME_TAGLINE] = I18N_STRING(
|
||||
"keyboard-first terminal chat",
|
||||
"键盘友好的终端交流"
|
||||
),
|
||||
[I18N_WELCOME_FALLBACK_FORMAT] = I18N_STRING(
|
||||
"TNT %s - anonymous chat over SSH\r\n\r\n",
|
||||
"TNT %s - SSH 匿名聊天室\r\n\r\n"
|
||||
),
|
||||
[I18N_INSERT_HINT_WIDE] = I18N_STRING(
|
||||
"Enter send · Esc NORMAL",
|
||||
"Enter 发送 · Esc NORMAL"
|
||||
),
|
||||
[I18N_INSERT_HINT_NARROW] = I18N_STRING(
|
||||
"Enter · Esc",
|
||||
"Enter · Esc"
|
||||
),
|
||||
[I18N_NORMAL_LATEST] = I18N_STRING(
|
||||
"G latest",
|
||||
"G 最新"
|
||||
),
|
||||
[I18N_NORMAL_NEW_MESSAGES] = I18N_STRING(
|
||||
"new",
|
||||
"新消息"
|
||||
),
|
||||
[I18N_HELP_TITLE] = I18N_STRING(
|
||||
" KEYS ",
|
||||
" 按键 "
|
||||
),
|
||||
[I18N_HELP_STATUS_FORMAT] = I18N_STRING(
|
||||
"-- KEY REFERENCE -- (%d/%d) j/k:scroll g/G:top/bottom l:lang q:close",
|
||||
"-- 按键参考 -- (%d/%d) j/k:滚动 g/G:首尾 l:语言 q:关闭"
|
||||
),
|
||||
[I18N_COMMAND_OUTPUT_TITLE] = I18N_STRING(
|
||||
" COMMAND OUTPUT ",
|
||||
" 命令输出 "
|
||||
),
|
||||
[I18N_COMMAND_OUTPUT_STATUS_FORMAT] = I18N_STRING(
|
||||
"-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom q:close",
|
||||
"-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 q:关闭"
|
||||
),
|
||||
[I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT] = I18N_STRING(
|
||||
"-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom r:refresh q:close",
|
||||
"-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 r:刷新 q:关闭"
|
||||
),
|
||||
[I18N_MOTD_TITLE] = I18N_STRING(
|
||||
" NOTICE ",
|
||||
" 公告 "
|
||||
),
|
||||
[I18N_MOTD_CONTINUE_HINT] = I18N_STRING(
|
||||
" Press any key ",
|
||||
" 按任意键继续 "
|
||||
),
|
||||
[I18N_TITLE_ONLINE_FORMAT] = I18N_STRING(
|
||||
"online %d",
|
||||
"在线 %d"
|
||||
),
|
||||
[I18N_TITLE_MUTED] = I18N_STRING(
|
||||
"muted",
|
||||
"静音"
|
||||
),
|
||||
[I18N_TITLE_HELP_HINT] = I18N_STRING(
|
||||
"? keys",
|
||||
"? 按键"
|
||||
),
|
||||
[I18N_EMPTY_ROOM] = I18N_STRING(
|
||||
"No messages yet",
|
||||
"暂无消息"
|
||||
),
|
||||
[I18N_EMPTY_FILTERED] = I18N_STRING(
|
||||
"No visible messages",
|
||||
"暂无可见消息"
|
||||
),
|
||||
[I18N_IDLE_TIMEOUT_FORMAT] = I18N_STRING(
|
||||
"\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n",
|
||||
"\r\n\033[33m已断开: 空闲超时 (%d 分钟)\033[0m\r\n"
|
||||
),
|
||||
[I18N_SYSTEM_USERNAME] = I18N_STRING(
|
||||
"system",
|
||||
"系统"
|
||||
),
|
||||
[I18N_SYSTEM_JOIN_FORMAT] = I18N_STRING(
|
||||
"%s joined the room",
|
||||
"%s 加入了聊天室"
|
||||
),
|
||||
[I18N_SYSTEM_LEAVE_FORMAT] = I18N_STRING(
|
||||
"%s left the room",
|
||||
"%s 离开了聊天室"
|
||||
),
|
||||
[I18N_SYSTEM_NICK_FORMAT] = I18N_STRING(
|
||||
"%s renamed to %s",
|
||||
"%s 更名为 %s"
|
||||
),
|
||||
[I18N_USERS_TITLE] = I18N_STRING(
|
||||
"Online users",
|
||||
"在线用户"
|
||||
),
|
||||
[I18N_MSG_SENT_FORMAT] = I18N_STRING(
|
||||
"Private message sent to %s\n",
|
||||
"私信已发送给 %s\n"
|
||||
),
|
||||
[I18N_MSG_USER_NOT_FOUND_FORMAT] = I18N_STRING(
|
||||
"User '%s' not found\n",
|
||||
"未找到用户 '%s'\n"
|
||||
),
|
||||
[I18N_REPLY_NO_TARGET] = I18N_STRING(
|
||||
"No private message to reply to\n",
|
||||
"没有可回复的私信\n"
|
||||
),
|
||||
[I18N_INBOX_TITLE] = I18N_STRING(
|
||||
"Private messages",
|
||||
"私信"
|
||||
),
|
||||
[I18N_INBOX_EMPTY] = I18N_STRING(
|
||||
"(empty)",
|
||||
"(空)"
|
||||
),
|
||||
[I18N_INBOX_SENT_TO_FORMAT] = I18N_STRING(
|
||||
"you -> %s",
|
||||
"你 -> %s"
|
||||
),
|
||||
[I18N_INBOX_CLEARED] = I18N_STRING(
|
||||
"Private messages cleared\n",
|
||||
"私信已清空\n"
|
||||
),
|
||||
[I18N_INBOX_UNREAD_FORMAT] = I18N_STRING(
|
||||
"%d new",
|
||||
"%d 新"
|
||||
),
|
||||
[I18N_NICK_INVALID] = I18N_STRING(
|
||||
"Invalid username\n",
|
||||
"用户名无效\n"
|
||||
),
|
||||
[I18N_NICK_TAKEN_FORMAT] = I18N_STRING(
|
||||
"Nickname '%s' is already taken\n",
|
||||
"昵称 '%s' 已被使用\n"
|
||||
),
|
||||
[I18N_NICK_UNCHANGED] = I18N_STRING(
|
||||
"Nickname unchanged\n",
|
||||
"昵称未变化\n"
|
||||
),
|
||||
[I18N_NICK_CHANGED_FORMAT] = I18N_STRING(
|
||||
"Nickname changed: %s -> %s\n",
|
||||
"昵称已修改: %s -> %s\n"
|
||||
),
|
||||
[I18N_LAST_HEADER_FORMAT] = I18N_STRING(
|
||||
"--- Last %d message(s) ---\n",
|
||||
"--- 最近 %d 条消息 ---\n"
|
||||
),
|
||||
[I18N_LAST_EMPTY] = I18N_STRING(
|
||||
"No messages to show\n",
|
||||
"没有可显示的消息\n"
|
||||
),
|
||||
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
|
||||
"--- Search: \"%s\" (showing last %d match(es)) ---\n",
|
||||
"--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n"
|
||||
),
|
||||
[I18N_SEARCH_EMPTY] = I18N_STRING(
|
||||
"No matches\n",
|
||||
"没有匹配结果\n"
|
||||
),
|
||||
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
|
||||
"Join/leave notifications: %s\n",
|
||||
"加入/离开提示: %s\n"
|
||||
),
|
||||
[I18N_MUTE_JOINS_MUTED] = I18N_STRING(
|
||||
"muted",
|
||||
"已静音"
|
||||
),
|
||||
[I18N_MUTE_JOINS_UNMUTED] = I18N_STRING(
|
||||
"unmuted",
|
||||
"已开启"
|
||||
),
|
||||
[I18N_CLEAR_DONE] = I18N_STRING(
|
||||
"Command output cleared\n",
|
||||
"命令输出已清空\n"
|
||||
),
|
||||
[I18N_LANG_CURRENT_FORMAT] = I18N_STRING(
|
||||
"Current language: %s\n"
|
||||
"Usage: lang <en|zh>\n",
|
||||
"当前语言: %s\n"
|
||||
"用法: lang <en|zh>\n"
|
||||
),
|
||||
[I18N_LANG_SET_FORMAT] = I18N_STRING(
|
||||
"Language set to: %s\n",
|
||||
"语言已切换为: %s\n"
|
||||
),
|
||||
[I18N_LANG_UNSUPPORTED_FORMAT] = I18N_STRING(
|
||||
"Unsupported language: %s\n"
|
||||
"Usage: lang <en|zh>\n",
|
||||
"不支持的语言: %s\n"
|
||||
"用法: lang <en|zh>\n"
|
||||
),
|
||||
[I18N_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
|
||||
"Unknown command: %s\n",
|
||||
"未知命令: %s\n"
|
||||
),
|
||||
[I18N_DID_YOU_MEAN_FORMAT] = I18N_STRING(
|
||||
"Did you mean :%s?\n",
|
||||
"你是想输入 :%s 吗?\n"
|
||||
),
|
||||
[I18N_UNKNOWN_GUIDANCE] = I18N_STRING(
|
||||
"Type :help for help\n",
|
||||
"输入 :help 查看帮助\n"
|
||||
),
|
||||
[I18N_EXEC_POST_EMPTY] = I18N_STRING(
|
||||
"post: message cannot be empty\n",
|
||||
"post: 消息不能为空\n"
|
||||
),
|
||||
[I18N_EXEC_POST_INVALID_UTF8] = I18N_STRING(
|
||||
"post: invalid UTF-8 input\n",
|
||||
"post: 输入不是有效 UTF-8\n"
|
||||
),
|
||||
[I18N_EXEC_POST_TOO_LONG] = I18N_STRING(
|
||||
"post: message too long\n",
|
||||
"post: 消息过长\n"
|
||||
),
|
||||
[I18N_EXEC_POST_PERSIST_FAILED] = I18N_STRING(
|
||||
"post: failed to persist message\n",
|
||||
"post: 消息持久化失败\n"
|
||||
),
|
||||
[I18N_EXEC_COMMAND_TOO_LONG] = I18N_STRING(
|
||||
"exec: command too long\n",
|
||||
"exec: 命令过长\n"
|
||||
),
|
||||
[I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
|
||||
"Unknown command: %s\n",
|
||||
"未知命令: %s\n"
|
||||
)
|
||||
};
|
||||
|
||||
const char *i18n_text(ui_lang_t lang, i18n_text_id_t id) {
|
||||
if (id < 0 || id >= I18N_TEXT_COUNT) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const i18n_string_t *entry = &text_catalog[id];
|
||||
return i18n_string(*entry, lang);
|
||||
}
|
||||
447
src/input.c
447
src/input.c
|
|
@ -2,11 +2,14 @@
|
|||
#include "chat_room.h"
|
||||
#include "client.h"
|
||||
#include "commands.h"
|
||||
#include "config_defaults.h"
|
||||
#include "common.h"
|
||||
#include "exec.h"
|
||||
#include "history_view.h"
|
||||
#include "i18n.h"
|
||||
#include "input_buffer.h"
|
||||
#include "message.h"
|
||||
#include "module_runtime.h"
|
||||
#include "ratelimit.h"
|
||||
#include "system_message.h"
|
||||
#include "tui.h"
|
||||
|
|
@ -20,22 +23,24 @@
|
|||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
static int g_idle_timeout = DEFAULT_IDLE_TIMEOUT;
|
||||
static help_lang_t g_default_lang = LANG_EN;
|
||||
static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT;
|
||||
static ui_lang_t g_default_ui_lang = UI_LANG_EN;
|
||||
|
||||
#define MAIN_LOOP_POLL_TIMEOUT_MS 250
|
||||
|
||||
void input_init(void) {
|
||||
g_idle_timeout = env_int("TNT_IDLE_TIMEOUT", DEFAULT_IDLE_TIMEOUT, 0, 86400);
|
||||
g_default_lang = i18n_default_lang();
|
||||
g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT);
|
||||
g_default_ui_lang = i18n_default_ui_lang();
|
||||
}
|
||||
|
||||
static int read_username(client_t *client) {
|
||||
char username[MAX_USERNAME_LEN] = {0};
|
||||
int pos = 0;
|
||||
char buf[4];
|
||||
const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT);
|
||||
|
||||
tui_render_welcome(client);
|
||||
client_printf(client, "%s", i18n_text(client->help_lang,
|
||||
I18N_USERNAME_PROMPT));
|
||||
client_printf(client, "%s", prompt);
|
||||
|
||||
while (1) {
|
||||
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
|
||||
|
|
@ -54,6 +59,18 @@ static int read_username(client_t *client) {
|
|||
|
||||
if (b == '\r' || b == '\n') {
|
||||
break;
|
||||
} else if (b == 3 || b == 4) { /* Ctrl+C / Ctrl+D */
|
||||
return -1;
|
||||
} else if (b == 21) { /* Ctrl+U: clear line */
|
||||
username[0] = '\0';
|
||||
pos = 0;
|
||||
client_printf(client, "\r\033[K%s", prompt);
|
||||
} else if (b == 23) { /* Ctrl+W: delete word */
|
||||
if (username[0] != '\0') {
|
||||
utf8_remove_last_word(username);
|
||||
pos = (int)strlen(username);
|
||||
client_printf(client, "\r\033[K%s%s", prompt, username);
|
||||
}
|
||||
} else if (b == 127 || b == 8) { /* Backspace */
|
||||
if (pos > 0) {
|
||||
/* Compute width of the last character before removing it */
|
||||
|
|
@ -117,7 +134,7 @@ static int read_username(client_t *client) {
|
|||
|
||||
/* Validate username for security */
|
||||
if (!is_valid_username(client->username)) {
|
||||
client_printf(client, "%s", i18n_text(client->help_lang,
|
||||
client_printf(client, "%s", i18n_text(client->ui_lang,
|
||||
I18N_INVALID_USERNAME));
|
||||
strcpy(client->username, "anonymous");
|
||||
} else {
|
||||
|
|
@ -134,9 +151,17 @@ static int read_username(client_t *client) {
|
|||
void notify_mentions(const char *content, const client_t *sender) {
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
int count = g_room->client_count;
|
||||
client_t *targets[MAX_CLIENTS];
|
||||
client_t **targets = NULL;
|
||||
int target_count = 0;
|
||||
|
||||
if (count > 0) {
|
||||
targets = calloc((size_t)count, sizeof(*targets));
|
||||
if (!targets) {
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
client_t *c = g_room->clients[i];
|
||||
if (c == sender) continue;
|
||||
|
|
@ -150,11 +175,11 @@ void notify_mentions(const char *content, const client_t *sender) {
|
|||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
for (int i = 0; i < target_count; i++) {
|
||||
client_send(targets[i], "\a", 1);
|
||||
targets[i]->unread_mentions++;
|
||||
targets[i]->redraw_pending = true;
|
||||
client_queue_bell(targets[i]);
|
||||
client_release(targets[i]);
|
||||
}
|
||||
free(targets);
|
||||
}
|
||||
|
||||
static int read_channel_exact(client_t *client, char *buf, size_t len,
|
||||
|
|
@ -173,44 +198,184 @@ static int read_channel_exact(client_t *client, char *buf, size_t len,
|
|||
return (int)got;
|
||||
}
|
||||
|
||||
static bool append_paste_byte(char *input, unsigned char b) {
|
||||
if (b == '\r' || b == '\n' || b == '\t') {
|
||||
b = ' ';
|
||||
}
|
||||
if (b < 32) {
|
||||
return true;
|
||||
static int normal_visible_message_count(const client_t *client) {
|
||||
if (!client || !client->mute_joins) {
|
||||
return room_get_message_count(g_room);
|
||||
}
|
||||
|
||||
size_t cur = strlen(input);
|
||||
if (cur < MAX_MESSAGE_LEN - 1) {
|
||||
input[cur] = (char)b;
|
||||
input[cur + 1] = '\0';
|
||||
return true;
|
||||
int count = 0;
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
for (int i = 0; i < g_room->message_count; i++) {
|
||||
if (!system_message_is_join_leave(&g_room->messages[i])) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
return count;
|
||||
}
|
||||
|
||||
static void normal_scroll_to_latest(client_t *client) {
|
||||
if (!client) return;
|
||||
history_view_scroll_to_latest(&client->scroll_pos, &client->follow_tail,
|
||||
room_get_message_count(g_room),
|
||||
normal_visible_message_count(client),
|
||||
history_view_height(client->height));
|
||||
}
|
||||
|
||||
static void normal_scroll_by(client_t *client, int delta) {
|
||||
if (!client) return;
|
||||
history_view_scroll_by(&client->scroll_pos, &client->follow_tail,
|
||||
room_get_message_count(g_room),
|
||||
normal_visible_message_count(client),
|
||||
history_view_height(client->height), delta);
|
||||
}
|
||||
|
||||
static void normal_enter_insert(client_t *client) {
|
||||
if (!client) return;
|
||||
client->mode = MODE_INSERT;
|
||||
client->follow_tail = true;
|
||||
client->unread_mentions = 0;
|
||||
tui_render_screen(client);
|
||||
}
|
||||
|
||||
static void dismiss_command_output(client_t *client) {
|
||||
bool was_motd;
|
||||
|
||||
if (!client) return;
|
||||
|
||||
was_motd = client->show_motd;
|
||||
client->command_output[0] = '\0';
|
||||
client->command_output_scroll = 0;
|
||||
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
|
||||
client->show_motd = false;
|
||||
if (was_motd) {
|
||||
client->mode = MODE_INSERT;
|
||||
client->follow_tail = true;
|
||||
client->unread_mentions = 0;
|
||||
normal_scroll_to_latest(client);
|
||||
} else {
|
||||
client->mode = MODE_NORMAL;
|
||||
}
|
||||
tui_render_screen(client);
|
||||
}
|
||||
|
||||
typedef enum {
|
||||
PAGER_ACTION_NONE,
|
||||
PAGER_ACTION_SCROLL,
|
||||
PAGER_ACTION_CLOSE,
|
||||
PAGER_ACTION_REFRESH
|
||||
} pager_action_t;
|
||||
|
||||
static int pager_page_height(client_t *client) {
|
||||
int page = client->height - 2;
|
||||
if (page < 1) page = 1;
|
||||
return page;
|
||||
}
|
||||
|
||||
static void pager_scroll_by(int *scroll_pos, int delta) {
|
||||
*scroll_pos += delta;
|
||||
if (*scroll_pos < 0) {
|
||||
*scroll_pos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static pager_action_t pager_apply_key(client_t *client, unsigned char key,
|
||||
int *scroll_pos, bool allow_refresh) {
|
||||
int page = pager_page_height(client);
|
||||
int half = page / 2;
|
||||
if (half < 1) half = 1;
|
||||
|
||||
if (key == 'q') {
|
||||
return PAGER_ACTION_CLOSE;
|
||||
} else if (key == 'j') {
|
||||
pager_scroll_by(scroll_pos, 1);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (key == 'k') {
|
||||
pager_scroll_by(scroll_pos, -1);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (key == 4) { /* Ctrl+D: half page down */
|
||||
pager_scroll_by(scroll_pos, half);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (key == 21) { /* Ctrl+U: half page up */
|
||||
pager_scroll_by(scroll_pos, -half);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (key == 6 || key == ' ') { /* Ctrl+F / Space: page down */
|
||||
pager_scroll_by(scroll_pos, page);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (key == 2 || key == 'b') { /* Ctrl+B / b: page up */
|
||||
pager_scroll_by(scroll_pos, -page);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (key == 'g') {
|
||||
*scroll_pos = 0;
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (key == 'G') {
|
||||
*scroll_pos = 999;
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if ((key == 'r' || key == 'R') && allow_refresh) {
|
||||
return PAGER_ACTION_REFRESH;
|
||||
} else if (key == 27) {
|
||||
char seq[3];
|
||||
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
|
||||
if (n != 1) {
|
||||
return PAGER_ACTION_CLOSE;
|
||||
}
|
||||
if (seq[0] != '[') {
|
||||
return PAGER_ACTION_NONE;
|
||||
}
|
||||
|
||||
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
|
||||
if (n != 1) {
|
||||
return PAGER_ACTION_NONE;
|
||||
}
|
||||
|
||||
if (seq[1] == 'A') { /* Up arrow */
|
||||
pager_scroll_by(scroll_pos, -1);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (seq[1] == 'B') { /* Down arrow */
|
||||
pager_scroll_by(scroll_pos, 1);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (seq[1] == 'H') { /* Home */
|
||||
*scroll_pos = 0;
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (seq[1] == 'F') { /* End */
|
||||
*scroll_pos = 999;
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (seq[1] >= '1' && seq[1] <= '6') {
|
||||
n = ssh_channel_read_timeout(client->channel, &seq[2], 1, 0, 50);
|
||||
if (n == 1 && seq[2] == '~') {
|
||||
if (seq[1] == '5') { /* PageUp */
|
||||
pager_scroll_by(scroll_pos, -page);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (seq[1] == '6') { /* PageDown */
|
||||
pager_scroll_by(scroll_pos, page);
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (seq[1] == '1') { /* Home */
|
||||
*scroll_pos = 0;
|
||||
return PAGER_ACTION_SCROLL;
|
||||
} else if (seq[1] == '4') { /* End */
|
||||
*scroll_pos = 999;
|
||||
return PAGER_ACTION_SCROLL;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PAGER_ACTION_NONE;
|
||||
}
|
||||
|
||||
/* Handle a single key press. Returns true if the key was fully consumed
|
||||
* (no further character buffering needed). */
|
||||
static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||
/* Handle Ctrl+C (Exit or switch to NORMAL) */
|
||||
if (key == 3) {
|
||||
client_mode_t previous_mode = client->mode;
|
||||
if (client->show_help) {
|
||||
client->show_help = false;
|
||||
tui_render_screen(client);
|
||||
return true;
|
||||
}
|
||||
if (client->command_output[0] != '\0') {
|
||||
dismiss_command_output(client);
|
||||
return true;
|
||||
}
|
||||
if (previous_mode != MODE_NORMAL) {
|
||||
client->mode = MODE_NORMAL;
|
||||
client->command_input[0] = '\0';
|
||||
|
|
@ -228,63 +393,46 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
|
||||
/* Handle help screen */
|
||||
if (client->show_help) {
|
||||
/* Page size: roughly the visible help body region. */
|
||||
int page = client->height - 2;
|
||||
if (page < 1) page = 1;
|
||||
int half = page / 2;
|
||||
if (half < 1) half = 1;
|
||||
pager_action_t action;
|
||||
|
||||
if (key == 'q' || key == 27) {
|
||||
if (key == 'l' || key == 'L') {
|
||||
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
|
||||
client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
return true;
|
||||
}
|
||||
|
||||
action = pager_apply_key(client, key, &client->help_scroll_pos, false);
|
||||
if (action == PAGER_ACTION_CLOSE) {
|
||||
client->show_help = false;
|
||||
tui_render_screen(client);
|
||||
} else if (key == 'e' || key == 'E') {
|
||||
client->help_lang = LANG_EN;
|
||||
client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
} else if (key == 'z' || key == 'Z') {
|
||||
client->help_lang = LANG_ZH;
|
||||
client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
} else if (key == 'j') {
|
||||
client->help_scroll_pos++;
|
||||
tui_render_help(client);
|
||||
} else if (key == 'k' && client->help_scroll_pos > 0) {
|
||||
client->help_scroll_pos--;
|
||||
tui_render_help(client);
|
||||
} else if (key == 4) { /* Ctrl+D: half page down */
|
||||
client->help_scroll_pos += half;
|
||||
tui_render_help(client);
|
||||
} else if (key == 21) { /* Ctrl+U: half page up */
|
||||
client->help_scroll_pos -= half;
|
||||
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
} else if (key == 6) { /* Ctrl+F: full page down */
|
||||
client->help_scroll_pos += page;
|
||||
tui_render_help(client);
|
||||
} else if (key == 2) { /* Ctrl+B: full page up */
|
||||
client->help_scroll_pos -= page;
|
||||
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
} else if (key == 'g') {
|
||||
client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
} else if (key == 'G') {
|
||||
client->help_scroll_pos = 999; /* Large number */
|
||||
} else if (action == PAGER_ACTION_SCROLL) {
|
||||
tui_render_help(client);
|
||||
}
|
||||
return true; /* Key consumed */
|
||||
}
|
||||
|
||||
/* Handle command output / MOTD display: any key dismisses */
|
||||
/* Handle command output / MOTD display. MOTD remains a simple notice;
|
||||
* command output behaves like a small pager so long results can be read. */
|
||||
if (client->command_output[0] != '\0') {
|
||||
bool was_motd = client->show_motd;
|
||||
client->command_output[0] = '\0';
|
||||
client->show_motd = false;
|
||||
client->mode = MODE_NORMAL;
|
||||
if (was_motd) {
|
||||
normal_scroll_to_latest(client);
|
||||
pager_action_t action;
|
||||
|
||||
if (client->show_motd) {
|
||||
dismiss_command_output(client);
|
||||
return true;
|
||||
}
|
||||
|
||||
action = pager_apply_key(client, key, &client->command_output_scroll,
|
||||
true);
|
||||
if (action == PAGER_ACTION_CLOSE) {
|
||||
dismiss_command_output(client);
|
||||
} else if (action == PAGER_ACTION_SCROLL) {
|
||||
tui_render_command_output(client);
|
||||
} else if (action == PAGER_ACTION_REFRESH) {
|
||||
if (commands_refresh_active_output(client)) {
|
||||
tui_render_command_output(client);
|
||||
}
|
||||
}
|
||||
tui_render_screen(client);
|
||||
return true; /* Key consumed */
|
||||
}
|
||||
|
||||
|
|
@ -336,6 +484,8 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
* spaces so a multi-line paste stays a
|
||||
* single message instead of N sends. */
|
||||
bool overflow = false;
|
||||
bool invalid_utf8 = false;
|
||||
tnt_input_utf8_state_t paste_utf8 = {0};
|
||||
while (1) {
|
||||
char b;
|
||||
int k = ssh_channel_read_timeout(
|
||||
|
|
@ -356,21 +506,40 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
* but keep printable bytes that
|
||||
* followed it. */
|
||||
for (int i = 0; i < t; i++) {
|
||||
if (!append_paste_byte(
|
||||
input,
|
||||
(unsigned char)tail[i])) {
|
||||
int status =
|
||||
tnt_input_append_stream_byte(
|
||||
input, MAX_MESSAGE_LEN,
|
||||
&paste_utf8,
|
||||
(unsigned char)tail[i],
|
||||
true);
|
||||
if (status &
|
||||
TNT_INPUT_APPEND_OVERFLOW) {
|
||||
overflow = true;
|
||||
}
|
||||
if (status &
|
||||
TNT_INPUT_APPEND_INVALID_UTF8) {
|
||||
invalid_utf8 = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!append_paste_byte(input,
|
||||
(unsigned char)b)) {
|
||||
int status = tnt_input_append_stream_byte(
|
||||
input, MAX_MESSAGE_LEN, &paste_utf8,
|
||||
(unsigned char)b, true);
|
||||
if (status & TNT_INPUT_APPEND_OVERFLOW) {
|
||||
overflow = true;
|
||||
}
|
||||
if (status & TNT_INPUT_APPEND_INVALID_UTF8) {
|
||||
invalid_utf8 = true;
|
||||
}
|
||||
}
|
||||
if (tnt_input_utf8_state_finish(
|
||||
&paste_utf8) &
|
||||
TNT_INPUT_APPEND_INVALID_UTF8) {
|
||||
invalid_utf8 = true;
|
||||
}
|
||||
tui_render_input(client, input);
|
||||
if (overflow) {
|
||||
if (overflow || invalid_utf8) {
|
||||
client_send(client, "\a", 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -416,7 +585,11 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
}
|
||||
room_broadcast(g_room, &msg);
|
||||
notify_mentions(msg.content, client);
|
||||
message_save(&msg);
|
||||
if (message_save(&msg) == 0) {
|
||||
tnt_module_runtime_publish_message_created(&msg);
|
||||
} else {
|
||||
fprintf(stderr, "interactive: failed to persist message\n");
|
||||
}
|
||||
input[0] = '\0';
|
||||
}
|
||||
tui_render_screen(client);
|
||||
|
|
@ -491,16 +664,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
case MODE_NORMAL: {
|
||||
int nm_msg_height = history_view_height(client->height);
|
||||
|
||||
if (key == 'i') {
|
||||
client->mode = MODE_INSERT;
|
||||
client->follow_tail = true;
|
||||
client->unread_mentions = 0;
|
||||
tui_render_screen(client);
|
||||
if (key == 'i' || key == 'a' || key == 'A' ||
|
||||
key == 'o' || key == 'O') {
|
||||
normal_enter_insert(client);
|
||||
return true;
|
||||
} else if (key == ':') {
|
||||
client->mode = MODE_COMMAND;
|
||||
client->command_input[0] = '\0';
|
||||
tui_render_screen(client);
|
||||
tui_render_command_input(client);
|
||||
return true;
|
||||
} else if (key == '/') {
|
||||
client->mode = MODE_COMMAND;
|
||||
snprintf(client->command_input, sizeof(client->command_input),
|
||||
"search ");
|
||||
tui_render_command_input(client);
|
||||
return true;
|
||||
} else if (key == 'j') {
|
||||
normal_scroll_by(client, 1);
|
||||
|
|
@ -600,7 +777,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
client->command_history[client->command_history_pos],
|
||||
sizeof(client->command_input) - 1);
|
||||
client->command_input[sizeof(client->command_input) - 1] = '\0';
|
||||
tui_render_screen(client);
|
||||
tui_render_command_input(client);
|
||||
}
|
||||
return true;
|
||||
} else if (seq[1] == 'B') { /* Down arrow */
|
||||
|
|
@ -614,7 +791,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
client->command_history_pos = client->command_history_count;
|
||||
client->command_input[0] = '\0';
|
||||
}
|
||||
tui_render_screen(client);
|
||||
tui_render_command_input(client);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -629,19 +806,19 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
} else if (key == 127 || key == 8) { /* Backspace */
|
||||
if (client->command_input[0] != '\0') {
|
||||
utf8_remove_last_char(client->command_input);
|
||||
tui_render_screen(client);
|
||||
tui_render_command_input(client);
|
||||
}
|
||||
return true; /* Key consumed */
|
||||
} else if (key == 23) { /* Ctrl+W (Delete Word) */
|
||||
if (client->command_input[0] != '\0') {
|
||||
utf8_remove_last_word(client->command_input);
|
||||
tui_render_screen(client);
|
||||
tui_render_command_input(client);
|
||||
}
|
||||
return true;
|
||||
} else if (key == 21) { /* Ctrl+U (Delete Line) */
|
||||
if (client->command_input[0] != '\0') {
|
||||
client->command_input[0] = '\0';
|
||||
tui_render_screen(client);
|
||||
tui_render_command_input(client);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -665,20 +842,24 @@ void input_run_session(client_t *client) {
|
|||
/* Terminal size already set from PTY request */
|
||||
client->mode = MODE_INSERT;
|
||||
client->follow_tail = true;
|
||||
client->help_lang = g_default_lang;
|
||||
client->ui_lang = g_default_ui_lang;
|
||||
client->connected = true;
|
||||
client->command_history_count = 0;
|
||||
client->command_history_pos = 0;
|
||||
client->command_output_scroll = 0;
|
||||
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
|
||||
client->connect_time = time(NULL);
|
||||
client->last_active = time(NULL);
|
||||
|
||||
/* Check for exec command */
|
||||
if (client->exec_command[0] != '\0') {
|
||||
if (client->exec_command[0] != '\0' || client->exec_command_too_long) {
|
||||
int exit_status = exec_dispatch(client);
|
||||
ssh_channel_request_send_exit_status(client->channel, exit_status);
|
||||
ssh_blocking_flush(client->session, 1000);
|
||||
ssh_channel_send_eof(client->channel);
|
||||
ssh_blocking_flush(client->session, 1000);
|
||||
ssh_channel_close(client->channel);
|
||||
ssh_blocking_flush(client->session, 1000);
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
|
|
@ -689,7 +870,7 @@ void input_run_session(client_t *client) {
|
|||
|
||||
/* Add to room */
|
||||
if (room_add_client(g_room, client) < 0) {
|
||||
client_printf(client, "%s", i18n_text(client->help_lang,
|
||||
client_printf(client, "%s", i18n_text(client->ui_lang,
|
||||
I18N_ROOM_FULL));
|
||||
goto cleanup;
|
||||
}
|
||||
|
|
@ -703,7 +884,7 @@ void input_run_session(client_t *client) {
|
|||
|
||||
/* Broadcast join message */
|
||||
message_t join_msg;
|
||||
system_message_make_join(&join_msg, client->username, client->help_lang);
|
||||
system_message_make_join(&join_msg, client->username, client->ui_lang);
|
||||
room_broadcast(g_room, &join_msg);
|
||||
message_save(&join_msg);
|
||||
|
||||
|
|
@ -721,6 +902,8 @@ void input_run_session(client_t *client) {
|
|||
snprintf(client->command_output,
|
||||
sizeof(client->command_output),
|
||||
"%s", motd_buf);
|
||||
client->command_output_scroll = 0;
|
||||
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
|
||||
client->show_motd = true;
|
||||
tui_render_motd(client);
|
||||
seen_update_seq = room_get_update_seq(g_room);
|
||||
|
|
@ -738,7 +921,12 @@ main_loop:
|
|||
|
||||
/* Main input loop */
|
||||
while (client->connected && ssh_channel_is_open(client->channel)) {
|
||||
int ready = ssh_channel_poll_timeout(client->channel, 1000, 0);
|
||||
if (client_flush_output(client) != 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
int ready = ssh_channel_poll_timeout(client->channel,
|
||||
MAIN_LOOP_POLL_TIMEOUT_MS, 0);
|
||||
|
||||
if (ready == SSH_ERROR) {
|
||||
break;
|
||||
|
|
@ -752,11 +940,26 @@ main_loop:
|
|||
break;
|
||||
}
|
||||
|
||||
if (client_flush_output(client) != 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (client_flush_pending_bells(client) != 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (current_update_seq != seen_update_seq) {
|
||||
seen_update_seq = current_update_seq;
|
||||
room_updated = true;
|
||||
}
|
||||
|
||||
if (client->command_output_kind == TNT_COMMAND_OUTPUT_INBOX &&
|
||||
client->command_output[0] != '\0' &&
|
||||
client->unread_whispers > 0) {
|
||||
commands_refresh_active_output(client);
|
||||
client->redraw_pending = true;
|
||||
}
|
||||
|
||||
if (client->redraw_pending ||
|
||||
(room_updated && !client->show_help &&
|
||||
client->command_output[0] == '\0')) {
|
||||
|
|
@ -788,7 +991,7 @@ main_loop:
|
|||
if (g_idle_timeout > 0 && joined_room &&
|
||||
time(NULL) - client->last_active >= g_idle_timeout) {
|
||||
client_printf(client,
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_IDLE_TIMEOUT_FORMAT),
|
||||
g_idle_timeout / 60);
|
||||
break;
|
||||
|
|
@ -817,10 +1020,9 @@ main_loop:
|
|||
if (client->mode == MODE_INSERT && !client->show_help &&
|
||||
client->command_output[0] == '\0') {
|
||||
if (b >= 32 && b < 127) { /* ASCII printable */
|
||||
int len = strlen(input);
|
||||
if (len < MAX_MESSAGE_LEN - 1) {
|
||||
input[len] = b;
|
||||
input[len + 1] = '\0';
|
||||
int status = tnt_input_append_ascii(input,
|
||||
MAX_MESSAGE_LEN, b);
|
||||
if (status == TNT_INPUT_APPEND_OK) {
|
||||
tui_render_input(client, input);
|
||||
} else {
|
||||
client_send(client, "\a", 1);
|
||||
|
|
@ -844,10 +1046,9 @@ main_loop:
|
|||
/* Invalid UTF-8 sequence */
|
||||
continue;
|
||||
}
|
||||
int len = strlen(input);
|
||||
if (len + char_len <= MAX_MESSAGE_LEN - 1) {
|
||||
memcpy(input + len, buf, char_len);
|
||||
input[len + char_len] = '\0';
|
||||
int status = tnt_input_append_utf8_sequence(
|
||||
input, MAX_MESSAGE_LEN, buf, char_len);
|
||||
if (status == TNT_INPUT_APPEND_OK) {
|
||||
tui_render_input(client, input);
|
||||
} else {
|
||||
client_send(client, "\a", 1);
|
||||
|
|
@ -856,11 +1057,13 @@ main_loop:
|
|||
} else if (client->mode == MODE_COMMAND && !client->show_help &&
|
||||
client->command_output[0] == '\0') {
|
||||
if (b >= 32 && b < 127) { /* ASCII printable */
|
||||
size_t len = strlen(client->command_input);
|
||||
if (len < sizeof(client->command_input) - 1) {
|
||||
client->command_input[len] = b;
|
||||
client->command_input[len + 1] = '\0';
|
||||
tui_render_screen(client);
|
||||
int status = tnt_input_append_ascii(
|
||||
client->command_input, sizeof(client->command_input),
|
||||
b);
|
||||
if (status == TNT_INPUT_APPEND_OK) {
|
||||
tui_render_command_input(client);
|
||||
} else {
|
||||
client_send(client, "\a", 1);
|
||||
}
|
||||
} else if (b >= 128) { /* UTF-8 multi-byte */
|
||||
int char_len = utf8_byte_length(b);
|
||||
|
|
@ -872,11 +1075,13 @@ main_loop:
|
|||
if (read_bytes != char_len - 1) continue;
|
||||
}
|
||||
if (!utf8_is_valid_sequence(buf, char_len)) continue;
|
||||
size_t len = strlen(client->command_input);
|
||||
if (len + (size_t)char_len < sizeof(client->command_input) - 1) {
|
||||
memcpy(client->command_input + len, buf, char_len);
|
||||
client->command_input[len + char_len] = '\0';
|
||||
tui_render_screen(client);
|
||||
int status = tnt_input_append_utf8_sequence(
|
||||
client->command_input, sizeof(client->command_input),
|
||||
buf, char_len);
|
||||
if (status == TNT_INPUT_APPEND_OK) {
|
||||
tui_render_command_input(client);
|
||||
} else {
|
||||
client_send(client, "\a", 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -893,7 +1098,7 @@ cleanup:
|
|||
if (joined_room) {
|
||||
message_t leave_msg;
|
||||
system_message_make_leave(&leave_msg, client->username,
|
||||
client->help_lang);
|
||||
client->ui_lang);
|
||||
|
||||
client->connected = false;
|
||||
room_remove_client(g_room, client);
|
||||
|
|
@ -903,17 +1108,7 @@ cleanup:
|
|||
|
||||
ratelimit_release_ip(client->client_ip);
|
||||
|
||||
/* Remove channel callbacks before releasing refs to prevent use-after-free
|
||||
* if a callback fires between the two releases. */
|
||||
if (client->channel && client->channel_cb) {
|
||||
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
|
||||
}
|
||||
|
||||
/* Release the callback reference (paired with addref before client_install_channel_callbacks) */
|
||||
client_release(client);
|
||||
|
||||
/* Release the main reference - client will be freed when all refs are gone */
|
||||
client_release(client);
|
||||
client_release_session(client);
|
||||
|
||||
/* Decrement connection count */
|
||||
ratelimit_decrement_total();
|
||||
|
|
|
|||
147
src/input_buffer.c
Normal file
147
src/input_buffer.c
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
#include "input_buffer.h"
|
||||
|
||||
#include "utf8.h"
|
||||
|
||||
void tnt_input_utf8_state_reset(tnt_input_utf8_state_t *state) {
|
||||
if (!state) return;
|
||||
state->len = 0;
|
||||
state->expected_len = 0;
|
||||
memset(state->bytes, 0, sizeof(state->bytes));
|
||||
}
|
||||
|
||||
static int append_bytes(char *input, size_t input_size, const char *bytes,
|
||||
size_t len) {
|
||||
size_t cur;
|
||||
|
||||
if (!input || !bytes || input_size == 0 || len == 0) {
|
||||
return TNT_INPUT_APPEND_IGNORED;
|
||||
}
|
||||
|
||||
cur = strlen(input);
|
||||
if (cur + len >= input_size) {
|
||||
return TNT_INPUT_APPEND_OVERFLOW;
|
||||
}
|
||||
|
||||
memcpy(input + cur, bytes, len);
|
||||
input[cur + len] = '\0';
|
||||
return TNT_INPUT_APPEND_OK;
|
||||
}
|
||||
|
||||
int tnt_input_append_ascii(char *input, size_t input_size, unsigned char b) {
|
||||
char c = (char)b;
|
||||
|
||||
if (b < 32 || b >= 127) {
|
||||
return TNT_INPUT_APPEND_IGNORED;
|
||||
}
|
||||
|
||||
return append_bytes(input, input_size, &c, 1);
|
||||
}
|
||||
|
||||
int tnt_input_append_utf8_sequence(char *input, size_t input_size,
|
||||
const char *bytes, int len) {
|
||||
if (!bytes || len <= 0 || len > 4 ||
|
||||
!utf8_is_valid_sequence(bytes, len)) {
|
||||
return TNT_INPUT_APPEND_INVALID_UTF8;
|
||||
}
|
||||
|
||||
return append_bytes(input, input_size, bytes, (size_t)len);
|
||||
}
|
||||
|
||||
static int append_printable_byte(char *input, size_t input_size,
|
||||
tnt_input_utf8_state_t *state,
|
||||
unsigned char b, bool paste_mode);
|
||||
|
||||
static int start_utf8_sequence(char *input, size_t input_size,
|
||||
tnt_input_utf8_state_t *state,
|
||||
unsigned char b, bool paste_mode) {
|
||||
int expected = utf8_byte_length(b);
|
||||
|
||||
if (expected <= 1 || expected > 4) {
|
||||
tnt_input_utf8_state_reset(state);
|
||||
return TNT_INPUT_APPEND_INVALID_UTF8;
|
||||
}
|
||||
|
||||
state->bytes[0] = (char)b;
|
||||
state->len = 1;
|
||||
state->expected_len = expected;
|
||||
|
||||
if (expected == 1) {
|
||||
int status = tnt_input_append_utf8_sequence(input, input_size,
|
||||
state->bytes, 1);
|
||||
tnt_input_utf8_state_reset(state);
|
||||
return status;
|
||||
}
|
||||
|
||||
(void)paste_mode;
|
||||
return TNT_INPUT_APPEND_OK;
|
||||
}
|
||||
|
||||
static int append_printable_byte(char *input, size_t input_size,
|
||||
tnt_input_utf8_state_t *state,
|
||||
unsigned char b, bool paste_mode) {
|
||||
int status = TNT_INPUT_APPEND_OK;
|
||||
|
||||
if (b < 128) {
|
||||
if (state && state->len > 0) {
|
||||
tnt_input_utf8_state_reset(state);
|
||||
status |= TNT_INPUT_APPEND_INVALID_UTF8;
|
||||
}
|
||||
status |= tnt_input_append_ascii(input, input_size, b);
|
||||
return status;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return TNT_INPUT_APPEND_INVALID_UTF8;
|
||||
}
|
||||
|
||||
if (state->len == 0) {
|
||||
return start_utf8_sequence(input, input_size, state, b, paste_mode);
|
||||
}
|
||||
|
||||
if ((b & 0xC0) != 0x80) {
|
||||
tnt_input_utf8_state_reset(state);
|
||||
status |= TNT_INPUT_APPEND_INVALID_UTF8;
|
||||
status |= append_printable_byte(input, input_size, state, b,
|
||||
paste_mode);
|
||||
return status;
|
||||
}
|
||||
|
||||
state->bytes[state->len++] = (char)b;
|
||||
if (state->len == state->expected_len) {
|
||||
status |= tnt_input_append_utf8_sequence(input, input_size,
|
||||
state->bytes, state->len);
|
||||
tnt_input_utf8_state_reset(state);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
int tnt_input_append_stream_byte(char *input, size_t input_size,
|
||||
tnt_input_utf8_state_t *state,
|
||||
unsigned char b, bool paste_mode) {
|
||||
int status = TNT_INPUT_APPEND_OK;
|
||||
|
||||
if (paste_mode && (b == '\r' || b == '\n' || b == '\t')) {
|
||||
b = ' ';
|
||||
}
|
||||
|
||||
if (b < 32) {
|
||||
if (state && state->len > 0) {
|
||||
tnt_input_utf8_state_reset(state);
|
||||
status |= TNT_INPUT_APPEND_INVALID_UTF8;
|
||||
}
|
||||
return status | TNT_INPUT_APPEND_IGNORED;
|
||||
}
|
||||
|
||||
return status | append_printable_byte(input, input_size, state, b,
|
||||
paste_mode);
|
||||
}
|
||||
|
||||
int tnt_input_utf8_state_finish(tnt_input_utf8_state_t *state) {
|
||||
if (!state || state->len == 0) {
|
||||
return TNT_INPUT_APPEND_OK;
|
||||
}
|
||||
|
||||
tnt_input_utf8_state_reset(state);
|
||||
return TNT_INPUT_APPEND_INVALID_UTF8;
|
||||
}
|
||||
343
src/json_text.c
Normal file
343
src/json_text.c
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
#include "json_text.h"
|
||||
|
||||
#include <ctype.h>
|
||||
|
||||
void tnt_json_append_string(char *buffer, size_t buf_size, size_t *pos,
|
||||
const char *text) {
|
||||
const unsigned char *p = (const unsigned char *)(text ? text : "");
|
||||
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
|
||||
|
||||
while (*p && pos && *pos < buf_size - 1) {
|
||||
char escaped[7];
|
||||
|
||||
switch (*p) {
|
||||
case '\\':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2);
|
||||
break;
|
||||
case '"':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2);
|
||||
break;
|
||||
case '\b':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\b", 2);
|
||||
break;
|
||||
case '\f':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\f", 2);
|
||||
break;
|
||||
case '\n':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\n", 2);
|
||||
break;
|
||||
case '\r':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\r", 2);
|
||||
break;
|
||||
case '\t':
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\\t", 2);
|
||||
break;
|
||||
default:
|
||||
if (*p < 0x20) {
|
||||
snprintf(escaped, sizeof(escaped), "\\u%04x", *p);
|
||||
buffer_append_bytes(buffer, buf_size, pos,
|
||||
escaped, strlen(escaped));
|
||||
} else {
|
||||
buffer_append_bytes(buffer, buf_size, pos,
|
||||
(const char *)p, 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
|
||||
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
|
||||
}
|
||||
|
||||
static const char *skip_ws(const char *p) {
|
||||
while (p && isspace((unsigned char)*p)) {
|
||||
p++;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
static int hex_value(char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static bool parse_hex4(const char *p, uint32_t *out) {
|
||||
uint32_t cp = 0;
|
||||
|
||||
if (!p || !out) return false;
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int v = hex_value(p[i]);
|
||||
if (v < 0) return false;
|
||||
cp = (cp << 4) | (uint32_t)v;
|
||||
}
|
||||
|
||||
*out = cp;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool append_decoded_codepoint(char *out, size_t out_size,
|
||||
size_t *pos, uint32_t cp) {
|
||||
char bytes[4];
|
||||
size_t len;
|
||||
|
||||
if (!out || !pos || out_size == 0 || cp == 0 ||
|
||||
cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cp <= 0x7F) {
|
||||
bytes[0] = (char)cp;
|
||||
len = 1;
|
||||
} else if (cp <= 0x7FF) {
|
||||
bytes[0] = (char)(0xC0 | (cp >> 6));
|
||||
bytes[1] = (char)(0x80 | (cp & 0x3F));
|
||||
len = 2;
|
||||
} else if (cp <= 0xFFFF) {
|
||||
bytes[0] = (char)(0xE0 | (cp >> 12));
|
||||
bytes[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
bytes[2] = (char)(0x80 | (cp & 0x3F));
|
||||
len = 3;
|
||||
} else {
|
||||
bytes[0] = (char)(0xF0 | (cp >> 18));
|
||||
bytes[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
|
||||
bytes[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
bytes[3] = (char)(0x80 | (cp & 0x3F));
|
||||
len = 4;
|
||||
}
|
||||
|
||||
if (*pos + len >= out_size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(out + *pos, bytes, len);
|
||||
*pos += len;
|
||||
out[*pos] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool append_byte(char *out, size_t out_size, size_t *pos, char c) {
|
||||
if (!out || !pos || out_size == 0 || *pos + 1 >= out_size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out[(*pos)++] = c;
|
||||
out[*pos] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parse_json_string(const char **cursor, char *out,
|
||||
size_t out_size) {
|
||||
const char *p;
|
||||
size_t pos = 0;
|
||||
|
||||
if (!cursor || !*cursor || **cursor != '"' || !out || out_size == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out[0] = '\0';
|
||||
p = *cursor + 1;
|
||||
|
||||
while (*p) {
|
||||
unsigned char c = (unsigned char)*p++;
|
||||
|
||||
if (c == '"') {
|
||||
*cursor = p;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (c < 0x20) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c != '\\') {
|
||||
if (!append_byte(out, out_size, &pos, (char)c)) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
char esc = *p++;
|
||||
switch (esc) {
|
||||
case '"':
|
||||
case '\\':
|
||||
case '/':
|
||||
if (!append_byte(out, out_size, &pos, esc)) return false;
|
||||
break;
|
||||
case 'b':
|
||||
if (!append_byte(out, out_size, &pos, '\b')) return false;
|
||||
break;
|
||||
case 'f':
|
||||
if (!append_byte(out, out_size, &pos, '\f')) return false;
|
||||
break;
|
||||
case 'n':
|
||||
if (!append_byte(out, out_size, &pos, '\n')) return false;
|
||||
break;
|
||||
case 'r':
|
||||
if (!append_byte(out, out_size, &pos, '\r')) return false;
|
||||
break;
|
||||
case 't':
|
||||
if (!append_byte(out, out_size, &pos, '\t')) return false;
|
||||
break;
|
||||
case 'u': {
|
||||
uint32_t cp;
|
||||
if (!parse_hex4(p, &cp)) return false;
|
||||
p += 4;
|
||||
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF) {
|
||||
uint32_t low;
|
||||
if (p[0] != '\\' || p[1] != 'u' ||
|
||||
!parse_hex4(p + 2, &low) ||
|
||||
low < 0xDC00 || low > 0xDFFF) {
|
||||
return false;
|
||||
}
|
||||
p += 6;
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
|
||||
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!append_decoded_codepoint(out, out_size, &pos, cp)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool skip_json_string(const char **cursor) {
|
||||
const char *p;
|
||||
|
||||
if (!cursor || !*cursor || **cursor != '"') {
|
||||
return false;
|
||||
}
|
||||
|
||||
p = *cursor + 1;
|
||||
while (*p) {
|
||||
unsigned char c = (unsigned char)*p++;
|
||||
if (c == '"') {
|
||||
*cursor = p;
|
||||
return true;
|
||||
}
|
||||
if (c < 0x20) return false;
|
||||
if (c == '\\') {
|
||||
if (*p == 'u') {
|
||||
uint32_t ignored;
|
||||
if (!parse_hex4(p + 1, &ignored)) return false;
|
||||
p += 5;
|
||||
} else if (*p) {
|
||||
p++;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool skip_json_value(const char **cursor) {
|
||||
const char *p;
|
||||
|
||||
if (!cursor || !*cursor) return false;
|
||||
p = skip_ws(*cursor);
|
||||
|
||||
if (*p == '"') {
|
||||
if (!skip_json_string(&p)) return false;
|
||||
*cursor = p;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (*p == '{' || *p == '[') {
|
||||
int depth = 0;
|
||||
do {
|
||||
if (*p == '"' && !skip_json_string(&p)) {
|
||||
return false;
|
||||
}
|
||||
if (*p == '{' || *p == '[') {
|
||||
depth++;
|
||||
p++;
|
||||
} else if (*p == '}' || *p == ']') {
|
||||
depth--;
|
||||
p++;
|
||||
} else if (*p) {
|
||||
p++;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} while (depth > 0);
|
||||
|
||||
*cursor = p;
|
||||
return true;
|
||||
}
|
||||
|
||||
while (*p && *p != ',' && *p != '}' && *p != ']') {
|
||||
p++;
|
||||
}
|
||||
|
||||
*cursor = p;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tnt_json_get_string_field(const char *json, const char *key,
|
||||
char *out, size_t out_size) {
|
||||
const char *p;
|
||||
bool found = false;
|
||||
|
||||
if (!json || !key || key[0] == '\0' || !out || out_size == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out[0] = '\0';
|
||||
p = skip_ws(json);
|
||||
if (*p != '{') {
|
||||
return false;
|
||||
}
|
||||
p++;
|
||||
|
||||
while (1) {
|
||||
char parsed_key[128];
|
||||
|
||||
p = skip_ws(p);
|
||||
if (*p == '}') {
|
||||
return false;
|
||||
}
|
||||
if (*p != '"' || !parse_json_string(&p, parsed_key,
|
||||
sizeof(parsed_key))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
p = skip_ws(p);
|
||||
if (*p != ':') {
|
||||
return false;
|
||||
}
|
||||
p = skip_ws(p + 1);
|
||||
|
||||
if (strcmp(parsed_key, key) == 0) {
|
||||
if (*p != '"' || !parse_json_string(&p, out, out_size)) {
|
||||
return false;
|
||||
}
|
||||
found = true;
|
||||
} else if (!skip_json_value(&p)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
p = skip_ws(p);
|
||||
if (*p == ',') {
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
if (*p == '}') {
|
||||
return found;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
223
src/main.c
223
src/main.c
|
|
@ -1,8 +1,11 @@
|
|||
#include "chat_room.h"
|
||||
#include "cli_text.h"
|
||||
#include "config_defaults.h"
|
||||
#include "common.h"
|
||||
#include "i18n.h"
|
||||
#include "message.h"
|
||||
#include "message_log_tool.h"
|
||||
#include "module_runtime.h"
|
||||
#include "ssh_server.h"
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
|
|
@ -18,57 +21,212 @@ static void signal_handler(int sig) {
|
|||
_exit(0);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int port = DEFAULT_PORT;
|
||||
help_lang_t lang = i18n_default_lang();
|
||||
static bool is_config_token(const char *value) {
|
||||
const unsigned char *p = (const unsigned char *)value;
|
||||
|
||||
/* Environment provides defaults; command-line flags override it. */
|
||||
const char *port_env = getenv("PORT");
|
||||
if (port_env && port_env[0] != '\0') {
|
||||
char *end;
|
||||
long val = strtol(port_env, &end, 10);
|
||||
if (*end == '\0' && val > 0 && val <= 65535) {
|
||||
port = (int)val;
|
||||
}
|
||||
if (!value || value[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
while (*p) {
|
||||
if (*p <= 32 || *p == 127) {
|
||||
return false;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static int set_env_option(const char *name, const char *value) {
|
||||
if (setenv(name, value, 1) != 0) {
|
||||
perror(name);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int set_numeric_env_option(const tnt_int_config_spec_t *spec,
|
||||
const char *opt_name, const char *value,
|
||||
ui_lang_t lang) {
|
||||
int parsed;
|
||||
|
||||
if (!tnt_config_parse_int(value, spec, &parsed)) {
|
||||
fprintf(stderr, cli_text_invalid_value_format(lang), opt_name, value);
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
if (set_env_option(spec->env_name, value) != 0) {
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
return TNT_EXIT_OK;
|
||||
}
|
||||
|
||||
static bool require_option_arg(int argc, char **argv, int index,
|
||||
ui_lang_t lang) {
|
||||
if (index + 1 >= argc || argv[index + 1][0] == '\0') {
|
||||
fprintf(stderr, cli_text_option_requires_arg_format(lang),
|
||||
argv[index]);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int port = tnt_config_env_int(&TNT_CONFIG_PORT);
|
||||
ui_lang_t lang = i18n_default_ui_lang();
|
||||
const char *log_check_path = NULL;
|
||||
const char *log_recover_path = NULL;
|
||||
|
||||
/* Parse command line arguments */
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) &&
|
||||
i + 1 < argc) {
|
||||
char *end;
|
||||
long val = strtol(argv[i + 1], &end, 10);
|
||||
if (*end != '\0' || val <= 0 || val > 65535) {
|
||||
if (strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) {
|
||||
int val;
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
if (!tnt_config_parse_int(argv[i + 1], &TNT_CONFIG_PORT, &val)) {
|
||||
fprintf(stderr, cli_text_invalid_port_format(lang),
|
||||
argv[i + 1]);
|
||||
return 1;
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
port = (int)val;
|
||||
port = val;
|
||||
i++;
|
||||
} else if ((strcmp(argv[i], "-d") == 0 ||
|
||||
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) {
|
||||
if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) {
|
||||
perror("setenv TNT_STATE_DIR");
|
||||
return 1;
|
||||
} else if (strcmp(argv[i], "-d") == 0 ||
|
||||
strcmp(argv[i], "--state-dir") == 0) {
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
if (set_env_option("TNT_STATE_DIR", argv[i + 1]) != 0) {
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "--bind") == 0) {
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
if (!is_config_token(argv[i + 1])) {
|
||||
fprintf(stderr, cli_text_invalid_value_format(lang),
|
||||
argv[i], argv[i + 1]);
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
if (set_env_option("TNT_BIND_ADDR", argv[i + 1]) != 0) {
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "--public-host") == 0) {
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
if (!is_config_token(argv[i + 1])) {
|
||||
fprintf(stderr, cli_text_invalid_value_format(lang),
|
||||
argv[i], argv[i + 1]);
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
if (set_env_option("TNT_PUBLIC_HOST", argv[i + 1]) != 0) {
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "--max-connections") == 0) {
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONNECTIONS,
|
||||
argv[i], argv[i + 1], lang);
|
||||
if (rc != TNT_EXIT_OK) {
|
||||
return rc;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "--max-conn-per-ip") == 0) {
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_PER_IP,
|
||||
argv[i], argv[i + 1], lang);
|
||||
if (rc != TNT_EXIT_OK) {
|
||||
return rc;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "--max-conn-rate-per-ip") == 0) {
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_RATE_PER_IP,
|
||||
argv[i], argv[i + 1], lang);
|
||||
if (rc != TNT_EXIT_OK) {
|
||||
return rc;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "--rate-limit") == 0) {
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
int rc = set_numeric_env_option(&TNT_CONFIG_RATE_LIMIT, argv[i],
|
||||
argv[i + 1], lang);
|
||||
if (rc != TNT_EXIT_OK) {
|
||||
return rc;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "--idle-timeout") == 0) {
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
int rc = set_numeric_env_option(&TNT_CONFIG_IDLE_TIMEOUT, argv[i],
|
||||
argv[i + 1], lang);
|
||||
if (rc != TNT_EXIT_OK) {
|
||||
return rc;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "--ssh-log-level") == 0) {
|
||||
if (!require_option_arg(argc, argv, i, lang)) {
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
int rc = set_numeric_env_option(&TNT_CONFIG_SSH_LOG_LEVEL,
|
||||
argv[i], argv[i + 1], lang);
|
||||
if (rc != TNT_EXIT_OK) {
|
||||
return rc;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "--log-check") == 0) {
|
||||
if (i + 1 >= argc || argv[i + 1][0] == '\0') {
|
||||
fprintf(stderr, cli_text_option_requires_arg_format(lang),
|
||||
argv[i]);
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
log_check_path = argv[++i];
|
||||
} else if (strcmp(argv[i], "--log-recover") == 0) {
|
||||
if (i + 1 >= argc || argv[i + 1][0] == '\0') {
|
||||
fprintf(stderr, cli_text_option_requires_arg_format(lang),
|
||||
argv[i]);
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
log_recover_path = argv[++i];
|
||||
} else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
|
||||
printf("tnt %s\n", TNT_VERSION);
|
||||
return 0;
|
||||
return TNT_EXIT_OK;
|
||||
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
||||
char output[2048] = {0};
|
||||
size_t pos = 0;
|
||||
|
||||
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
|
||||
fputs(output, stdout);
|
||||
return 0;
|
||||
return TNT_EXIT_OK;
|
||||
} else {
|
||||
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
|
||||
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
|
||||
return 1;
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
}
|
||||
|
||||
if (log_check_path && log_recover_path) {
|
||||
fprintf(stderr, cli_text_invalid_value_format(lang),
|
||||
"--log-check", "--log-recover");
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
if (log_check_path) {
|
||||
return message_log_tool_check(log_check_path);
|
||||
}
|
||||
if (log_recover_path) {
|
||||
return message_log_tool_recover(log_recover_path);
|
||||
}
|
||||
|
||||
/* Setup signal handlers */
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
|
|
@ -77,28 +235,35 @@ int main(int argc, char **argv) {
|
|||
/* Initialize subsystems */
|
||||
if (tnt_ensure_state_dir() < 0) {
|
||||
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
message_init();
|
||||
if (tnt_module_runtime_init() < 0) {
|
||||
fprintf(stderr, "Failed to initialize module runtime\n");
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
/* Create chat room */
|
||||
g_room = room_create();
|
||||
if (!g_room) {
|
||||
fprintf(stderr, "Failed to create chat room\n");
|
||||
return 1;
|
||||
tnt_module_runtime_shutdown();
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
/* Initialize server */
|
||||
if (ssh_server_init(port) < 0) {
|
||||
fprintf(stderr, "Failed to initialize server\n");
|
||||
tnt_module_runtime_shutdown();
|
||||
room_destroy(g_room);
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
/* Start server (blocking) */
|
||||
int ret = ssh_server_start(0);
|
||||
|
||||
tnt_module_runtime_shutdown();
|
||||
room_destroy(g_room);
|
||||
return ret;
|
||||
}
|
||||
|
|
|
|||
9
src/manual.c
Normal file
9
src/manual.c
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#include "manual.h"
|
||||
#include "manual_text.h"
|
||||
|
||||
void manual_append_interactive_panel(char *buffer, size_t buf_size,
|
||||
size_t *pos, ui_lang_t lang) {
|
||||
if (!buffer || !pos) return;
|
||||
|
||||
manual_text_append_interactive(buffer, buf_size, pos, lang);
|
||||
}
|
||||
50
src/manual_text.c
Normal file
50
src/manual_text.c
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#include "manual_text.h"
|
||||
|
||||
#include "command_catalog.h"
|
||||
#include "i18n.h"
|
||||
|
||||
void manual_text_append_interactive(char *buffer, size_t buf_size,
|
||||
size_t *pos, ui_lang_t lang) {
|
||||
static const i18n_string_t intro = I18N_STRING(
|
||||
"\033[1;36mTNT(1) help\033[0m\n"
|
||||
"\n"
|
||||
"\033[1;37mName\033[0m\n"
|
||||
" TNT - SSH terminal chat room\n"
|
||||
"\n"
|
||||
"\033[1;37mUse\033[0m\n"
|
||||
" Type, Enter sends; Up/Down recalls; Tab completes @mentions\n"
|
||||
" Esc browses; / searches; G latest; i/a/o types; : commands; ? keys\n"
|
||||
"\n"
|
||||
"\033[1;37mCommands\033[0m\n",
|
||||
"\033[1;36mTNT(1) 帮助\033[0m\n"
|
||||
"\n"
|
||||
"\033[1;37m名称\033[0m\n"
|
||||
" TNT - SSH 终端聊天室\n"
|
||||
"\n"
|
||||
"\033[1;37m使用\033[0m\n"
|
||||
" 输入并 Enter 发送;Up/Down 调出消息;Tab 补全 @mention\n"
|
||||
" Esc 浏览;/ 搜索;G 最新;i/a/o 输入;: 命令;? 按键\n"
|
||||
"\n"
|
||||
"\033[1;37m命令\033[0m\n"
|
||||
);
|
||||
static const i18n_string_t outro = I18N_STRING(
|
||||
"\n"
|
||||
"\033[1;37mLanguage\033[0m\n"
|
||||
" :lang show current language\n"
|
||||
" :lang en|zh switch language\n"
|
||||
"\n"
|
||||
"\033[1;37mSee also\033[0m\n"
|
||||
" ? full key reference\n",
|
||||
"\n"
|
||||
"\033[1;37m语言\033[0m\n"
|
||||
" :lang 显示当前语言\n"
|
||||
" :lang en|zh 切换语言\n"
|
||||
"\n"
|
||||
"\033[1;37m参见\033[0m\n"
|
||||
" ? 完整按键参考\n"
|
||||
);
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", i18n_string(intro, lang));
|
||||
command_catalog_append_manual(buffer, buf_size, pos, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", i18n_string(outro, lang));
|
||||
}
|
||||
285
src/message.c
285
src/message.c
|
|
@ -1,29 +1,63 @@
|
|||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE /* for timegm() on glibc */
|
||||
#define _DEFAULT_SOURCE /* for strcasestr() on glibc */
|
||||
#endif
|
||||
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
|
||||
#define _DARWIN_C_SOURCE /* for timegm() on macOS */
|
||||
#define _DARWIN_C_SOURCE /* for strcasestr() on macOS */
|
||||
#endif
|
||||
|
||||
#include "message.h"
|
||||
#include "message_log.h"
|
||||
#include "utf8.h"
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
static pthread_mutex_t g_message_file_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static time_t parse_rfc3339_utc(const char *timestamp_str) {
|
||||
struct tm tm = {0};
|
||||
static void discard_line_remainder(FILE *fp) {
|
||||
int c;
|
||||
|
||||
if (!timestamp_str) {
|
||||
return (time_t)-1;
|
||||
while ((c = fgetc(fp)) != '\n' && c != EOF) {
|
||||
}
|
||||
}
|
||||
|
||||
static int append_dump_record(char **output, size_t *capacity,
|
||||
size_t *len, const message_t *msg) {
|
||||
size_t needed;
|
||||
size_t available;
|
||||
|
||||
if (!output || !capacity || !len || !msg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm);
|
||||
if (!result || *result != '\0') {
|
||||
return (time_t)-1;
|
||||
if (message_log_format_record(msg, NULL, 0, &needed) < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return timegm(&tm);
|
||||
available = *capacity > *len ? *capacity - *len : 0;
|
||||
if (needed + 1 > available) {
|
||||
size_t new_capacity = *capacity ? *capacity : 1024;
|
||||
while (needed + 1 > new_capacity - *len) {
|
||||
if (new_capacity > SIZE_MAX / 2) {
|
||||
return -1;
|
||||
}
|
||||
new_capacity *= 2;
|
||||
}
|
||||
|
||||
char *grown = realloc(*output, new_capacity);
|
||||
if (!grown) {
|
||||
return -1;
|
||||
}
|
||||
*output = grown;
|
||||
*capacity = new_capacity;
|
||||
}
|
||||
|
||||
if (message_log_format_record(msg, *output + *len, *capacity - *len,
|
||||
NULL) < 0) {
|
||||
return -1;
|
||||
}
|
||||
*len += needed;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Initialize message subsystem */
|
||||
|
|
@ -118,67 +152,25 @@ int message_load(message_t **messages, int max_messages) {
|
|||
fseek(fp, 0, SEEK_SET);
|
||||
|
||||
read_messages:;
|
||||
char line[2048];
|
||||
char line[MESSAGE_LOG_MAX_LINE];
|
||||
int count = 0;
|
||||
time_t now = time(NULL);
|
||||
|
||||
/* Now read forward */
|
||||
while (fgets(line, sizeof(line), fp) && count < max_messages) {
|
||||
/* Check for oversized lines */
|
||||
size_t line_len = strlen(line);
|
||||
if (line_len >= sizeof(line) - 1) {
|
||||
/* Skip remainder of line */
|
||||
int c;
|
||||
while ((c = fgetc(fp)) != '\n' && c != EOF);
|
||||
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
|
||||
discard_line_remainder(fp);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Format: RFC3339_timestamp|username|content */
|
||||
char line_copy[2048];
|
||||
strncpy(line_copy, line, sizeof(line_copy) - 1);
|
||||
line_copy[sizeof(line_copy) - 1] = '\0';
|
||||
|
||||
char *timestamp_str = strtok(line_copy, "|");
|
||||
char *username = strtok(NULL, "|");
|
||||
char *content = strtok(NULL, "\n");
|
||||
|
||||
/* Validate all fields exist and are non-empty */
|
||||
if (!timestamp_str || !username || !content) {
|
||||
continue;
|
||||
}
|
||||
if (username[0] == '\0') {
|
||||
message_t parsed;
|
||||
if (!message_log_parse_record(line, &parsed, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Validate field lengths */
|
||||
if (strlen(username) >= MAX_USERNAME_LEN) {
|
||||
continue;
|
||||
}
|
||||
if (strlen(content) >= MAX_MESSAGE_LEN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Parse strict UTC RFC3339 timestamp */
|
||||
time_t msg_time = parse_rfc3339_utc(timestamp_str);
|
||||
if (msg_time == (time_t)-1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Validate timestamp is reasonable (not in far future or past) */
|
||||
time_t now = time(NULL);
|
||||
if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) {
|
||||
continue;
|
||||
}
|
||||
|
||||
msg_array[count].timestamp = msg_time;
|
||||
strncpy(msg_array[count].username, username, MAX_USERNAME_LEN - 1);
|
||||
msg_array[count].username[MAX_USERNAME_LEN - 1] = '\0';
|
||||
strncpy(msg_array[count].content, content, MAX_MESSAGE_LEN - 1);
|
||||
msg_array[count].content[MAX_MESSAGE_LEN - 1] = '\0';
|
||||
count++;
|
||||
msg_array[count++] = parsed;
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
|
|
@ -190,6 +182,9 @@ read_messages:;
|
|||
/* Save a message to log file */
|
||||
int message_save(const message_t *msg) {
|
||||
char log_path[PATH_MAX];
|
||||
message_t safe_msg;
|
||||
char record[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48];
|
||||
size_t record_len = 0;
|
||||
int rc = 0;
|
||||
|
||||
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
|
||||
|
|
@ -204,36 +199,29 @@ int message_save(const message_t *msg) {
|
|||
return -1;
|
||||
}
|
||||
|
||||
/* Format timestamp as RFC3339 */
|
||||
char timestamp[64];
|
||||
struct tm tm_info;
|
||||
gmtime_r(&msg->timestamp, &tm_info);
|
||||
strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &tm_info);
|
||||
|
||||
/* Sanitize username and content to prevent log injection */
|
||||
char safe_username[MAX_USERNAME_LEN];
|
||||
char safe_content[MAX_MESSAGE_LEN];
|
||||
safe_msg.timestamp = msg->timestamp;
|
||||
strncpy(safe_msg.username, msg->username, sizeof(safe_msg.username) - 1);
|
||||
safe_msg.username[sizeof(safe_msg.username) - 1] = '\0';
|
||||
|
||||
strncpy(safe_username, msg->username, sizeof(safe_username) - 1);
|
||||
safe_username[sizeof(safe_username) - 1] = '\0';
|
||||
|
||||
strncpy(safe_content, msg->content, sizeof(safe_content) - 1);
|
||||
safe_content[sizeof(safe_content) - 1] = '\0';
|
||||
strncpy(safe_msg.content, msg->content, sizeof(safe_msg.content) - 1);
|
||||
safe_msg.content[sizeof(safe_msg.content) - 1] = '\0';
|
||||
|
||||
/* Replace pipe characters and newlines to prevent log format corruption */
|
||||
for (char *p = safe_username; *p; p++) {
|
||||
for (char *p = safe_msg.username; *p; p++) {
|
||||
if (*p == '|' || *p == '\n' || *p == '\r') {
|
||||
*p = '_';
|
||||
}
|
||||
}
|
||||
for (char *p = safe_content; *p; p++) {
|
||||
for (char *p = safe_msg.content; *p; p++) {
|
||||
if (*p == '|' || *p == '\n' || *p == '\r') {
|
||||
*p = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
/* Write to file: timestamp|username|content */
|
||||
if (fprintf(fp, "%s|%s|%s\n", timestamp, safe_username, safe_content) < 0 ||
|
||||
if (message_log_format_record(&safe_msg, record, sizeof(record),
|
||||
&record_len) < 0 ||
|
||||
fwrite(record, 1, record_len, fp) != record_len ||
|
||||
fflush(fp) != 0) {
|
||||
rc = -1;
|
||||
}
|
||||
|
|
@ -274,40 +262,21 @@ int message_search(const char *query, message_t **results, int max_results) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
char line[2048];
|
||||
char line[MESSAGE_LOG_MAX_LINE];
|
||||
int count = 0;
|
||||
time_t now = time(NULL);
|
||||
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
size_t line_len = strlen(line);
|
||||
if (line_len >= sizeof(line) - 1) {
|
||||
int c;
|
||||
while ((c = fgetc(fp)) != '\n' && c != EOF);
|
||||
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
|
||||
discard_line_remainder(fp);
|
||||
continue;
|
||||
}
|
||||
|
||||
char line_copy[2048];
|
||||
strncpy(line_copy, line, sizeof(line_copy) - 1);
|
||||
line_copy[sizeof(line_copy) - 1] = '\0';
|
||||
|
||||
char *timestamp_str = strtok(line_copy, "|");
|
||||
char *username = strtok(NULL, "|");
|
||||
char *content = strtok(NULL, "\n");
|
||||
|
||||
if (!timestamp_str || !username || !content || username[0] == '\0') continue;
|
||||
if (strlen(username) >= MAX_USERNAME_LEN || strlen(content) >= MAX_MESSAGE_LEN) continue;
|
||||
if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) continue;
|
||||
|
||||
if (strcasestr(username, query) == NULL && strcasestr(content, query) == NULL) continue;
|
||||
|
||||
time_t msg_time = parse_rfc3339_utc(timestamp_str);
|
||||
if (msg_time == (time_t)-1) continue;
|
||||
|
||||
message_t m;
|
||||
m.timestamp = msg_time;
|
||||
strncpy(m.username, username, MAX_USERNAME_LEN - 1);
|
||||
m.username[MAX_USERNAME_LEN - 1] = '\0';
|
||||
strncpy(m.content, content, MAX_MESSAGE_LEN - 1);
|
||||
m.content[MAX_MESSAGE_LEN - 1] = '\0';
|
||||
if (!message_log_parse_record(line, &m, now)) continue;
|
||||
if (strcasestr(m.username, query) == NULL &&
|
||||
strcasestr(m.content, query) == NULL) continue;
|
||||
|
||||
if (count < max_results) {
|
||||
res[count++] = m;
|
||||
|
|
@ -324,6 +293,118 @@ int message_search(const char *query, message_t **results, int max_results) {
|
|||
return (count < max_results) ? count : max_results;
|
||||
}
|
||||
|
||||
int message_dump_text(char **output, size_t *output_len, int max_records) {
|
||||
char log_path[PATH_MAX];
|
||||
char *buf = NULL;
|
||||
size_t capacity = 0;
|
||||
size_t len = 0;
|
||||
message_t *ring = NULL;
|
||||
int seen = 0;
|
||||
int rc = 0;
|
||||
|
||||
if (!output || !output_len || max_records < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
*output = calloc(1, 1);
|
||||
if (!*output) {
|
||||
return -1;
|
||||
}
|
||||
*output_len = 0;
|
||||
|
||||
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
|
||||
free(*output);
|
||||
*output = NULL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (max_records > 0) {
|
||||
ring = calloc((size_t)max_records, sizeof(*ring));
|
||||
if (!ring) {
|
||||
free(*output);
|
||||
*output = NULL;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_message_file_lock);
|
||||
FILE *fp = fopen(log_path, "r");
|
||||
if (!fp) {
|
||||
int saved_errno = errno;
|
||||
pthread_mutex_unlock(&g_message_file_lock);
|
||||
free(ring);
|
||||
if (saved_errno != ENOENT) {
|
||||
free(*output);
|
||||
*output = NULL;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
char line[MESSAGE_LOG_MAX_LINE];
|
||||
time_t now = time(NULL);
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
size_t line_len = strlen(line);
|
||||
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
|
||||
discard_line_remainder(fp);
|
||||
continue;
|
||||
}
|
||||
|
||||
message_t parsed;
|
||||
if (!message_log_parse_record(line, &parsed, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (max_records > 0) {
|
||||
ring[seen % max_records] = parsed;
|
||||
seen++;
|
||||
} else if (append_dump_record(output, &capacity, output_len,
|
||||
&parsed) < 0) {
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
pthread_mutex_unlock(&g_message_file_lock);
|
||||
|
||||
if (rc == 0 && max_records > 0 && seen > 0) {
|
||||
int count = seen < max_records ? seen : max_records;
|
||||
int start = seen < max_records ? 0 : seen % max_records;
|
||||
|
||||
free(*output);
|
||||
*output = NULL;
|
||||
*output_len = 0;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
message_t *msg = &ring[(start + i) % max_records];
|
||||
if (append_dump_record(&buf, &capacity, &len, msg) < 0) {
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rc == 0) {
|
||||
*output = buf ? buf : calloc(1, 1);
|
||||
*output_len = len;
|
||||
if (!*output) {
|
||||
rc = -1;
|
||||
}
|
||||
} else {
|
||||
free(buf);
|
||||
}
|
||||
}
|
||||
|
||||
free(ring);
|
||||
if (rc < 0) {
|
||||
free(*output);
|
||||
*output = NULL;
|
||||
*output_len = 0;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Format a message for display */
|
||||
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) {
|
||||
struct tm tm_info;
|
||||
|
|
|
|||
129
src/message_log.c
Normal file
129
src/message_log.c
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE /* for timegm() on glibc */
|
||||
#endif
|
||||
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
|
||||
#define _DARWIN_C_SOURCE /* for timegm() on macOS */
|
||||
#endif
|
||||
|
||||
#include "message_log.h"
|
||||
#include "utf8.h"
|
||||
|
||||
static time_t parse_rfc3339_utc(const char *timestamp_str) {
|
||||
struct tm tm = {0};
|
||||
|
||||
if (!timestamp_str) {
|
||||
return (time_t)-1;
|
||||
}
|
||||
|
||||
char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm);
|
||||
if (!result || *result != '\0') {
|
||||
return (time_t)-1;
|
||||
}
|
||||
|
||||
return timegm(&tm);
|
||||
}
|
||||
|
||||
void message_log_format_timestamp_utc(time_t ts, char *buffer,
|
||||
size_t buf_size) {
|
||||
struct tm tm_info;
|
||||
|
||||
if (!buffer || buf_size == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
gmtime_r(&ts, &tm_info);
|
||||
strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info);
|
||||
}
|
||||
|
||||
bool message_log_parse_record(const char *line, message_t *out, time_t now) {
|
||||
char line_copy[MESSAGE_LOG_MAX_LINE];
|
||||
char *first_sep;
|
||||
char *second_sep;
|
||||
char *timestamp_str;
|
||||
char *username;
|
||||
char *content;
|
||||
time_t msg_time;
|
||||
size_t line_len;
|
||||
|
||||
if (!line || !out) {
|
||||
return false;
|
||||
}
|
||||
|
||||
line_len = strlen(line);
|
||||
if (line_len == 0 || line[line_len - 1] != '\n') {
|
||||
return false;
|
||||
}
|
||||
if (line_len >= sizeof(line_copy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(line_copy, line, line_len + 1);
|
||||
line_copy[line_len - 1] = '\0';
|
||||
|
||||
first_sep = strchr(line_copy, '|');
|
||||
if (!first_sep) {
|
||||
return false;
|
||||
}
|
||||
second_sep = strchr(first_sep + 1, '|');
|
||||
if (!second_sep || strchr(second_sep + 1, '|')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*first_sep = '\0';
|
||||
*second_sep = '\0';
|
||||
timestamp_str = line_copy;
|
||||
username = first_sep + 1;
|
||||
content = second_sep + 1;
|
||||
|
||||
if (timestamp_str[0] == '\0' || username[0] == '\0' ||
|
||||
content[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (strlen(username) >= MAX_USERNAME_LEN ||
|
||||
strlen(content) >= MAX_MESSAGE_LEN) {
|
||||
return false;
|
||||
}
|
||||
if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
msg_time = parse_rfc3339_utc(timestamp_str);
|
||||
if (msg_time == (time_t)-1) {
|
||||
return false;
|
||||
}
|
||||
if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out->timestamp = msg_time;
|
||||
strncpy(out->username, username, MAX_USERNAME_LEN - 1);
|
||||
out->username[MAX_USERNAME_LEN - 1] = '\0';
|
||||
strncpy(out->content, content, MAX_MESSAGE_LEN - 1);
|
||||
out->content[MAX_MESSAGE_LEN - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
int message_log_format_record(const message_t *msg, char *buffer,
|
||||
size_t buf_size, size_t *record_len) {
|
||||
char timestamp[64];
|
||||
int needed;
|
||||
|
||||
if (!msg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
message_log_format_timestamp_utc(msg->timestamp, timestamp,
|
||||
sizeof(timestamp));
|
||||
needed = snprintf(buffer, buf_size, "%s|%s|%s\n", timestamp,
|
||||
msg->username, msg->content);
|
||||
if (needed < 0) {
|
||||
return -1;
|
||||
}
|
||||
if (record_len) {
|
||||
*record_len = (size_t)needed;
|
||||
}
|
||||
if (!buffer || buf_size == 0) {
|
||||
return 0;
|
||||
}
|
||||
return (size_t)needed < buf_size ? 0 : -1;
|
||||
}
|
||||
111
src/message_log_tool.c
Normal file
111
src/message_log_tool.c
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
#include "message_log_tool.h"
|
||||
|
||||
#include "message_log.h"
|
||||
|
||||
#include <errno.h>
|
||||
|
||||
typedef struct {
|
||||
long records_seen;
|
||||
long valid_records;
|
||||
long invalid_records;
|
||||
long first_invalid_line;
|
||||
} message_log_report_t;
|
||||
|
||||
static void discard_line_remainder(FILE *fp) {
|
||||
int c;
|
||||
|
||||
while ((c = fgetc(fp)) != '\n' && c != EOF) {
|
||||
}
|
||||
}
|
||||
|
||||
static int print_recovered_record(const message_t *msg) {
|
||||
char record[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48];
|
||||
size_t record_len = 0;
|
||||
|
||||
if (message_log_format_record(msg, record, sizeof(record),
|
||||
&record_len) < 0) {
|
||||
return -1;
|
||||
}
|
||||
return fwrite(record, 1, record_len, stdout) == record_len ? 0 : -1;
|
||||
}
|
||||
|
||||
static void print_report(FILE *stream, const char *path,
|
||||
const message_log_report_t *report) {
|
||||
fprintf(stream,
|
||||
"path %s\n"
|
||||
"records_seen %ld\n"
|
||||
"valid_records %ld\n"
|
||||
"invalid_records %ld\n"
|
||||
"first_invalid_line %ld\n",
|
||||
path,
|
||||
report->records_seen,
|
||||
report->valid_records,
|
||||
report->invalid_records,
|
||||
report->first_invalid_line);
|
||||
}
|
||||
|
||||
static int scan_log(const char *path, bool recover) {
|
||||
FILE *fp;
|
||||
char line[MESSAGE_LOG_MAX_LINE];
|
||||
long line_no = 0;
|
||||
time_t now = time(NULL);
|
||||
message_log_report_t report = {0};
|
||||
|
||||
if (!path || path[0] == '\0') {
|
||||
fprintf(stderr, "log: invalid path\n");
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
||||
fp = fopen(path, "r");
|
||||
if (!fp) {
|
||||
fprintf(stderr, "log: %s: %s\n", path, strerror(errno));
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
size_t line_len = strlen(line);
|
||||
message_t parsed;
|
||||
bool valid = false;
|
||||
|
||||
line_no++;
|
||||
report.records_seen++;
|
||||
|
||||
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
|
||||
discard_line_remainder(fp);
|
||||
} else {
|
||||
valid = message_log_parse_record(line, &parsed, now);
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
report.valid_records++;
|
||||
if (recover && print_recovered_record(&parsed) < 0) {
|
||||
fclose(fp);
|
||||
fprintf(stderr, "log: failed to write recovered output\n");
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
} else {
|
||||
report.invalid_records++;
|
||||
if (report.first_invalid_line == 0) {
|
||||
report.first_invalid_line = line_no;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ferror(fp)) {
|
||||
fclose(fp);
|
||||
fprintf(stderr, "log: failed to read %s\n", path);
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
print_report(recover ? stderr : stdout, path, &report);
|
||||
return report.invalid_records == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
int message_log_tool_check(const char *path) {
|
||||
return scan_log(path, false);
|
||||
}
|
||||
|
||||
int message_log_tool_recover(const char *path) {
|
||||
return scan_log(path, true);
|
||||
}
|
||||
112
src/module_protocol.c
Normal file
112
src/module_protocol.c
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#include "module_protocol.h"
|
||||
|
||||
#include "common.h"
|
||||
#include "json_text.h"
|
||||
#include "message_log.h"
|
||||
#include "utf8.h"
|
||||
|
||||
static bool append_was_truncated(size_t pos, size_t buf_size) {
|
||||
return buf_size == 0 || pos >= buf_size - 1;
|
||||
}
|
||||
|
||||
static bool has_plain_text_controls(const char *text) {
|
||||
const unsigned char *p = (const unsigned char *)text;
|
||||
|
||||
while (p && *p) {
|
||||
if (*p < 32 || *p == 127) {
|
||||
return true;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos,
|
||||
const char *server_version) {
|
||||
const char *version = server_version ? server_version : TNT_VERSION;
|
||||
size_t before;
|
||||
|
||||
if (!buffer || !pos || buf_size == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
before = *pos;
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"{\"type\":\"handshake\",\"protocol\":");
|
||||
tnt_json_append_string(buffer, buf_size, pos, TNT_MODULE_PROTOCOL_VERSION);
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
",\"server\":{\"name\":\"tnt\",\"version\":");
|
||||
tnt_json_append_string(buffer, buf_size, pos, version);
|
||||
buffer_appendf(buffer, buf_size, pos, "}}\n");
|
||||
|
||||
return append_was_truncated(*pos, buf_size) && *pos == buf_size - 1 &&
|
||||
before != *pos
|
||||
? -1
|
||||
: 0;
|
||||
}
|
||||
|
||||
int tnt_module_append_message_created(char *buffer, size_t buf_size,
|
||||
size_t *pos, const char *message_id,
|
||||
const message_t *msg) {
|
||||
char timestamp[64];
|
||||
size_t before;
|
||||
|
||||
if (!buffer || !pos || buf_size == 0 || !message_id || !msg ||
|
||||
message_id[0] == '\0' || !utf8_is_valid_string(msg->username) ||
|
||||
!utf8_is_valid_string(msg->content)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
before = *pos;
|
||||
message_log_format_timestamp_utc(msg->timestamp, timestamp,
|
||||
sizeof(timestamp));
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"{\"type\":\"%s\",\"message\":{\"id\":",
|
||||
TNT_MODULE_EVENT_MESSAGE_CREATED);
|
||||
tnt_json_append_string(buffer, buf_size, pos, message_id);
|
||||
buffer_appendf(buffer, buf_size, pos, ",\"timestamp\":");
|
||||
tnt_json_append_string(buffer, buf_size, pos, timestamp);
|
||||
buffer_appendf(buffer, buf_size, pos, ",\"sender\":");
|
||||
tnt_json_append_string(buffer, buf_size, pos, msg->username);
|
||||
buffer_appendf(buffer, buf_size, pos, ",\"kind\":\"text\","
|
||||
"\"plain_text\":");
|
||||
tnt_json_append_string(buffer, buf_size, pos, msg->content);
|
||||
buffer_appendf(buffer, buf_size, pos, ",\"metadata\":{}}}\n");
|
||||
|
||||
return append_was_truncated(*pos, buf_size) && *pos == buf_size - 1 &&
|
||||
before != *pos
|
||||
? -1
|
||||
: 0;
|
||||
}
|
||||
|
||||
bool tnt_module_parse_message_create(const char *line,
|
||||
tnt_module_message_create_t *out) {
|
||||
char type[64];
|
||||
char plain_text[MAX_MESSAGE_LEN];
|
||||
|
||||
if (!line || !out) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!tnt_json_get_string_field(line, "type", type, sizeof(type)) ||
|
||||
strcmp(type, TNT_MODULE_RESPONSE_MESSAGE_CREATE) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!tnt_json_get_string_field(line, "plain_text", plain_text,
|
||||
sizeof(plain_text))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (plain_text[0] == '\0' ||
|
||||
strlen(plain_text) >= sizeof(out->plain_text) ||
|
||||
!utf8_is_valid_string(plain_text) ||
|
||||
has_plain_text_controls(plain_text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
snprintf(out->plain_text, sizeof(out->plain_text), "%s", plain_text);
|
||||
return true;
|
||||
}
|
||||
537
src/module_runtime.c
Normal file
537
src/module_runtime.c
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
#include "module_runtime.h"
|
||||
|
||||
#include "chat_room.h"
|
||||
#include "common.h"
|
||||
#include "json_text.h"
|
||||
#include "module_protocol.h"
|
||||
#include "utf8.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define TNT_MODULE_LINE_MAX 4096
|
||||
#define TNT_MODULE_HANDSHAKE_TIMEOUT_MS 2000
|
||||
#define TNT_MODULE_RESPONSE_TIMEOUT_MS 100
|
||||
|
||||
struct client;
|
||||
void notify_mentions(const char *content, const struct client *sender);
|
||||
|
||||
typedef struct module_process {
|
||||
tnt_module_manifest_t manifest;
|
||||
pid_t pid;
|
||||
int stdin_fd;
|
||||
int stdout_fd;
|
||||
bool active;
|
||||
} module_process_t;
|
||||
|
||||
typedef struct module_event_node {
|
||||
message_t msg;
|
||||
struct module_event_node *next;
|
||||
} module_event_node_t;
|
||||
|
||||
static module_process_t g_modules[TNT_MAX_MODULES];
|
||||
static int g_module_count = 0;
|
||||
static pthread_t g_module_thread;
|
||||
static bool g_thread_started = false;
|
||||
static bool g_running = false;
|
||||
static pthread_mutex_t g_queue_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_cond_t g_queue_cond = PTHREAD_COND_INITIALIZER;
|
||||
static module_event_node_t *g_queue_head = NULL;
|
||||
static module_event_node_t *g_queue_tail = NULL;
|
||||
static int g_queue_len = 0;
|
||||
|
||||
static bool is_safe_relative_entrypoint(const char *entrypoint) {
|
||||
if (!entrypoint || entrypoint[0] == '\0' || entrypoint[0] == '/') {
|
||||
return false;
|
||||
}
|
||||
if (strstr(entrypoint, "..") != NULL) {
|
||||
return false;
|
||||
}
|
||||
for (const unsigned char *p = (const unsigned char *)entrypoint; *p; p++) {
|
||||
if (*p <= 32 || *p == 127 || *p == '|' || *p == ';' ||
|
||||
*p == '&' || *p == '`' || *p == '$' || *p == '<' ||
|
||||
*p == '>' || *p == '\\') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool json_array_contains_string(const char *json, const char *key,
|
||||
const char *value) {
|
||||
char needle[128];
|
||||
const char *p;
|
||||
|
||||
if (!json || !key || !value ||
|
||||
snprintf(needle, sizeof(needle), "\"%s\"", key) >=
|
||||
(int)sizeof(needle)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
p = strstr(json, needle);
|
||||
if (!p) return false;
|
||||
p = strchr(p + strlen(needle), ':');
|
||||
if (!p) return false;
|
||||
p++;
|
||||
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
|
||||
if (*p != '[') return false;
|
||||
p++;
|
||||
|
||||
while (*p) {
|
||||
char item[128];
|
||||
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' ||
|
||||
*p == ',') {
|
||||
p++;
|
||||
}
|
||||
if (*p == ']') return false;
|
||||
if (*p != '"') return false;
|
||||
const char *cursor = p;
|
||||
size_t pos = 0;
|
||||
item[0] = '\0';
|
||||
cursor++;
|
||||
while (*cursor && *cursor != '"') {
|
||||
if (*cursor == '\\') {
|
||||
cursor++;
|
||||
if (!*cursor) return false;
|
||||
}
|
||||
if (pos + 1 >= sizeof(item)) return false;
|
||||
item[pos++] = *cursor++;
|
||||
}
|
||||
if (*cursor != '"') return false;
|
||||
item[pos] = '\0';
|
||||
if (strcmp(item, value) == 0) return true;
|
||||
p = cursor + 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
int tnt_module_manifest_load(const char *module_dir,
|
||||
tnt_module_manifest_t *out) {
|
||||
char manifest_path[PATH_MAX];
|
||||
char manifest[TNT_MODULE_LINE_MAX];
|
||||
char protocol[64];
|
||||
FILE *fp;
|
||||
size_t n;
|
||||
|
||||
if (!module_dir || module_dir[0] == '\0' || !out) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (snprintf(manifest_path, sizeof(manifest_path), "%s/tnt-module.json",
|
||||
module_dir) >= (int)sizeof(manifest_path)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
fp = fopen(manifest_path, "rb");
|
||||
if (!fp) {
|
||||
return -1;
|
||||
}
|
||||
n = fread(manifest, 1, sizeof(manifest) - 1, fp);
|
||||
fclose(fp);
|
||||
manifest[n] = '\0';
|
||||
|
||||
if (n == 0 || n >= sizeof(manifest) - 1 ||
|
||||
!tnt_json_get_string_field(manifest, "protocol", protocol,
|
||||
sizeof(protocol)) ||
|
||||
strcmp(protocol, TNT_MODULE_PROTOCOL_VERSION) != 0 ||
|
||||
!tnt_json_get_string_field(manifest, "name", out->name,
|
||||
sizeof(out->name)) ||
|
||||
!tnt_json_get_string_field(manifest, "entrypoint", out->entrypoint,
|
||||
sizeof(out->entrypoint)) ||
|
||||
!is_valid_username(out->name) ||
|
||||
!is_safe_relative_entrypoint(out->entrypoint)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
out->wants_message_created = json_array_contains_string(
|
||||
manifest, "events", TNT_MODULE_EVENT_MESSAGE_CREATED);
|
||||
out->can_read_messages = json_array_contains_string(
|
||||
manifest, "permissions", "message:read");
|
||||
out->can_create_messages = json_array_contains_string(
|
||||
manifest, "permissions", "message:create");
|
||||
|
||||
if (!out->wants_message_created || !out->can_read_messages ||
|
||||
!out->can_create_messages) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int wait_fd_readable(int fd, int timeout_ms) {
|
||||
fd_set readfds;
|
||||
struct timeval tv;
|
||||
|
||||
FD_ZERO(&readfds);
|
||||
FD_SET(fd, &readfds);
|
||||
tv.tv_sec = timeout_ms / 1000;
|
||||
tv.tv_usec = (timeout_ms % 1000) * 1000;
|
||||
|
||||
return select(fd + 1, &readfds, NULL, NULL, &tv);
|
||||
}
|
||||
|
||||
static int read_line_timeout(int fd, char *line, size_t line_size,
|
||||
int timeout_ms) {
|
||||
size_t pos = 0;
|
||||
|
||||
if (!line || line_size == 0) return -1;
|
||||
line[0] = '\0';
|
||||
|
||||
while (pos + 1 < line_size) {
|
||||
char c;
|
||||
int ready = wait_fd_readable(fd, timeout_ms);
|
||||
if (ready <= 0) {
|
||||
break;
|
||||
}
|
||||
ssize_t n = read(fd, &c, 1);
|
||||
if (n <= 0) {
|
||||
return -1;
|
||||
}
|
||||
if (c == '\n') {
|
||||
line[pos] = '\0';
|
||||
return (int)pos;
|
||||
}
|
||||
if ((unsigned char)c < 32 && c != '\t' && c != '\r') {
|
||||
return -1;
|
||||
}
|
||||
line[pos++] = c;
|
||||
}
|
||||
|
||||
line[pos] = '\0';
|
||||
return pos > 0 ? (int)pos : 0;
|
||||
}
|
||||
|
||||
static void close_module_process(module_process_t *module) {
|
||||
if (!module || !module->active) return;
|
||||
|
||||
close(module->stdin_fd);
|
||||
close(module->stdout_fd);
|
||||
kill(module->pid, SIGTERM);
|
||||
waitpid(module->pid, NULL, WNOHANG);
|
||||
module->active = false;
|
||||
}
|
||||
|
||||
static bool handshake_ok(const char *line) {
|
||||
char type[64];
|
||||
char protocol[64];
|
||||
|
||||
return tnt_json_get_string_field(line, "type", type, sizeof(type)) &&
|
||||
strcmp(type, "handshake.ok") == 0 &&
|
||||
tnt_json_get_string_field(line, "protocol", protocol,
|
||||
sizeof(protocol)) &&
|
||||
strcmp(protocol, TNT_MODULE_PROTOCOL_VERSION) == 0;
|
||||
}
|
||||
|
||||
static int start_module_process(const char *module_dir,
|
||||
module_process_t *module) {
|
||||
int in_pipe[2] = {-1, -1};
|
||||
int out_pipe[2] = {-1, -1};
|
||||
char handshake[512] = "";
|
||||
char line[TNT_MODULE_LINE_MAX];
|
||||
size_t pos = 0;
|
||||
|
||||
if (!module) {
|
||||
return -1;
|
||||
}
|
||||
if (pipe(in_pipe) < 0) {
|
||||
return -1;
|
||||
}
|
||||
if (pipe(out_pipe) < 0) {
|
||||
close(in_pipe[0]);
|
||||
close(in_pipe[1]);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(in_pipe[0]);
|
||||
close(in_pipe[1]);
|
||||
close(out_pipe[0]);
|
||||
close(out_pipe[1]);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
close(in_pipe[1]);
|
||||
close(out_pipe[0]);
|
||||
if (dup2(in_pipe[0], STDIN_FILENO) < 0 ||
|
||||
dup2(out_pipe[1], STDOUT_FILENO) < 0 ||
|
||||
chdir(module_dir) < 0) {
|
||||
_exit(127);
|
||||
}
|
||||
close(in_pipe[0]);
|
||||
close(out_pipe[1]);
|
||||
execl(module->manifest.entrypoint, module->manifest.entrypoint,
|
||||
(char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
close(in_pipe[0]);
|
||||
close(out_pipe[1]);
|
||||
|
||||
module->pid = pid;
|
||||
module->stdin_fd = in_pipe[1];
|
||||
module->stdout_fd = out_pipe[0];
|
||||
module->active = true;
|
||||
|
||||
if (tnt_module_append_handshake(handshake, sizeof(handshake), &pos,
|
||||
TNT_VERSION) < 0 ||
|
||||
write(module->stdin_fd, handshake, strlen(handshake)) !=
|
||||
(ssize_t)strlen(handshake) ||
|
||||
read_line_timeout(module->stdout_fd, line, sizeof(line),
|
||||
TNT_MODULE_HANDSHAKE_TIMEOUT_MS) <= 0 ||
|
||||
!handshake_ok(line)) {
|
||||
close_module_process(module);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void enqueue_message(const message_t *msg) {
|
||||
module_event_node_t *node;
|
||||
|
||||
if (!msg) return;
|
||||
|
||||
pthread_mutex_lock(&g_queue_lock);
|
||||
if (!g_running || g_queue_len >= TNT_MODULE_QUEUE_LIMIT) {
|
||||
pthread_mutex_unlock(&g_queue_lock);
|
||||
if (g_queue_len >= TNT_MODULE_QUEUE_LIMIT) {
|
||||
fprintf(stderr, "module runtime: event queue full, dropping\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
node = calloc(1, sizeof(*node));
|
||||
if (!node) {
|
||||
pthread_mutex_unlock(&g_queue_lock);
|
||||
return;
|
||||
}
|
||||
node->msg = *msg;
|
||||
|
||||
if (g_queue_tail) {
|
||||
g_queue_tail->next = node;
|
||||
} else {
|
||||
g_queue_head = node;
|
||||
}
|
||||
g_queue_tail = node;
|
||||
g_queue_len++;
|
||||
pthread_cond_signal(&g_queue_cond);
|
||||
pthread_mutex_unlock(&g_queue_lock);
|
||||
}
|
||||
|
||||
static module_event_node_t *dequeue_message(void) {
|
||||
module_event_node_t *node;
|
||||
|
||||
pthread_mutex_lock(&g_queue_lock);
|
||||
while (g_running && !g_queue_head) {
|
||||
pthread_cond_wait(&g_queue_cond, &g_queue_lock);
|
||||
}
|
||||
node = g_queue_head;
|
||||
if (node) {
|
||||
g_queue_head = node->next;
|
||||
if (!g_queue_head) g_queue_tail = NULL;
|
||||
g_queue_len--;
|
||||
}
|
||||
pthread_mutex_unlock(&g_queue_lock);
|
||||
return node;
|
||||
}
|
||||
|
||||
static void publish_module_message(const module_process_t *module,
|
||||
const char *plain_text) {
|
||||
message_t msg = {
|
||||
.timestamp = time(NULL),
|
||||
};
|
||||
|
||||
if (!module || !plain_text || plain_text[0] == '\0') return;
|
||||
|
||||
snprintf(msg.username, sizeof(msg.username), "module:%s",
|
||||
module->manifest.name);
|
||||
snprintf(msg.content, sizeof(msg.content), "%s", plain_text);
|
||||
|
||||
if (message_save(&msg) < 0) {
|
||||
fprintf(stderr, "module runtime: failed to persist module message\n");
|
||||
return;
|
||||
}
|
||||
|
||||
room_broadcast(g_room, &msg);
|
||||
notify_mentions(msg.content, NULL);
|
||||
}
|
||||
|
||||
static void handle_module_response(module_process_t *module, const char *line) {
|
||||
tnt_module_message_create_t create;
|
||||
char type[64];
|
||||
|
||||
if (!module || !line || line[0] == '\0') return;
|
||||
|
||||
if (tnt_module_parse_message_create(line, &create)) {
|
||||
publish_module_message(module, create.plain_text);
|
||||
return;
|
||||
}
|
||||
if (tnt_json_get_string_field(line, "type", type, sizeof(type)) &&
|
||||
strcmp(type, "event.ok") == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stderr, "module runtime: ignored invalid response from %s\n",
|
||||
module->manifest.name);
|
||||
}
|
||||
|
||||
static void deliver_message_to_module(module_process_t *module,
|
||||
const message_t *msg,
|
||||
uint64_t event_id) {
|
||||
char event[TNT_MODULE_LINE_MAX] = "";
|
||||
char line[TNT_MODULE_LINE_MAX];
|
||||
char message_id[64];
|
||||
size_t pos = 0;
|
||||
|
||||
if (!module || !module->active || !msg) return;
|
||||
|
||||
snprintf(message_id, sizeof(message_id), "local-%llu",
|
||||
(unsigned long long)event_id);
|
||||
if (tnt_module_append_message_created(event, sizeof(event), &pos,
|
||||
message_id, msg) < 0 ||
|
||||
write(module->stdin_fd, event, strlen(event)) !=
|
||||
(ssize_t)strlen(event)) {
|
||||
fprintf(stderr, "module runtime: disabling %s after write failure\n",
|
||||
module->manifest.name);
|
||||
close_module_process(module);
|
||||
return;
|
||||
}
|
||||
|
||||
while (1) {
|
||||
int n = read_line_timeout(module->stdout_fd, line, sizeof(line),
|
||||
TNT_MODULE_RESPONSE_TIMEOUT_MS);
|
||||
if (n == 0) {
|
||||
return;
|
||||
}
|
||||
if (n < 0) {
|
||||
fprintf(stderr, "module runtime: disabling %s after read failure\n",
|
||||
module->manifest.name);
|
||||
close_module_process(module);
|
||||
return;
|
||||
}
|
||||
handle_module_response(module, line);
|
||||
}
|
||||
}
|
||||
|
||||
static void *module_worker_main(void *arg) {
|
||||
uint64_t event_id = 0;
|
||||
(void)arg;
|
||||
|
||||
while (g_running) {
|
||||
module_event_node_t *node = dequeue_message();
|
||||
if (!node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
event_id++;
|
||||
for (int i = 0; i < g_module_count; i++) {
|
||||
deliver_message_to_module(&g_modules[i], &node->msg, event_id);
|
||||
}
|
||||
free(node);
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int load_modules_from_env(void) {
|
||||
const char *paths = getenv("TNT_MODULE_PATHS");
|
||||
char copy[4096];
|
||||
char *saveptr = NULL;
|
||||
char *token;
|
||||
|
||||
if (!paths || paths[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
if (strlen(paths) >= sizeof(copy)) {
|
||||
fprintf(stderr, "module runtime: TNT_MODULE_PATHS too long\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(copy, sizeof(copy), "%s", paths);
|
||||
token = strtok_r(copy, ":", &saveptr);
|
||||
while (token && g_module_count < TNT_MAX_MODULES) {
|
||||
module_process_t *module = &g_modules[g_module_count];
|
||||
|
||||
memset(module, 0, sizeof(*module));
|
||||
module->stdin_fd = -1;
|
||||
module->stdout_fd = -1;
|
||||
if (tnt_module_manifest_load(token, &module->manifest) == 0 &&
|
||||
start_module_process(token, module) == 0) {
|
||||
fprintf(stderr, "module runtime: enabled %s\n",
|
||||
module->manifest.name);
|
||||
g_module_count++;
|
||||
} else {
|
||||
fprintf(stderr, "module runtime: failed to enable module at %s\n",
|
||||
token);
|
||||
}
|
||||
token = strtok_r(NULL, ":", &saveptr);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int tnt_module_runtime_init(void) {
|
||||
g_module_count = 0;
|
||||
g_running = false;
|
||||
|
||||
if (load_modules_from_env() < 0) {
|
||||
return -1;
|
||||
}
|
||||
if (g_module_count == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
g_running = true;
|
||||
if (pthread_create(&g_module_thread, NULL, module_worker_main, NULL) != 0) {
|
||||
g_running = false;
|
||||
for (int i = 0; i < g_module_count; i++) {
|
||||
close_module_process(&g_modules[i]);
|
||||
}
|
||||
g_module_count = 0;
|
||||
return -1;
|
||||
}
|
||||
g_thread_started = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void tnt_module_runtime_shutdown(void) {
|
||||
module_event_node_t *node;
|
||||
|
||||
pthread_mutex_lock(&g_queue_lock);
|
||||
g_running = false;
|
||||
pthread_cond_broadcast(&g_queue_cond);
|
||||
pthread_mutex_unlock(&g_queue_lock);
|
||||
|
||||
if (g_thread_started) {
|
||||
pthread_join(g_module_thread, NULL);
|
||||
g_thread_started = false;
|
||||
}
|
||||
|
||||
while ((node = g_queue_head) != NULL) {
|
||||
g_queue_head = node->next;
|
||||
free(node);
|
||||
}
|
||||
g_queue_tail = NULL;
|
||||
g_queue_len = 0;
|
||||
|
||||
for (int i = 0; i < g_module_count; i++) {
|
||||
close_module_process(&g_modules[i]);
|
||||
}
|
||||
g_module_count = 0;
|
||||
}
|
||||
|
||||
void tnt_module_runtime_publish_message_created(const message_t *msg) {
|
||||
if (!msg || g_module_count == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
enqueue_message(msg);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
#include "ratelimit.h"
|
||||
#include "config_defaults.h"
|
||||
#include "common.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <pthread.h>
|
||||
|
|
@ -27,16 +28,20 @@ static pthread_mutex_t g_rate_limit_lock = PTHREAD_MUTEX_INITIALIZER;
|
|||
static int g_total_connections = 0;
|
||||
static pthread_mutex_t g_conn_count_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static int g_max_connections = 64;
|
||||
static int g_max_conn_per_ip = 5;
|
||||
static int g_max_conn_rate_per_ip = 10;
|
||||
static int g_rate_limit_enabled = 1;
|
||||
static int g_max_connections = TNT_DEFAULT_MAX_CONNECTIONS;
|
||||
static int g_max_conn_per_ip = TNT_DEFAULT_MAX_CONN_PER_IP;
|
||||
static int g_max_conn_rate_per_ip = TNT_DEFAULT_MAX_CONN_RATE_PER_IP;
|
||||
static int g_rate_limit_enabled = TNT_DEFAULT_RATE_LIMIT_ENABLED;
|
||||
|
||||
void ratelimit_init(void) {
|
||||
g_max_connections = env_int("TNT_MAX_CONNECTIONS", 64, 1, 1024);
|
||||
g_max_conn_per_ip = env_int("TNT_MAX_CONN_PER_IP", 5, 1, 1024);
|
||||
g_max_conn_rate_per_ip = env_int("TNT_MAX_CONN_RATE_PER_IP", 10, 1, 1024);
|
||||
g_rate_limit_enabled = env_int("TNT_RATE_LIMIT", 1, 0, 1);
|
||||
g_max_connections =
|
||||
tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS);
|
||||
g_max_conn_per_ip =
|
||||
tnt_config_env_int(&TNT_CONFIG_MAX_CONN_PER_IP);
|
||||
g_max_conn_rate_per_ip =
|
||||
tnt_config_env_int(&TNT_CONFIG_MAX_CONN_RATE_PER_IP);
|
||||
g_rate_limit_enabled =
|
||||
tnt_config_env_int(&TNT_CONFIG_RATE_LIMIT);
|
||||
}
|
||||
|
||||
/* Caller MUST hold g_rate_limit_lock. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "ssh_server.h"
|
||||
#include "bootstrap.h"
|
||||
#include "commands.h"
|
||||
#include "config_defaults.h"
|
||||
#include "exec.h"
|
||||
#include "input.h"
|
||||
#include "ratelimit.h"
|
||||
|
|
@ -23,7 +24,7 @@
|
|||
|
||||
/* Global SSH bind instance */
|
||||
static ssh_bind g_sshbind = NULL;
|
||||
static int g_listen_port = DEFAULT_PORT;
|
||||
static int g_listen_port = TNT_DEFAULT_PORT;
|
||||
|
||||
static time_t g_server_start_time = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
#include "support.h"
|
||||
#include "support_text.h"
|
||||
|
||||
void support_append_interactive_panel(char *buffer, size_t buf_size,
|
||||
size_t *pos, help_lang_t lang) {
|
||||
if (!buffer || !pos) return;
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, "%s",
|
||||
support_text_interactive(lang));
|
||||
}
|
||||
|
||||
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos,
|
||||
help_lang_t lang) {
|
||||
if (!buffer || !pos) return;
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", support_text_exec(lang));
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
#include "support_text.h"
|
||||
|
||||
const char *support_text_interactive(help_lang_t lang) {
|
||||
if (lang == LANG_ZH) {
|
||||
return "\033[1;36m支持 · support\033[0m\n"
|
||||
"\n"
|
||||
"\033[1;37m第一次进来\033[0m\n"
|
||||
" INSERT 输入消息,Enter 发送,ESC 进入 NORMAL\n"
|
||||
" NORMAL 浏览消息,G 回到最新,i 继续输入\n"
|
||||
" COMMAND 按 : 输入命令,q/ESC 关闭当前面板\n"
|
||||
"\n"
|
||||
"\033[1;37m我想...\033[0m\n"
|
||||
" 看谁在线 :users\n"
|
||||
" 看最近历史 :last 20\n"
|
||||
" 搜索聊天记录 :search <keyword>\n"
|
||||
" 回到最新消息 G 或 End\n"
|
||||
" 私聊某个人 :msg <user> <text>\n"
|
||||
" 查看私聊收件箱 :inbox\n"
|
||||
" 静音进出提示 :mute-joins\n"
|
||||
"\n"
|
||||
"\033[1;37m遇到问题\033[0m\n"
|
||||
" 看不到新消息: 在 NORMAL 按 G 或 End 回到最新\n"
|
||||
" 粘贴多行文本: 直接粘贴,TNT 会等 Enter 后一次发送\n"
|
||||
" 输入太长: 状态行接近限制时会提示,超出会响铃\n"
|
||||
" 命令不记得: 输入 :help 看列表,输入 :support 回到这里\n"
|
||||
" 连接断开: 可能是空闲超时、连接数限制或网络重连\n"
|
||||
"\n"
|
||||
"\033[2;37m更多: ? 打开完整按键帮助,:help 查看命令列表\033[0m\n";
|
||||
}
|
||||
|
||||
return "\033[1;36mSupport\033[0m\n"
|
||||
"\n"
|
||||
"\033[1;37mFirst minute\033[0m\n"
|
||||
" INSERT Type messages, Enter sends, ESC enters NORMAL\n"
|
||||
" NORMAL Browse history, G jumps latest, i continues typing\n"
|
||||
" COMMAND Press : for commands, q/ESC closes this panel\n"
|
||||
"\n"
|
||||
"\033[1;37mI want to...\033[0m\n"
|
||||
" See who is online :users\n"
|
||||
" See recent history :last 20\n"
|
||||
" Search history :search <keyword>\n"
|
||||
" Return to latest G or End\n"
|
||||
" Whisper someone :msg <user> <text>\n"
|
||||
" Read whispers :inbox\n"
|
||||
" Mute join notices :mute-joins\n"
|
||||
"\n"
|
||||
"\033[1;37mTroubleshooting\033[0m\n"
|
||||
" Missing new messages: press G or End in NORMAL\n"
|
||||
" Pasting many lines: paste normally, then Enter sends once\n"
|
||||
" Message too long: the status line warns near the limit\n"
|
||||
" Forgot a command: type :help or return here with :support\n"
|
||||
" Disconnected: check idle timeout, limits, or reconnect\n"
|
||||
"\n"
|
||||
"\033[2;37mMore: ? opens full key help, :help lists commands\033[0m\n";
|
||||
}
|
||||
|
||||
const char *support_text_exec(help_lang_t lang) {
|
||||
if (lang == LANG_ZH) {
|
||||
return "TNT 支持\n"
|
||||
"\n"
|
||||
"交互使用:\n"
|
||||
" ssh -p 2222 HOST\n"
|
||||
" INSERT: 输入消息并按 Enter 发送\n"
|
||||
" NORMAL: G 回到最新,k/PageUp 查看更早消息\n"
|
||||
" COMMAND: 按 : 后可运行 users, last, search, msg, inbox\n"
|
||||
"\n"
|
||||
"非交互检查:\n"
|
||||
" ssh -p 2222 HOST health\n"
|
||||
" ssh -p 2222 HOST stats --json\n"
|
||||
" ssh -p 2222 HOST users --json\n"
|
||||
" ssh -p 2222 HOST 'tail -n 20'\n"
|
||||
" ssh -p 2222 USER@HOST post 'message'\n"
|
||||
"\n"
|
||||
"排查:\n"
|
||||
" 连接过早关闭: 检查限流、空闲超时、连接容量、\n"
|
||||
" 单 IP 限制和防火墙规则。\n";
|
||||
}
|
||||
|
||||
return "TNT support\n"
|
||||
"\n"
|
||||
"Interactive use:\n"
|
||||
" ssh -p 2222 HOST\n"
|
||||
" INSERT: type and press Enter to send\n"
|
||||
" NORMAL: press G for latest, k/PageUp for older messages\n"
|
||||
" COMMAND: press : then run users, last, search, msg, inbox\n"
|
||||
"\n"
|
||||
"Non-interactive checks:\n"
|
||||
" ssh -p 2222 HOST health\n"
|
||||
" ssh -p 2222 HOST stats --json\n"
|
||||
" ssh -p 2222 HOST users --json\n"
|
||||
" ssh -p 2222 HOST 'tail -n 20'\n"
|
||||
" ssh -p 2222 USER@HOST post 'message'\n"
|
||||
"\n"
|
||||
"Troubleshooting:\n"
|
||||
" Connection closes early: check rate limits, idle timeout,\n"
|
||||
" global connection capacity, per-IP limits, and firewall rules.\n";
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
static void system_message_init(message_t *msg, help_lang_t lang) {
|
||||
static void system_message_init(message_t *msg, ui_lang_t lang) {
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ static void system_message_init(message_t *msg, help_lang_t lang) {
|
|||
}
|
||||
|
||||
void system_message_make_join(message_t *msg, const char *username,
|
||||
help_lang_t lang) {
|
||||
ui_lang_t lang) {
|
||||
system_message_init(msg, lang);
|
||||
if (!msg) {
|
||||
return;
|
||||
|
|
@ -28,7 +28,7 @@ void system_message_make_join(message_t *msg, const char *username,
|
|||
}
|
||||
|
||||
void system_message_make_leave(message_t *msg, const char *username,
|
||||
help_lang_t lang) {
|
||||
ui_lang_t lang) {
|
||||
system_message_init(msg, lang);
|
||||
if (!msg) {
|
||||
return;
|
||||
|
|
@ -40,7 +40,7 @@ void system_message_make_leave(message_t *msg, const char *username,
|
|||
}
|
||||
|
||||
void system_message_make_nick(message_t *msg, const char *old_name,
|
||||
const char *new_name, help_lang_t lang) {
|
||||
const char *new_name, ui_lang_t lang) {
|
||||
system_message_init(msg, lang);
|
||||
if (!msg) {
|
||||
return;
|
||||
|
|
|
|||
298
src/tntctl.c
Normal file
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);
|
||||
}
|
||||
244
src/tui.c
244
src/tui.c
|
|
@ -21,6 +21,25 @@ static const char *username_color(const char *name) {
|
|||
return colors[h % 6];
|
||||
}
|
||||
|
||||
static char *client_render_buffer(client_t *client, size_t min_size) {
|
||||
if (!client || min_size == 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (client->render_buffer_capacity >= min_size) {
|
||||
return client->render_buffer;
|
||||
}
|
||||
|
||||
char *grown = realloc(client->render_buffer, min_size);
|
||||
if (!grown) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
client->render_buffer = grown;
|
||||
client->render_buffer_capacity = min_size;
|
||||
return client->render_buffer;
|
||||
}
|
||||
|
||||
static void format_message_colored(const message_t *msg, char *buffer,
|
||||
size_t buf_size, int width,
|
||||
const char *my_username) {
|
||||
|
|
@ -154,8 +173,8 @@ void tui_render_welcome(client_t *client) {
|
|||
|
||||
/* Lines, in display order. Width is computed in display columns. */
|
||||
const char *line1 = "TNT · " TNT_VERSION;
|
||||
const char *line2 = i18n_text(client->help_lang, I18N_WELCOME_SUBTITLE);
|
||||
const char *line3 = i18n_text(client->help_lang, I18N_WELCOME_TAGLINE);
|
||||
const char *line2 = i18n_text(client->ui_lang, I18N_WELCOME_SUBTITLE);
|
||||
const char *line3 = i18n_text(client->ui_lang, I18N_WELCOME_TAGLINE);
|
||||
|
||||
int inner_w = utf8_string_width(line1);
|
||||
int w2 = utf8_string_width(line2);
|
||||
|
|
@ -169,7 +188,7 @@ void tui_render_welcome(client_t *client) {
|
|||
char fallback_text[96];
|
||||
char fallback[128];
|
||||
snprintf(fallback_text, sizeof(fallback_text),
|
||||
i18n_text(client->help_lang, I18N_WELCOME_FALLBACK_FORMAT),
|
||||
i18n_text(client->ui_lang, I18N_WELCOME_FALLBACK_FORMAT),
|
||||
TNT_VERSION);
|
||||
int n = snprintf(fallback, sizeof(fallback), ANSI_CLEAR ANSI_HOME "%s",
|
||||
fallback_text);
|
||||
|
|
@ -245,7 +264,7 @@ void tui_render_screen(client_t *client) {
|
|||
if (render_height < 4) render_height = 4;
|
||||
|
||||
const size_t buf_size = (size_t)(render_height + 10) * (MAX_MESSAGE_LEN + 64) + 2048;
|
||||
char *buffer = malloc(buf_size);
|
||||
char *buffer = client_render_buffer(client, buf_size);
|
||||
if (!buffer) return;
|
||||
size_t pos = 0;
|
||||
buffer[0] = '\0';
|
||||
|
|
@ -255,6 +274,7 @@ void tui_render_screen(client_t *client) {
|
|||
int online = g_room->client_count;
|
||||
int msg_count = g_room->message_count;
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
int raw_msg_count = msg_count;
|
||||
|
||||
/* Calculate which messages to show. The initial slice is capped by
|
||||
* message count; the lock-held copy below tightens "latest" slices so
|
||||
|
|
@ -280,47 +300,95 @@ void tui_render_screen(client_t *client) {
|
|||
int end = start + msg_height;
|
||||
if (end > msg_count) end = msg_count;
|
||||
|
||||
/* Allocate snapshot outside the lock to avoid blocking writers */
|
||||
message_t *visible_messages = NULL;
|
||||
message_t *msg_snapshot = NULL;
|
||||
int snapshot_capacity = msg_height;
|
||||
int snapshot_count = end - start;
|
||||
int snapshot_count = 0;
|
||||
|
||||
if (snapshot_count > 0 && snapshot_capacity > 0) {
|
||||
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
|
||||
if (client->mute_joins && msg_count > 0) {
|
||||
visible_messages = calloc(MAX_MESSAGES, sizeof(message_t));
|
||||
if (visible_messages) {
|
||||
int visible_count = 0;
|
||||
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
online = g_room->client_count;
|
||||
raw_msg_count = g_room->message_count;
|
||||
for (int i = 0; i < g_room->message_count; i++) {
|
||||
if (!system_message_is_join_leave(&g_room->messages[i])) {
|
||||
visible_messages[visible_count++] = g_room->messages[i];
|
||||
}
|
||||
}
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
msg_count = visible_count;
|
||||
latest_scroll_start = history_view_max_scroll(msg_count, msg_height);
|
||||
anchor_latest = client->mode != MODE_NORMAL ||
|
||||
client->follow_tail ||
|
||||
client->scroll_pos >= latest_scroll_start;
|
||||
if (client->mode == MODE_NORMAL) {
|
||||
start = client->scroll_pos;
|
||||
if (start > latest_scroll_start) {
|
||||
start = latest_scroll_start;
|
||||
}
|
||||
if (start < 0) start = 0;
|
||||
} else {
|
||||
start = latest_scroll_start;
|
||||
}
|
||||
end = start + msg_height;
|
||||
if (end > msg_count) end = msg_count;
|
||||
if (anchor_latest) {
|
||||
start = history_view_latest_start_for_height(
|
||||
visible_messages, msg_count, msg_height);
|
||||
end = msg_count;
|
||||
}
|
||||
snapshot_count = end - start;
|
||||
if (snapshot_count > 0) {
|
||||
msg_snapshot = visible_messages + start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Second pass under lock: copy messages */
|
||||
if (msg_snapshot) {
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
/* Re-clamp in case msg_count changed */
|
||||
int actual_count = g_room->message_count;
|
||||
int actual_start = start;
|
||||
int actual_end = end;
|
||||
if (anchor_latest) {
|
||||
actual_end = actual_count;
|
||||
actual_start = history_view_latest_start_for_height(
|
||||
g_room->messages, actual_count, msg_height);
|
||||
} else {
|
||||
actual_end = (actual_end <= actual_count) ? actual_end : actual_count;
|
||||
actual_start = (actual_start < actual_end) ? actual_start : actual_end;
|
||||
if (!visible_messages) {
|
||||
/* Allocate snapshot outside the lock to avoid blocking writers */
|
||||
int snapshot_capacity = msg_height;
|
||||
snapshot_count = end - start;
|
||||
|
||||
if (snapshot_count > 0 && snapshot_capacity > 0) {
|
||||
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
|
||||
}
|
||||
int actual_snapshot = actual_end - actual_start;
|
||||
if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) {
|
||||
memcpy(msg_snapshot, &g_room->messages[actual_start],
|
||||
actual_snapshot * sizeof(message_t));
|
||||
start = actual_start;
|
||||
end = actual_end;
|
||||
snapshot_count = actual_snapshot;
|
||||
} else {
|
||||
snapshot_count = 0;
|
||||
|
||||
/* Second pass under lock: copy messages */
|
||||
if (msg_snapshot) {
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
/* Re-clamp in case msg_count changed */
|
||||
int actual_count = g_room->message_count;
|
||||
int actual_start = start;
|
||||
int actual_end = end;
|
||||
if (anchor_latest) {
|
||||
actual_end = actual_count;
|
||||
actual_start = history_view_latest_start_for_height(
|
||||
g_room->messages, actual_count, msg_height);
|
||||
} else {
|
||||
actual_end = (actual_end <= actual_count) ? actual_end : actual_count;
|
||||
actual_start = (actual_start < actual_end) ? actual_start : actual_end;
|
||||
}
|
||||
int actual_snapshot = actual_end - actual_start;
|
||||
if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) {
|
||||
memcpy(msg_snapshot, &g_room->messages[actual_start],
|
||||
actual_snapshot * sizeof(message_t));
|
||||
start = actual_start;
|
||||
end = actual_end;
|
||||
snapshot_count = actual_snapshot;
|
||||
} else {
|
||||
snapshot_count = 0;
|
||||
}
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
}
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
}
|
||||
|
||||
/* Now render using snapshot (no lock held) */
|
||||
|
||||
/* If mute_joins is set, remove join/leave messages from snapshot in place */
|
||||
if (client->mute_joins && msg_snapshot) {
|
||||
if (client->mute_joins && msg_snapshot && !anchor_latest) {
|
||||
int filtered = 0;
|
||||
for (int i = 0; i < snapshot_count; i++) {
|
||||
if (!system_message_is_join_leave(&msg_snapshot[i])) {
|
||||
|
|
@ -355,7 +423,7 @@ void tui_render_screen(client_t *client) {
|
|||
|
||||
char online_buf[32];
|
||||
snprintf(online_buf, sizeof(online_buf),
|
||||
i18n_text(client->help_lang, I18N_TITLE_ONLINE_FORMAT),
|
||||
i18n_text(client->ui_lang, I18N_TITLE_ONLINE_FORMAT),
|
||||
online);
|
||||
chips[chip_count].value = online_buf;
|
||||
chips[chip_count].value_color = "\033[37m";
|
||||
|
|
@ -373,9 +441,11 @@ void tui_render_screen(client_t *client) {
|
|||
chips[chip_count].value_color = mode_color;
|
||||
chip_count++;
|
||||
|
||||
const char *hint = i18n_text(client->help_lang, I18N_TITLE_HELP_HINT);
|
||||
const char *hint = client->mode == MODE_NORMAL
|
||||
? i18n_text(client->ui_lang, I18N_TITLE_HELP_HINT)
|
||||
: "";
|
||||
int hint_width = utf8_string_width(hint);
|
||||
const char *mute_label = i18n_text(client->help_lang, I18N_TITLE_MUTED);
|
||||
const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED);
|
||||
int mute_width = client->mute_joins ? utf8_string_width(mute_label) + 2 : 0;
|
||||
|
||||
/* Unread @-mentions chip — high-priority, gets a bright yellow star.
|
||||
|
|
@ -401,7 +471,7 @@ void tui_render_screen(client_t *client) {
|
|||
|
||||
/* Decide what fits. Reserve at least 1 col of gap between left and
|
||||
* right halves so they never visually touch. */
|
||||
int show_hint = 1;
|
||||
int show_hint = hint[0] != '\0';
|
||||
int show_mute = client->mute_joins ? 1 : 0;
|
||||
int show_unread = unread_count > 0 ? 1 : 0;
|
||||
int show_whisper = whisper_count > 0 ? 1 : 0;
|
||||
|
|
@ -511,9 +581,26 @@ void tui_render_screen(client_t *client) {
|
|||
buffer_appendf(buffer, buf_size, &pos, "%s\033[K\r\n", msg_line);
|
||||
rows_written++;
|
||||
}
|
||||
free(msg_snapshot);
|
||||
}
|
||||
|
||||
if (rows_written == 0) {
|
||||
const char *empty_text =
|
||||
client->mute_joins && raw_msg_count > 0
|
||||
? i18n_text(client->ui_lang, I18N_EMPTY_FILTERED)
|
||||
: i18n_text(client->ui_lang, I18N_EMPTY_ROOM);
|
||||
int empty_width = utf8_string_width(empty_text);
|
||||
int empty_pad = (render_width - empty_width) / 2;
|
||||
if (empty_pad < 0) empty_pad = 0;
|
||||
for (int i = 0; i < empty_pad; i++) {
|
||||
buffer_append_bytes(buffer, buf_size, &pos, " ", 1);
|
||||
}
|
||||
buffer_appendf(buffer, buf_size, &pos,
|
||||
"\033[2;37m%s\033[0m\033[K\r\n", empty_text);
|
||||
rows_written++;
|
||||
}
|
||||
|
||||
free(visible_messages ? visible_messages : msg_snapshot);
|
||||
|
||||
/* Fill empty lines and clear them */
|
||||
for (int i = rows_written; i < msg_height; i++) {
|
||||
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
|
||||
|
|
@ -529,7 +616,6 @@ void tui_render_screen(client_t *client) {
|
|||
tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end);
|
||||
|
||||
client_send(client, buffer, pos);
|
||||
free(buffer);
|
||||
}
|
||||
|
||||
/* Render the input line.
|
||||
|
|
@ -606,6 +692,23 @@ void tui_render_input(client_t *client, const char *input) {
|
|||
client_send(client, buffer, strlen(buffer));
|
||||
}
|
||||
|
||||
void tui_render_command_input(client_t *client) {
|
||||
if (!client || !client->connected) return;
|
||||
|
||||
int rh = client->height;
|
||||
if (rh < 4) rh = 4;
|
||||
|
||||
char buffer[sizeof(client->command_input) + 64];
|
||||
size_t pos = 0;
|
||||
buffer[0] = '\0';
|
||||
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos,
|
||||
"\033[%d;1H" ANSI_CLEAR_LINE, rh);
|
||||
tui_status_append(buffer, sizeof(buffer), &pos, client, 0, 0, 0);
|
||||
|
||||
client_send(client, buffer, pos);
|
||||
}
|
||||
|
||||
/* Render the command output screen */
|
||||
void tui_render_command_output(client_t *client) {
|
||||
if (!client || !client->connected) return;
|
||||
|
|
@ -615,7 +718,7 @@ void tui_render_command_output(client_t *client) {
|
|||
if (rw < 10) rw = 10;
|
||||
if (rh < 4) rh = 4;
|
||||
|
||||
char buffer[4096];
|
||||
char buffer[MAX_COMMAND_OUTPUT_LEN + 1024];
|
||||
size_t pos = 0;
|
||||
buffer[0] = '\0';
|
||||
|
||||
|
|
@ -623,7 +726,7 @@ void tui_render_command_output(client_t *client) {
|
|||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
|
||||
|
||||
/* Title */
|
||||
const char *title = i18n_text(client->help_lang,
|
||||
const char *title = i18n_text(client->ui_lang,
|
||||
I18N_COMMAND_OUTPUT_TITLE);
|
||||
char title_display[64];
|
||||
utf8_ansi_truncate(title, title_display, sizeof(title_display), rw);
|
||||
|
|
@ -639,23 +742,50 @@ void tui_render_command_output(client_t *client) {
|
|||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
|
||||
|
||||
/* Command output - use a copy to avoid strtok corruption */
|
||||
char output_copy[2048];
|
||||
char output_copy[MAX_COMMAND_OUTPUT_LEN];
|
||||
strncpy(output_copy, client->command_output, sizeof(output_copy) - 1);
|
||||
output_copy[sizeof(output_copy) - 1] = '\0';
|
||||
|
||||
char *line = strtok(output_copy, "\n");
|
||||
char *lines[256];
|
||||
int line_count = 0;
|
||||
int max_lines = rh - 2;
|
||||
char *line = strtok(output_copy, "\n");
|
||||
while (line && line_count < (int)(sizeof(lines) / sizeof(lines[0]))) {
|
||||
lines[line_count++] = line;
|
||||
line = strtok(NULL, "\n");
|
||||
}
|
||||
|
||||
while (line && line_count < max_lines) {
|
||||
int content_height = rh - 2;
|
||||
if (content_height < 1) content_height = 1;
|
||||
int max_scroll = line_count - content_height;
|
||||
if (max_scroll < 0) max_scroll = 0;
|
||||
if (client->command_output_scroll < 0) client->command_output_scroll = 0;
|
||||
if (client->command_output_scroll > max_scroll) {
|
||||
client->command_output_scroll = max_scroll;
|
||||
}
|
||||
|
||||
int start = client->command_output_scroll;
|
||||
int end = start + content_height;
|
||||
if (end > line_count) end = line_count;
|
||||
|
||||
for (int i = start; i < end; i++) {
|
||||
char truncated[1024];
|
||||
utf8_ansi_truncate(line, truncated, sizeof(truncated), rw);
|
||||
utf8_ansi_truncate(lines[i], truncated, sizeof(truncated), rw);
|
||||
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", truncated);
|
||||
line = strtok(NULL, "\n");
|
||||
line_count++;
|
||||
}
|
||||
|
||||
for (int i = end - start; i < content_height; i++) {
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[K\r\n");
|
||||
}
|
||||
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
client->command_output_kind ==
|
||||
TNT_COMMAND_OUTPUT_INBOX
|
||||
? I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT
|
||||
: I18N_COMMAND_OUTPUT_STATUS_FORMAT),
|
||||
start + 1, max_scroll + 1);
|
||||
|
||||
client_send(client, buffer, pos);
|
||||
}
|
||||
|
||||
|
|
@ -682,7 +812,7 @@ void tui_render_motd(client_t *client) {
|
|||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
|
||||
|
||||
/* Top border with a localized title chip. */
|
||||
const char *title = i18n_text(client->help_lang, I18N_MOTD_TITLE);
|
||||
const char *title = i18n_text(client->ui_lang, I18N_MOTD_TITLE);
|
||||
int title_w = utf8_string_width(title);
|
||||
int top_dash_fill = rw - 2 - title_w - 1; /* 2 corners, 1 leading ─ */
|
||||
if (top_dash_fill < 0) top_dash_fill = 0;
|
||||
|
|
@ -734,7 +864,7 @@ void tui_render_motd(client_t *client) {
|
|||
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
|
||||
|
||||
/* Bottom border with a localized continue hint. */
|
||||
const char *footer = i18n_text(client->help_lang,
|
||||
const char *footer = i18n_text(client->ui_lang,
|
||||
I18N_MOTD_CONTINUE_HINT);
|
||||
int footer_w = utf8_string_width(footer);
|
||||
int bot_dash_fill = rw - 2 - footer_w - 1;
|
||||
|
|
@ -766,7 +896,7 @@ void tui_render_help(client_t *client) {
|
|||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
|
||||
|
||||
/* Title */
|
||||
const char *title = i18n_text(client->help_lang, I18N_HELP_TITLE);
|
||||
const char *title = i18n_text(client->ui_lang, I18N_HELP_TITLE);
|
||||
int title_width = utf8_string_width(title);
|
||||
int padding = rw - title_width;
|
||||
if (padding < 0) padding = 0;
|
||||
|
|
@ -777,11 +907,11 @@ void tui_render_help(client_t *client) {
|
|||
}
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
|
||||
|
||||
/* Help content */
|
||||
const char *help_text = help_text_full(client->help_lang);
|
||||
char help_copy[8192];
|
||||
strncpy(help_copy, help_text, sizeof(help_copy) - 1);
|
||||
help_copy[sizeof(help_copy) - 1] = '\0';
|
||||
size_t help_pos = 0;
|
||||
help_copy[0] = '\0';
|
||||
help_text_append_full(help_copy, sizeof(help_copy), &help_pos,
|
||||
client->ui_lang);
|
||||
|
||||
/* Split into lines and display with scrolling */
|
||||
char *lines[100];
|
||||
|
|
@ -812,7 +942,7 @@ void tui_render_help(client_t *client) {
|
|||
|
||||
/* Status line */
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos,
|
||||
i18n_text(client->help_lang, I18N_HELP_STATUS_FORMAT),
|
||||
i18n_text(client->ui_lang, I18N_HELP_STATUS_FORMAT),
|
||||
start + 1, max_scroll + 1);
|
||||
|
||||
client_send(client, buffer, pos);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,54 @@
|
|||
#include "tui_status.h"
|
||||
#include "i18n.h"
|
||||
#include "ssh_server.h"
|
||||
#include "utf8.h"
|
||||
|
||||
static void format_command_input_tail(const char *input, int avail_width,
|
||||
char *display, size_t display_size) {
|
||||
if (!input || !display || display_size == 0) return;
|
||||
|
||||
display[0] = '\0';
|
||||
if (avail_width < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (utf8_string_width(input) <= avail_width) {
|
||||
strncpy(display, input, display_size - 1);
|
||||
display[display_size - 1] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
const char *marker = "<";
|
||||
int marker_width = 1;
|
||||
int tail_width = avail_width - marker_width;
|
||||
if (tail_width < 1) {
|
||||
snprintf(display, display_size, "%s", marker);
|
||||
return;
|
||||
}
|
||||
|
||||
const char *p = input + strlen(input);
|
||||
const char *tail = p;
|
||||
int width = 0;
|
||||
|
||||
while (p > input && width < tail_width) {
|
||||
const char *q = p - 1;
|
||||
while (q > input && ((*q & 0xC0) == 0x80)) {
|
||||
q--;
|
||||
}
|
||||
|
||||
int bytes_read = 0;
|
||||
uint32_t cp = utf8_decode(q, &bytes_read);
|
||||
int char_width = utf8_char_width(cp);
|
||||
if (width + char_width > tail_width) {
|
||||
break;
|
||||
}
|
||||
width += char_width;
|
||||
tail = q;
|
||||
p = q;
|
||||
}
|
||||
|
||||
snprintf(display, display_size, "%s%s", marker, tail);
|
||||
}
|
||||
|
||||
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
|
||||
const struct client *client, int msg_count,
|
||||
|
|
@ -13,13 +61,13 @@ void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
|
|||
"\033[2;37m›\033[0m "
|
||||
"\033[2;37m%s\033[0m"
|
||||
"\033[K",
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_INSERT_HINT_WIDE));
|
||||
} else if (client->width >= 36) {
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"\033[2;37m›\033[0m "
|
||||
"\033[2;37m%s\033[0m\033[K",
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_INSERT_HINT_NARROW));
|
||||
} else {
|
||||
buffer_appendf(buffer, buf_size, pos, "\033[2;37m›\033[0m \033[K");
|
||||
|
|
@ -36,19 +84,24 @@ void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
|
|||
" \033[2;37m%d-%d / %d\033[0m"
|
||||
" \033[33m▼ %d %s · %s\033[0m\033[K",
|
||||
range_start, range_end, total, unseen,
|
||||
i18n_text(client->help_lang,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_NORMAL_NEW_MESSAGES),
|
||||
i18n_text(client->help_lang, I18N_NORMAL_LATEST));
|
||||
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
|
||||
} else {
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"\033[7;33m NORMAL \033[0m"
|
||||
" \033[2;37m%d-%d / %d\033[0m"
|
||||
" \033[2;37m%s\033[0m\033[K",
|
||||
range_start, range_end, total,
|
||||
i18n_text(client->help_lang, I18N_NORMAL_LATEST));
|
||||
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
|
||||
}
|
||||
} else if (client->mode == MODE_COMMAND) {
|
||||
char display[sizeof(client->command_input) + 2];
|
||||
int avail = client->width - 1;
|
||||
if (avail < 1) avail = 1;
|
||||
format_command_input_tail(client->command_input, avail, display,
|
||||
sizeof(display));
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"\033[35m:\033[0m%s\033[K", client->command_input);
|
||||
"\033[35m:\033[0m%s\033[K", display);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
82
tests/test_cli_options.sh
Executable file
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"
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue