mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:44:38 +08:00
Build public release readiness foundation
This commit is contained in:
parent
94b602613f
commit
33e2dc4f13
40 changed files with 1570 additions and 140 deletions
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
name: Bug Report
|
||||||
|
description: Report a reproducible problem in TNT.
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
For security vulnerabilities, do not open a public issue. See SECURITY.md.
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: Run `tnt --version`, or provide the commit hash.
|
||||||
|
placeholder: "tnt 1.0.1"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: install_method
|
||||||
|
attributes:
|
||||||
|
label: Installation Method
|
||||||
|
options:
|
||||||
|
- GitHub release binary
|
||||||
|
- Source build
|
||||||
|
- install.sh
|
||||||
|
- Package manager draft
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
placeholder: "Ubuntu 24.04 x86_64, Arch Linux, macOS 15 arm64"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Reproduction Steps
|
||||||
|
description: Keep this as small and concrete as possible.
|
||||||
|
placeholder: |
|
||||||
|
1. Start TNT with ...
|
||||||
|
2. Connect with ...
|
||||||
|
3. Run ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant Logs
|
||||||
|
description: Remove secrets, access tokens, and private hostnames.
|
||||||
|
render: text
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/m1ngsama/TNT/security
|
||||||
|
about: Do not open public issues for vulnerabilities. See SECURITY.md for private reporting paths.
|
||||||
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
name: Feature Request
|
||||||
|
description: Suggest a focused improvement to TNT.
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: What workflow or limitation should this improve?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposal
|
||||||
|
description: Describe the smallest useful behavior change.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Area
|
||||||
|
options:
|
||||||
|
- Interactive TUI
|
||||||
|
- SSH exec / scripting
|
||||||
|
- Packaging / release
|
||||||
|
- Operations / systemd
|
||||||
|
- Security
|
||||||
|
- Documentation
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: Optional. Existing commands, scripts, or workflows you tried.
|
||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -6,6 +6,9 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
|
||||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
|
|
@ -5,6 +5,9 @@ on:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build ${{ matrix.target }}
|
name: Build ${{ matrix.target }}
|
||||||
|
|
@ -15,15 +18,19 @@ jobs:
|
||||||
- os: ubuntu-24.04
|
- os: ubuntu-24.04
|
||||||
target: linux-amd64
|
target: linux-amd64
|
||||||
artifact: tnt-linux-amd64
|
artifact: tnt-linux-amd64
|
||||||
|
ctl_artifact: tntctl-linux-amd64
|
||||||
- os: ubuntu-24.04-arm
|
- os: ubuntu-24.04-arm
|
||||||
target: linux-arm64
|
target: linux-arm64
|
||||||
artifact: tnt-linux-arm64
|
artifact: tnt-linux-arm64
|
||||||
|
ctl_artifact: tntctl-linux-arm64
|
||||||
- os: macos-15-intel
|
- os: macos-15-intel
|
||||||
target: darwin-amd64
|
target: darwin-amd64
|
||||||
artifact: tnt-darwin-amd64
|
artifact: tnt-darwin-amd64
|
||||||
|
ctl_artifact: tntctl-darwin-amd64
|
||||||
- os: macos-15
|
- os: macos-15
|
||||||
target: darwin-arm64
|
target: darwin-arm64
|
||||||
artifact: tnt-darwin-arm64
|
artifact: tnt-darwin-arm64
|
||||||
|
ctl_artifact: tntctl-darwin-arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -48,29 +55,38 @@ jobs:
|
||||||
- name: Verify artifact architecture
|
- name: Verify artifact architecture
|
||||||
run: |
|
run: |
|
||||||
file tnt
|
file tnt
|
||||||
|
file tntctl
|
||||||
case "${{ matrix.target }}" in
|
case "${{ matrix.target }}" in
|
||||||
linux-amd64)
|
linux-amd64)
|
||||||
file tnt | grep -E 'ELF 64-bit.*x86-64'
|
file tnt | grep -E 'ELF 64-bit.*x86-64'
|
||||||
|
file tntctl | grep -E 'ELF 64-bit.*x86-64'
|
||||||
;;
|
;;
|
||||||
linux-arm64)
|
linux-arm64)
|
||||||
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||||
|
file tntctl | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||||
;;
|
;;
|
||||||
darwin-amd64)
|
darwin-amd64)
|
||||||
file tnt | grep -E 'Mach-O 64-bit.*x86_64'
|
file tnt | grep -E 'Mach-O 64-bit.*x86_64'
|
||||||
|
file tntctl | grep -E 'Mach-O 64-bit.*x86_64'
|
||||||
;;
|
;;
|
||||||
darwin-arm64)
|
darwin-arm64)
|
||||||
file tnt | grep -E 'Mach-O 64-bit.*arm64'
|
file tnt | grep -E 'Mach-O 64-bit.*arm64'
|
||||||
|
file tntctl | grep -E 'Mach-O 64-bit.*arm64'
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Rename binary
|
- name: Rename binary
|
||||||
run: mv tnt ${{ matrix.artifact }}
|
run: |
|
||||||
|
mv tnt ${{ matrix.artifact }}
|
||||||
|
mv tntctl ${{ matrix.ctl_artifact }}
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.artifact }}
|
name: ${{ matrix.artifact }}
|
||||||
path: ${{ matrix.artifact }}
|
path: |
|
||||||
|
${{ matrix.artifact }}
|
||||||
|
${{ matrix.ctl_artifact }}
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: build
|
needs: build
|
||||||
|
|
@ -90,7 +106,8 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
cd artifacts
|
cd artifacts
|
||||||
: > checksums.txt
|
: > checksums.txt
|
||||||
for artifact in */tnt-*; do
|
for artifact in */tnt-* */tntctl-*; do
|
||||||
|
[ -f "$artifact" ] || continue
|
||||||
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
|
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
|
||||||
done
|
done
|
||||||
cat checksums.txt
|
cat checksums.txt
|
||||||
|
|
@ -100,6 +117,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
artifacts/*/tnt-*
|
artifacts/*/tnt-*
|
||||||
|
artifacts/*/tntctl-*
|
||||||
artifacts/checksums.txt
|
artifacts/checksums.txt
|
||||||
body: |
|
body: |
|
||||||
## Installation
|
## Installation
|
||||||
|
|
@ -109,29 +127,41 @@ jobs:
|
||||||
**Linux AMD64:**
|
**Linux AMD64:**
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64
|
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64
|
||||||
|
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-amd64
|
||||||
chmod +x tnt-linux-amd64
|
chmod +x tnt-linux-amd64
|
||||||
|
chmod +x tntctl-linux-amd64
|
||||||
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
|
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
|
||||||
|
sudo mv tntctl-linux-amd64 /usr/local/bin/tntctl
|
||||||
```
|
```
|
||||||
|
|
||||||
**Linux ARM64:**
|
**Linux ARM64:**
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
|
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
|
||||||
|
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-arm64
|
||||||
chmod +x tnt-linux-arm64
|
chmod +x tnt-linux-arm64
|
||||||
|
chmod +x tntctl-linux-arm64
|
||||||
sudo mv tnt-linux-arm64 /usr/local/bin/tnt
|
sudo mv tnt-linux-arm64 /usr/local/bin/tnt
|
||||||
|
sudo mv tntctl-linux-arm64 /usr/local/bin/tntctl
|
||||||
```
|
```
|
||||||
|
|
||||||
**macOS Intel:**
|
**macOS Intel:**
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
|
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
|
||||||
|
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-amd64
|
||||||
chmod +x tnt-darwin-amd64
|
chmod +x tnt-darwin-amd64
|
||||||
|
chmod +x tntctl-darwin-amd64
|
||||||
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
|
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
|
||||||
|
sudo mv tntctl-darwin-amd64 /usr/local/bin/tntctl
|
||||||
```
|
```
|
||||||
|
|
||||||
**macOS Apple Silicon:**
|
**macOS Apple Silicon:**
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
|
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
|
||||||
|
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-arm64
|
||||||
chmod +x tnt-darwin-arm64
|
chmod +x tnt-darwin-arm64
|
||||||
|
chmod +x tntctl-darwin-arm64
|
||||||
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
|
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
|
||||||
|
sudo mv tntctl-darwin-arm64 /usr/local/bin/tntctl
|
||||||
```
|
```
|
||||||
|
|
||||||
**Verify checksums:**
|
**Verify checksums:**
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
*.o
|
*.o
|
||||||
obj/
|
obj/
|
||||||
tnt
|
tnt
|
||||||
|
tntctl
|
||||||
messages.log
|
messages.log
|
||||||
host_key
|
host_key
|
||||||
host_key.pub
|
host_key.pub
|
||||||
|
|
|
||||||
32
Makefile
32
Makefile
|
|
@ -20,10 +20,13 @@ SRC_DIR = src
|
||||||
INC_DIR = include
|
INC_DIR = include
|
||||||
OBJ_DIR = obj
|
OBJ_DIR = obj
|
||||||
|
|
||||||
SOURCES = $(wildcard $(SRC_DIR)/*.c)
|
SOURCES = $(filter-out $(SRC_DIR)/tntctl.c,$(wildcard $(SRC_DIR)/*.c))
|
||||||
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
|
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
|
||||||
DEPS = $(OBJECTS:.o=.d)
|
DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d)
|
||||||
TARGET = tnt
|
TARGET = tnt
|
||||||
|
CTL_TARGET = tntctl
|
||||||
|
CTL_OBJECTS = $(OBJ_DIR)/tntctl.o
|
||||||
|
TARGETS = $(TARGET) $(CTL_TARGET)
|
||||||
|
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
BINDIR ?= $(PREFIX)/bin
|
BINDIR ?= $(PREFIX)/bin
|
||||||
|
|
@ -33,12 +36,16 @@ 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 asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info
|
||||||
|
|
||||||
all: $(TARGET)
|
all: $(TARGETS)
|
||||||
|
|
||||||
$(TARGET): $(OBJECTS)
|
$(TARGET): $(OBJECTS)
|
||||||
$(CC) $(OBJECTS) -o $@ $(LDFLAGS)
|
$(CC) $(OBJECTS) -o $@ $(LDFLAGS)
|
||||||
@echo "Build complete: $(TARGET)"
|
@echo "Build complete: $(TARGET)"
|
||||||
|
|
||||||
|
$(CTL_TARGET): $(CTL_OBJECTS)
|
||||||
|
$(CC) $(CTL_OBJECTS) -o $@
|
||||||
|
@echo "Build complete: $(CTL_TARGET)"
|
||||||
|
|
||||||
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
|
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
|
||||||
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
|
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
|
||||||
|
|
||||||
|
|
@ -46,34 +53,40 @@ $(OBJ_DIR):
|
||||||
mkdir -p $(OBJ_DIR)
|
mkdir -p $(OBJ_DIR)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(OBJ_DIR) $(TARGET)
|
rm -rf $(OBJ_DIR) $(TARGETS)
|
||||||
rm -f tests/*.log tests/host_key* tests/messages.log
|
rm -f tests/*.log tests/host_key* tests/messages.log
|
||||||
@echo "Clean complete"
|
@echo "Clean complete"
|
||||||
|
|
||||||
install: $(TARGET)
|
install: $(TARGETS)
|
||||||
install -d $(DESTDIR)$(BINDIR)
|
install -d $(DESTDIR)$(BINDIR)
|
||||||
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
|
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
|
||||||
|
install -m 755 $(CTL_TARGET) $(DESTDIR)$(BINDIR)/
|
||||||
install -d $(DESTDIR)$(MANDIR)/man1
|
install -d $(DESTDIR)$(MANDIR)/man1
|
||||||
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
|
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
|
||||||
|
install -m 644 tntctl.1 $(DESTDIR)$(MANDIR)/man1/
|
||||||
|
|
||||||
install-systemd:
|
install-systemd:
|
||||||
install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR)
|
install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR)
|
||||||
install -m 644 tnt.service $(DESTDIR)$(SYSTEMD_UNIT_DIR)/
|
sed 's#^ExecStart=.*#ExecStart=$(BINDIR)/$(TARGET)#' tnt.service > "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
|
||||||
|
chmod 644 "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
|
||||||
|
|
||||||
uninstall:
|
uninstall:
|
||||||
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
|
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
|
||||||
|
rm -f $(DESTDIR)$(BINDIR)/$(CTL_TARGET)
|
||||||
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
|
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
|
||||||
|
rm -f $(DESTDIR)$(MANDIR)/man1/tntctl.1
|
||||||
|
|
||||||
uninstall-systemd:
|
uninstall-systemd:
|
||||||
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
|
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
|
||||||
|
|
||||||
# Development targets
|
# Development targets
|
||||||
debug: CFLAGS += -g -DDEBUG
|
debug: CFLAGS += -g -DDEBUG
|
||||||
debug: clean $(TARGET)
|
debug: clean $(TARGETS)
|
||||||
|
|
||||||
release: CFLAGS += -O3 -DNDEBUG
|
release: CFLAGS += -O3 -DNDEBUG
|
||||||
release: clean $(TARGET)
|
release: clean $(TARGETS)
|
||||||
strip $(TARGET)
|
strip $(TARGET)
|
||||||
|
strip $(CTL_TARGET)
|
||||||
|
|
||||||
release-check:
|
release-check:
|
||||||
./scripts/release_check.sh
|
./scripts/release_check.sh
|
||||||
|
|
@ -83,7 +96,7 @@ release-check-strict:
|
||||||
|
|
||||||
asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
|
asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
|
||||||
asan: LDFLAGS += -fsanitize=address
|
asan: LDFLAGS += -fsanitize=address
|
||||||
asan: clean $(TARGET)
|
asan: clean $(TARGETS)
|
||||||
@echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt"
|
@echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt"
|
||||||
|
|
||||||
valgrind: debug
|
valgrind: debug
|
||||||
|
|
@ -112,6 +125,7 @@ integration-test: all
|
||||||
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh
|
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh
|
||||||
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh
|
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh
|
||||||
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh
|
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh
|
||||||
|
@cd tests && ./test_tntctl_cli.sh
|
||||||
|
|
||||||
anonymous-access-test: all
|
anonymous-access-test: all
|
||||||
@echo "Running anonymous access tests..."
|
@echo "Running anonymous access tests..."
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -21,8 +21,9 @@ A minimalist terminal chat server with Vim-style interface over SSH.
|
||||||
```sh
|
```sh
|
||||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||||
```
|
```
|
||||||
The installer verifies the downloaded release binary against `checksums.txt`
|
The installer verifies downloaded release binaries against `checksums.txt`
|
||||||
before installing it.
|
before installing them. Older releases may provide only `tnt`; newer releases
|
||||||
|
also install `tntctl`.
|
||||||
|
|
||||||
**From source:**
|
**From source:**
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -183,6 +184,18 @@ ssh -p 2222 chat.example.com post "/me deploys v2.0"
|
||||||
|
|
||||||
**`post` identity**: the message is attributed to the SSH login name (the `user@` part of the URL, falling back to `anonymous`). In the default anonymous-access configuration there is no identity check, so any client can post as any name. Set `TNT_ACCESS_TOKEN` if you need authenticated posting.
|
**`post` identity**: the message is attributed to the SSH login name (the `user@` part of the URL, falling back to `anonymous`). In the default anonymous-access configuration there is no identity check, so any client can post as any name. Set `TNT_ACCESS_TOKEN` if you need authenticated posting.
|
||||||
|
|
||||||
|
See [docs/INTERFACE.md](docs/INTERFACE.md) for the stable exec command
|
||||||
|
contract, exit statuses, and JSON field definitions.
|
||||||
|
|
||||||
|
Source and package-manager installs also include `tntctl`, a thin wrapper
|
||||||
|
around the same SSH exec interface:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tntctl chat.example.com health
|
||||||
|
tntctl -p 2222 chat.example.com stats --json
|
||||||
|
tntctl -l operator chat.example.com post "service notice"
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
@ -254,6 +267,7 @@ TNT/
|
||||||
│ ├── commands.c # COMMAND-mode command dispatch
|
│ ├── commands.c # COMMAND-mode command dispatch
|
||||||
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
|
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
|
||||||
│ ├── exec.c # SSH exec command dispatch
|
│ ├── exec.c # SSH exec command dispatch
|
||||||
|
│ ├── tntctl.c # local wrapper around the SSH exec interface
|
||||||
│ ├── ssh_server.c # SSH server implementation
|
│ ├── ssh_server.c # SSH server implementation
|
||||||
│ ├── bootstrap.c # SSH authentication and session bootstrap
|
│ ├── bootstrap.c # SSH authentication and session bootstrap
|
||||||
│ ├── chat_room.c # chat room logic
|
│ ├── chat_room.c # chat room logic
|
||||||
|
|
@ -358,6 +372,7 @@ Delete `motd.txt` to disable the MOTD.
|
||||||
- [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual
|
- [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual
|
||||||
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
|
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
|
||||||
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
|
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
|
||||||
|
- [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields
|
||||||
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
|
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
|
||||||
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
|
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
|
||||||
- [Changelog](docs/CHANGELOG.md) - Version history
|
- [Changelog](docs/CHANGELOG.md) - Version history
|
||||||
|
|
|
||||||
61
SECURITY.md
Normal file
61
SECURITY.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
TNT currently supports security fixes for the latest published release and the
|
||||||
|
current `main` branch.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---|---|
|
||||||
|
| latest release | yes |
|
||||||
|
| `main` | best effort |
|
||||||
|
| older releases | no |
|
||||||
|
|
||||||
|
This policy will become stricter after TNT has a longer stable release history.
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Do not open a public issue for a security vulnerability.
|
||||||
|
|
||||||
|
Report privately through one of these paths:
|
||||||
|
|
||||||
|
- GitHub private vulnerability reporting, when available on the repository
|
||||||
|
- email: `contact@m1ng.space`
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- affected version or commit
|
||||||
|
- operating system and deployment shape
|
||||||
|
- reproduction steps or proof of concept
|
||||||
|
- expected impact
|
||||||
|
- whether the issue is already public
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
The maintainer will try to acknowledge valid reports within 7 days. Fixes may
|
||||||
|
land on `main` before a release is published. For serious issues, the release
|
||||||
|
notes will mention the security impact after users have a reasonable upgrade
|
||||||
|
path.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- remote crashes or memory-safety bugs
|
||||||
|
- authentication or access-token bypass
|
||||||
|
- unintended file writes outside `TNT_STATE_DIR`
|
||||||
|
- privilege escalation in packaged service configuration
|
||||||
|
- release artifact tampering or installer verification bypass
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- denial of service from an operator intentionally disabling rate limits
|
||||||
|
- identity spoofing in the documented anonymous-access mode
|
||||||
|
- vulnerabilities requiring local administrator access to the host
|
||||||
|
|
||||||
|
## Release Integrity
|
||||||
|
|
||||||
|
Release binaries are published with `checksums.txt`. The installer verifies
|
||||||
|
the selected binary against that file before installation. Future releases
|
||||||
|
should add a detached signature for `checksums.txt` before package recipes are
|
||||||
|
submitted to public registries.
|
||||||
|
|
@ -2,7 +2,48 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Documented the stable SSH exec interface contract, including exit statuses
|
||||||
|
and JSON field shapes for package tests, scripts, and future `tntctl` work.
|
||||||
|
- 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.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- `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`.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
- Split UI-language parsing from localized text lookup: `src/i18n.c` now owns
|
- Split UI-language parsing from localized text lookup: `src/i18n.c` now owns
|
||||||
locale/code parsing, while `src/i18n_text.c` owns the table-driven text
|
locale/code parsing, while `src/i18n_text.c` owns the table-driven text
|
||||||
catalog with coverage checks for every message ID.
|
catalog with coverage checks for every message ID.
|
||||||
|
|
|
||||||
64
docs/CICD.md
64
docs/CICD.md
|
|
@ -19,37 +19,87 @@ into production or restart services on push.
|
||||||
|
|
||||||
CREATING RELEASES
|
CREATING RELEASES
|
||||||
-----------------
|
-----------------
|
||||||
|
Release policy:
|
||||||
|
- Use SemVer-style tags: vMAJOR.MINOR.PATCH.
|
||||||
|
- Bump PATCH for compatible bug fixes and release hardening.
|
||||||
|
- Bump MINOR for new commands, new documented flags, JSON field additions,
|
||||||
|
or visible user-interface behavior changes.
|
||||||
|
- Bump MAJOR for incompatible command, config, storage, or package behavior.
|
||||||
|
- Keep GitHub draft release review manual. Do not auto-publish releases.
|
||||||
|
- Keep production deployment manual. Do not SSH into production from CI.
|
||||||
|
|
||||||
1. Update version metadata:
|
1. Update version metadata:
|
||||||
- include/common.h
|
- include/common.h
|
||||||
- tnt.1
|
- tnt.1
|
||||||
- docs/CHANGELOG.md
|
- docs/CHANGELOG.md
|
||||||
- packaging/arch/PKGBUILD
|
- packaging/arch/PKGBUILD
|
||||||
- packaging/homebrew/tnt-chat.rb
|
- packaging/homebrew/tnt-chat.rb
|
||||||
|
- packaging/debian/debian/changelog
|
||||||
|
- package checksums and maintainer metadata, when preparing public package
|
||||||
|
recipes
|
||||||
|
|
||||||
2. Run the local preflight:
|
2. Run the local preflight:
|
||||||
make release-check
|
make release-check
|
||||||
|
|
||||||
3. Replace package checksum placeholders and run:
|
3. Commit the release changes and create a local tag. Do not push the tag
|
||||||
|
until strict checks pass:
|
||||||
|
git tag v1.0.1
|
||||||
|
|
||||||
|
4. Run strict release checks:
|
||||||
make release-check-strict
|
make release-check-strict
|
||||||
|
|
||||||
4. Create and push tag:
|
Strict mode requires the local `vX.Y.Z` tag to point at HEAD. It also
|
||||||
git tag v1.0.1
|
builds from the tagged source archive, so it catches files that were left
|
||||||
|
untracked and would be missing from GitHub's source archive.
|
||||||
|
|
||||||
|
5. Push the tag:
|
||||||
git push origin v1.0.1
|
git push origin v1.0.1
|
||||||
|
|
||||||
5. GitHub Actions automatically:
|
6. GitHub Actions automatically:
|
||||||
- Builds binaries (Linux/macOS, AMD64/ARM64)
|
- Builds `tnt` and `tntctl` binaries (Linux/macOS, AMD64/ARM64)
|
||||||
- Creates a draft release
|
- Creates a draft release
|
||||||
- Uploads binaries
|
- Uploads binaries
|
||||||
- Generates one `checksums.txt` file
|
- Generates one `checksums.txt` file
|
||||||
- Verifies that artifact architecture matches the asset name
|
- Verifies that artifact architecture matches the asset name
|
||||||
|
|
||||||
6. Review the draft release, smoke-test downloaded assets, then publish it
|
7. Review the draft release, smoke-test downloaded assets, then publish it
|
||||||
manually from GitHub.
|
manually from GitHub.
|
||||||
|
|
||||||
7. Release appears at:
|
8. Release appears at:
|
||||||
https://github.com/m1ngsama/TNT/releases
|
https://github.com/m1ngsama/TNT/releases
|
||||||
|
|
||||||
|
|
||||||
|
RELEASE REVIEW CHECKLIST
|
||||||
|
------------------------
|
||||||
|
Before publishing a draft release:
|
||||||
|
- Confirm `git tag` points at the intended commit.
|
||||||
|
- Download every release asset from GitHub, not from the local workspace.
|
||||||
|
- Verify `checksums.txt` with `sha256sum -c checksums.txt`.
|
||||||
|
- Run downloaded `tnt --version` and `tntctl --version`.
|
||||||
|
- Start a temporary server and check:
|
||||||
|
ssh -p 2222 server health
|
||||||
|
ssh -p 2222 server stats --json
|
||||||
|
ssh -p 2222 server users --json
|
||||||
|
ssh -p 2222 operator@server post "release smoke"
|
||||||
|
ssh -p 2222 server "tail -n 1"
|
||||||
|
- Check runtime dynamic links (`ldd` on Linux, `otool -L` on macOS) and make
|
||||||
|
sure `libssh` is documented for the target install path.
|
||||||
|
- Confirm `make release-check-strict` passed after package checksums were
|
||||||
|
replaced.
|
||||||
|
|
||||||
|
|
||||||
|
ROLLBACK
|
||||||
|
--------
|
||||||
|
Production rollback stays manual:
|
||||||
|
1. Keep the previous binary before replacing it.
|
||||||
|
2. Stop or restart only the intended `tnt` service.
|
||||||
|
3. Restore the previous binary if smoke checks fail.
|
||||||
|
4. Re-run `health`, `stats --json`, and one post/tail smoke test.
|
||||||
|
|
||||||
|
Do not overwrite `TNT_STATE_DIR` during rollback. If a future release changes
|
||||||
|
the message log format, its release notes must include the downgrade behavior.
|
||||||
|
|
||||||
|
|
||||||
DEPLOYING TO SERVERS
|
DEPLOYING TO SERVERS
|
||||||
--------------------
|
--------------------
|
||||||
Deployments are operator-driven:
|
Deployments are operator-driven:
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
|
||||||
commands.c → COMMAND-mode command dispatch
|
commands.c → COMMAND-mode command dispatch
|
||||||
exec_catalog.c → SSH exec command matching, usage, and argument shape
|
exec_catalog.c → SSH exec command matching, usage, and argument shape
|
||||||
exec.c → SSH exec command dispatch
|
exec.c → SSH exec command dispatch
|
||||||
|
tntctl.c → local wrapper around the SSH exec interface
|
||||||
ssh_server.c → SSH listener setup
|
ssh_server.c → SSH listener setup
|
||||||
bootstrap.c → SSH authentication/session bootstrap
|
bootstrap.c → SSH authentication/session bootstrap
|
||||||
input.c → interactive session loop
|
input.c → interactive session loop
|
||||||
|
|
@ -69,7 +70,7 @@ utf8.c → UTF-8 string handling
|
||||||
|
|
||||||
## Known Limits
|
## Known Limits
|
||||||
|
|
||||||
- Max 64 clients (MAX_CLIENTS)
|
- Default 64 clients, configurable with `TNT_MAX_CONNECTIONS`
|
||||||
- Max 100 messages in memory (MAX_MESSAGES)
|
- Max 100 messages in memory (MAX_MESSAGES)
|
||||||
- Max 1024 bytes per message (MAX_MESSAGE_LEN)
|
- Max 1024 bytes per message (MAX_MESSAGE_LEN)
|
||||||
- Max 64 bytes username (MAX_USERNAME_LEN)
|
- Max 64 bytes username (MAX_USERNAME_LEN)
|
||||||
|
|
|
||||||
150
docs/INTERFACE.md
Normal file
150
docs/INTERFACE.md
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
# Interface Contract
|
||||||
|
|
||||||
|
This document defines the public surfaces that scripts, package tests, and
|
||||||
|
operators may rely on.
|
||||||
|
|
||||||
|
TNT is still evolving toward a split `tntd` / `tntctl` model. The stable
|
||||||
|
control surface is the SSH exec interface exposed by the `tnt` daemon.
|
||||||
|
`tntctl` is a thin wrapper around that same interface.
|
||||||
|
|
||||||
|
## Stability Scope
|
||||||
|
|
||||||
|
Stable:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Not yet stable:
|
||||||
|
|
||||||
|
- exact human-readable diagnostic wording
|
||||||
|
- interactive TUI layout
|
||||||
|
- on-disk message log format
|
||||||
|
- 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 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 -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.
|
||||||
|
|
||||||
|
### `post MESSAGE`
|
||||||
|
|
||||||
|
Posts a message as the SSH login name and prints:
|
||||||
|
|
||||||
|
```text
|
||||||
|
posted
|
||||||
|
```
|
||||||
|
|
||||||
|
In anonymous-access mode, the SSH login name is not authenticated. Operators
|
||||||
|
should configure `TNT_ACCESS_TOKEN` before relying on exec-post identity.
|
||||||
|
|
||||||
|
### `help`
|
||||||
|
|
||||||
|
Prints a localized human-readable command summary. It is intended for people,
|
||||||
|
not parsers.
|
||||||
|
|
||||||
|
## `tntctl`
|
||||||
|
|
||||||
|
`tntctl` preserves the command names, exit statuses, and JSON schemas above.
|
||||||
|
It invokes the local `ssh(1)` client without a local shell. OpenSSH transport
|
||||||
|
failures are mapped to `TNT_EXIT_UNAVAILABLE` (`69`); remote TNT exec statuses
|
||||||
|
are otherwise returned unchanged.
|
||||||
|
|
||||||
|
The wrapper intentionally does not accept arbitrary SSH options or a password
|
||||||
|
option. It exposes only bounded host-key options:
|
||||||
|
`--host-key-checking yes|accept-new|no` and `--known-hosts FILE`. Use normal
|
||||||
|
SSH configuration for jump hosts, identity files, and authentication. If the
|
||||||
|
server requires `TNT_ACCESS_TOKEN`, enter it through the normal SSH password
|
||||||
|
prompt or use an SSH setup appropriate for the deployment.
|
||||||
|
|
@ -17,26 +17,33 @@ This roadmap is intentionally strict. Each stage should leave the project easier
|
||||||
|
|
||||||
Goal: make TNT predictable for operators, scripts, and package maintainers.
|
Goal: make TNT predictable for operators, scripts, and package maintainers.
|
||||||
|
|
||||||
- split the current surface into `tntd` (daemon) and `tntctl` (control client)
|
- ✅ introduce `tntctl` as a thin control client over the stable SSH exec surface
|
||||||
- keep SSH exec support, but treat it as a transport for stable commands rather than the primary API shape
|
- keep SSH exec support, but treat it as a transport for stable commands rather
|
||||||
- define stable subcommands and exit codes for:
|
than an ad hoc command surface
|
||||||
|
- ✅ define stable subcommands and exit codes for:
|
||||||
- `health`
|
- `health`
|
||||||
- `stats`
|
- `stats`
|
||||||
- `users`
|
- `users`
|
||||||
- `tail`
|
- `tail`
|
||||||
- `post`
|
- `post`
|
||||||
- support text and JSON output modes where machine use is likely
|
- ✅ support text and JSON output modes where machine use is likely
|
||||||
- normalize command parsing, help text, and error reporting
|
- ✅ normalize command parsing, help text, and error reporting
|
||||||
|
- decide whether the server binary should remain `tnt` or split later into a
|
||||||
|
separate `tntd` daemon name
|
||||||
- add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently
|
- add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently
|
||||||
- add a man page for `tntd` and `tntctl`
|
- ✅ add man pages for `tnt` and `tntctl`
|
||||||
|
|
||||||
## Stage 2: Runtime Model
|
## Stage 2: Runtime Model
|
||||||
|
|
||||||
Goal: make long-running operation boring and reliable.
|
Goal: make long-running operation boring and reliable.
|
||||||
|
|
||||||
- move client state to a clearer ownership model with one release path
|
- move client state to a clearer ownership model with one release path
|
||||||
- finish replacing ad hoc cross-thread UI mutation with per-client event delivery
|
- ✅ remove cross-client SSH channel writes from mention and private-message
|
||||||
- add bounded outbound queues so slow clients cannot stall other users
|
notifications
|
||||||
|
- continue replacing ad hoc cross-thread UI mutation with per-client event
|
||||||
|
delivery
|
||||||
|
- add bounded outbound queues so slow clients cannot stall their own session
|
||||||
|
loop indefinitely
|
||||||
- separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly
|
- separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly
|
||||||
- make room/client capacity fully runtime-configurable with no hidden compile-time ceiling
|
- make room/client capacity fully runtime-configurable with no hidden compile-time ceiling
|
||||||
- document hard guarantees and soft limits
|
- document hard guarantees and soft limits
|
||||||
|
|
@ -73,7 +80,7 @@ Goal: make public deployment manageable.
|
||||||
|
|
||||||
- provide clear distinction between concurrent session limits and connection-rate limits
|
- provide clear distinction between concurrent session limits and connection-rate limits
|
||||||
- add admin-only controls for read-only mode, mute, and ban
|
- add admin-only controls for read-only mode, mute, and ban
|
||||||
- expose a minimal health and stats surface suitable for monitoring
|
- ✅ expose a minimal health and stats surface suitable for monitoring
|
||||||
- support systemd-friendly readiness and watchdog behavior
|
- support systemd-friendly readiness and watchdog behavior
|
||||||
- document recommended production defaults for public, private, and localhost-only deployments
|
- document recommended production defaults for public, private, and localhost-only deployments
|
||||||
- tighten CI around authentication, limits, and restart behavior
|
- tighten CI around authentication, limits, and restart behavior
|
||||||
|
|
@ -92,8 +99,12 @@ Goal: make regressions harder to introduce.
|
||||||
|
|
||||||
These are the next changes that should happen before new feature work expands the surface area.
|
These are the next changes that should happen before new feature work expands the surface area.
|
||||||
|
|
||||||
1. Introduce `tntctl` and move stable command handling behind it.
|
1. Decide the daemon naming path: keep `tnt` as the server binary for 1.x, or
|
||||||
2. Define exit codes and JSON schemas for `health`, `stats`, `users`, `tail`, and `post`.
|
introduce `tntd` later with a compatibility plan.
|
||||||
3. Add per-client outbound queues and finish untangling client-state ownership.
|
2. Add per-client outbound queues and finish untangling client-state ownership.
|
||||||
4. Remove the remaining hidden runtime limits and make them explicit configuration.
|
3. Remove the remaining hidden runtime limits and make them explicit
|
||||||
5. Add a long-running soak test that exercises idle sessions, reconnects, and slow consumers.
|
configuration.
|
||||||
|
4. Add a long-running soak test that exercises idle sessions, reconnects, and
|
||||||
|
slow consumers.
|
||||||
|
5. Replace remaining release placeholders with real maintainer metadata and
|
||||||
|
source-archive checksums when cutting a public package release.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@
|
||||||
* success, -1 if the channel is gone or a partial write fails. */
|
* success, -1 if the channel is gone or a partial write fails. */
|
||||||
int client_send(client_t *client, const char *data, size_t len);
|
int client_send(client_t *client, const char *data, size_t len);
|
||||||
|
|
||||||
|
/* Queue an audible bell for the client's own session loop to send. This
|
||||||
|
* avoids writing to another client's SSH channel from the sender's thread. */
|
||||||
|
void client_queue_bell(client_t *client);
|
||||||
|
|
||||||
|
/* Send one queued bell, if present, from the client's own session loop.
|
||||||
|
* Returns 0 when no bell was pending or it was written successfully. */
|
||||||
|
int client_flush_pending_bells(client_t *client);
|
||||||
|
|
||||||
/* printf-style wrapper around client_send(). The formatted string must
|
/* printf-style wrapper around client_send(). The formatted string must
|
||||||
* fit in 2048 bytes; truncation or encoding errors return -1. */
|
* fit in 2048 bytes; truncation or encoding errors return -1. */
|
||||||
int client_printf(client_t *client, const char *fmt, ...);
|
int client_printf(client_t *client, const char *fmt, ...);
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,8 @@
|
||||||
* - Toggles client->mute_joins on `:mute-joins`
|
* - Toggles client->mute_joins on `:mute-joins`
|
||||||
* - May broadcast a system rename message on `:nick`
|
* - May broadcast a system rename message on `:nick`
|
||||||
*
|
*
|
||||||
* Reads g_room. Caller must already hold the channel I/O serialisation
|
* Reads g_room. Renders command output through the normal client_send()
|
||||||
* established by handle_key() — this function calls back into client_send
|
* path; callers must not hold client->io_lock before dispatching. */
|
||||||
* (via tui_render_command_output) which acquires client->io_lock. */
|
|
||||||
void commands_dispatch(client_t *client);
|
void commands_dispatch(client_t *client);
|
||||||
|
|
||||||
#endif /* COMMANDS_H */
|
#endif /* COMMANDS_H */
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@
|
||||||
/* Project Metadata */
|
/* Project Metadata */
|
||||||
#define TNT_VERSION "1.0.1"
|
#define TNT_VERSION "1.0.1"
|
||||||
|
|
||||||
|
/* Public process/exec exit statuses. TNT follows the common sysexits(3)
|
||||||
|
* convention for usage errors while keeping runtime failures portable. */
|
||||||
|
#define TNT_EXIT_OK 0
|
||||||
|
#define TNT_EXIT_ERROR 1
|
||||||
|
#define TNT_EXIT_USAGE 64
|
||||||
|
#define TNT_EXIT_UNAVAILABLE 69
|
||||||
|
#define TNT_EXIT_CONFIG 78
|
||||||
|
|
||||||
/* Configuration constants */
|
/* Configuration constants */
|
||||||
#define DEFAULT_PORT 2222
|
#define DEFAULT_PORT 2222
|
||||||
#define MAX_MESSAGES 100
|
#define MAX_MESSAGES 100
|
||||||
|
|
@ -21,7 +29,8 @@
|
||||||
#define MAX_MESSAGE_LEN 1024
|
#define MAX_MESSAGE_LEN 1024
|
||||||
#define MAX_EXEC_COMMAND_LEN 1024
|
#define MAX_EXEC_COMMAND_LEN 1024
|
||||||
#define MAX_COMMAND_OUTPUT_LEN 8192
|
#define MAX_COMMAND_OUTPUT_LEN 8192
|
||||||
#define MAX_CLIENTS 64
|
#define DEFAULT_MAX_CLIENTS 64
|
||||||
|
#define MAX_CONFIGURED_CLIENTS 1024
|
||||||
#define LOG_FILE "messages.log"
|
#define LOG_FILE "messages.log"
|
||||||
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
|
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
|
||||||
#define HOST_KEY_FILE "host_key"
|
#define HOST_KEY_FILE "host_key"
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
/* Dispatch the non-interactive SSH exec command stored in
|
/* Dispatch the non-interactive SSH exec command stored in
|
||||||
* client->exec_command. Returns the exit status to send back to the
|
* client->exec_command. Returns the exit status to send back to the
|
||||||
* SSH client:
|
* SSH client:
|
||||||
* 0 = success
|
* TNT_EXIT_OK = success
|
||||||
* 1 = runtime error (I/O, OOM, persistence failure)
|
* TNT_EXIT_ERROR = runtime error (I/O, OOM, persistence failure)
|
||||||
* 64 = usage error (unknown command, bad args)
|
* TNT_EXIT_USAGE = usage error (unknown command, bad args)
|
||||||
*
|
*
|
||||||
* Reads g_room and shared client state. Safe to call once per
|
* Reads g_room and shared client state. Safe to call once per
|
||||||
* exec-mode session before the channel is closed. */
|
* exec-mode session before the channel is closed. */
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ typedef enum {
|
||||||
I18N_UNKNOWN_GUIDANCE,
|
I18N_UNKNOWN_GUIDANCE,
|
||||||
I18N_EXEC_POST_EMPTY,
|
I18N_EXEC_POST_EMPTY,
|
||||||
I18N_EXEC_POST_INVALID_UTF8,
|
I18N_EXEC_POST_INVALID_UTF8,
|
||||||
|
I18N_EXEC_POST_TOO_LONG,
|
||||||
|
I18N_EXEC_POST_PERSIST_FAILED,
|
||||||
|
I18N_EXEC_COMMAND_TOO_LONG,
|
||||||
I18N_EXEC_UNKNOWN_COMMAND_FORMAT,
|
I18N_EXEC_UNKNOWN_COMMAND_FORMAT,
|
||||||
I18N_TEXT_COUNT
|
I18N_TEXT_COUNT
|
||||||
} i18n_text_id_t;
|
} i18n_text_id_t;
|
||||||
|
|
|
||||||
|
|
@ -44,14 +44,16 @@ typedef struct client {
|
||||||
int command_output_scroll;
|
int command_output_scroll;
|
||||||
bool show_motd; /* command_output holds MOTD text */
|
bool show_motd; /* command_output holds MOTD text */
|
||||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||||
|
bool exec_command_too_long;
|
||||||
char ssh_login[MAX_USERNAME_LEN];
|
char ssh_login[MAX_USERNAME_LEN];
|
||||||
time_t connect_time;
|
time_t connect_time;
|
||||||
time_t last_active;
|
time_t last_active;
|
||||||
atomic_bool redraw_pending;
|
atomic_bool redraw_pending;
|
||||||
|
_Atomic int pending_bells; /* Bell nudges for this client's loop */
|
||||||
_Atomic int unread_mentions; /* @-mentions received since last reset */
|
_Atomic int unread_mentions; /* @-mentions received since last reset */
|
||||||
_Atomic int unread_whispers; /* whispers received since last :inbox view */
|
_Atomic int unread_whispers; /* whispers received since last :inbox view */
|
||||||
/* Per-client whisper inbox. Pushes serialise on io_lock; readers are
|
/* Per-client whisper inbox. Protected separately from SSH channel I/O
|
||||||
* the client's own thread inside :inbox handling. */
|
* so slow writes do not block in-memory private-message delivery. */
|
||||||
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
|
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
|
||||||
int whisper_inbox_count;
|
int whisper_inbox_count;
|
||||||
bool mute_joins;
|
bool mute_joins;
|
||||||
|
|
@ -60,6 +62,7 @@ typedef struct client {
|
||||||
int ref_count; /* Reference count for safe cleanup */
|
int ref_count; /* Reference count for safe cleanup */
|
||||||
pthread_mutex_t ref_lock; /* Lock for ref_count */
|
pthread_mutex_t ref_lock; /* Lock for ref_count */
|
||||||
pthread_mutex_t io_lock; /* Serialize SSH channel writes */
|
pthread_mutex_t io_lock; /* Serialize SSH channel writes */
|
||||||
|
pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */
|
||||||
struct ssh_channel_callbacks_struct *channel_cb;
|
struct ssh_channel_callbacks_struct *channel_cb;
|
||||||
} client_t;
|
} client_t;
|
||||||
|
|
||||||
|
|
|
||||||
61
install.sh
61
install.sh
|
|
@ -45,7 +45,8 @@ case "$ARCH" in
|
||||||
*) fail "Unsupported architecture: $ARCH" ;;
|
*) fail "Unsupported architecture: $ARCH" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
BINARY="tnt-${OS}-${ARCH}"
|
SERVER_BINARY="tnt-${OS}-${ARCH}"
|
||||||
|
CTL_BINARY="tntctl-${OS}-${ARCH}"
|
||||||
|
|
||||||
echo "=== TNT Installer ==="
|
echo "=== TNT Installer ==="
|
||||||
echo "OS: $OS"
|
echo "OS: $OS"
|
||||||
|
|
@ -65,51 +66,81 @@ fi
|
||||||
echo "Installing version: $VERSION"
|
echo "Installing version: $VERSION"
|
||||||
|
|
||||||
# Download
|
# Download
|
||||||
URL="https://github.com/$REPO/releases/download/$VERSION/$BINARY"
|
SERVER_URL="https://github.com/$REPO/releases/download/$VERSION/$SERVER_BINARY"
|
||||||
|
CTL_URL="https://github.com/$REPO/releases/download/$VERSION/$CTL_BINARY"
|
||||||
CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt"
|
CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt"
|
||||||
echo "Downloading from: $URL"
|
echo "Downloading from: $SERVER_URL"
|
||||||
|
|
||||||
TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
|
SERVER_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
|
||||||
|
CTL_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tntctl.XXXXXX")
|
||||||
CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX")
|
CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX")
|
||||||
|
INSTALL_CTL=0
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -f "$TMP_FILE" "$CHECKSUM_FILE"
|
rm -f "$SERVER_TMP_FILE" "$CTL_TMP_FILE" "$CHECKSUM_FILE"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
curl -fsSL -o "$TMP_FILE" "$URL" || fail "Failed to download $BINARY"
|
curl -fsSL -o "$SERVER_TMP_FILE" "$SERVER_URL" ||
|
||||||
|
fail "Failed to download $SERVER_BINARY"
|
||||||
|
|
||||||
echo "Downloading checksums from: $CHECKSUM_URL"
|
echo "Downloading checksums from: $CHECKSUM_URL"
|
||||||
curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" ||
|
curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" ||
|
||||||
fail "Failed to download checksums.txt"
|
fail "Failed to download checksums.txt"
|
||||||
|
|
||||||
EXPECTED_SHA=$(awk -v name="$BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
EXPECTED_SERVER_SHA=$(awk -v name="$SERVER_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||||
[ -n "$EXPECTED_SHA" ] || fail "No checksum entry found for $BINARY"
|
[ -n "$EXPECTED_SERVER_SHA" ] || fail "No checksum entry found for $SERVER_BINARY"
|
||||||
|
EXPECTED_CTL_SHA=$(awk -v name="$CTL_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||||
|
|
||||||
ACTUAL_SHA=$(sha256_of "$TMP_FILE") ||
|
ACTUAL_SERVER_SHA=$(sha256_of "$SERVER_TMP_FILE") ||
|
||||||
fail "sha256sum or shasum is required for checksum verification"
|
fail "sha256sum or shasum is required for checksum verification"
|
||||||
|
|
||||||
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] ||
|
[ "$ACTUAL_SERVER_SHA" = "$EXPECTED_SERVER_SHA" ] ||
|
||||||
fail "Checksum mismatch for $BINARY"
|
fail "Checksum mismatch for $SERVER_BINARY"
|
||||||
|
|
||||||
echo "Checksum verified: $ACTUAL_SHA"
|
echo "Checksum verified: $SERVER_BINARY $ACTUAL_SERVER_SHA"
|
||||||
|
if [ -n "$EXPECTED_CTL_SHA" ]; then
|
||||||
|
echo "Downloading from: $CTL_URL"
|
||||||
|
curl -fsSL -o "$CTL_TMP_FILE" "$CTL_URL" ||
|
||||||
|
fail "Failed to download $CTL_BINARY"
|
||||||
|
ACTUAL_CTL_SHA=$(sha256_of "$CTL_TMP_FILE") ||
|
||||||
|
fail "sha256sum or shasum is required for checksum verification"
|
||||||
|
[ "$ACTUAL_CTL_SHA" = "$EXPECTED_CTL_SHA" ] ||
|
||||||
|
fail "Checksum mismatch for $CTL_BINARY"
|
||||||
|
echo "Checksum verified: $CTL_BINARY $ACTUAL_CTL_SHA"
|
||||||
|
INSTALL_CTL=1
|
||||||
|
else
|
||||||
|
echo "No checksum entry found for $CTL_BINARY; skipping tntctl for this release"
|
||||||
|
fi
|
||||||
|
|
||||||
# Install
|
# Install
|
||||||
chmod +x "$TMP_FILE"
|
chmod +x "$SERVER_TMP_FILE"
|
||||||
|
[ "$INSTALL_CTL" -eq 0 ] || chmod +x "$CTL_TMP_FILE"
|
||||||
|
|
||||||
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
|
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
|
||||||
install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
|
install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
|
||||||
|
[ "$INSTALL_CTL" -eq 0 ] || install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
|
||||||
else
|
else
|
||||||
echo "Need sudo for installation to $INSTALL_DIR"
|
echo "Need sudo for installation to $INSTALL_DIR"
|
||||||
need_cmd sudo
|
need_cmd sudo
|
||||||
sudo mkdir -p "$INSTALL_DIR"
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
sudo install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
|
sudo install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
|
||||||
|
[ "$INSTALL_CTL" -eq 0 ] || sudo install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
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"
|
echo "TNT installed successfully to $INSTALL_DIR/tnt"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Run with:"
|
echo "Run with:"
|
||||||
echo " tnt"
|
echo " tnt"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Or specify port:"
|
echo "Or specify port:"
|
||||||
echo " PORT=3333 tnt"
|
echo " PORT=3333 tnt"
|
||||||
|
if [ "$INSTALL_CTL" -eq 1 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "Control a server with:"
|
||||||
|
echo " tntctl localhost health"
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ any public registry.
|
||||||
- `homebrew/` - Homebrew tap formula draft and maintainer notes.
|
- `homebrew/` - Homebrew tap formula draft and maintainer notes.
|
||||||
- `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata.
|
- `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata.
|
||||||
|
|
||||||
|
Package installs include both `tnt` and `tntctl`. `tnt` is the server process;
|
||||||
|
`tntctl` is a thin wrapper around the documented SSH exec interface.
|
||||||
|
|
||||||
## Release checklist
|
## Release checklist
|
||||||
|
|
||||||
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
|
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,6 @@ debuild -S
|
||||||
## Package shape
|
## Package shape
|
||||||
|
|
||||||
- Binary package name: `tnt-chat`
|
- Binary package name: `tnt-chat`
|
||||||
- Installed command: `/usr/bin/tnt`
|
- Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl`
|
||||||
- Runtime dependency: `libssh`
|
- Runtime dependency: `libssh`
|
||||||
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service`
|
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service`
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,13 @@ class TntChat < Formula
|
||||||
system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}"
|
system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}"
|
||||||
|
|
||||||
bin.install "#{buildpath}/stage#{prefix}/bin/tnt"
|
bin.install "#{buildpath}/stage#{prefix}/bin/tnt"
|
||||||
|
bin.install "#{buildpath}/stage#{prefix}/bin/tntctl"
|
||||||
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1"
|
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1"
|
||||||
|
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tntctl.1"
|
||||||
end
|
end
|
||||||
|
|
||||||
test do
|
test do
|
||||||
assert_match version.to_s, shell_output("#{bin}/tnt --version")
|
assert_match version.to_s, shell_output("#{bin}/tnt --version")
|
||||||
|
assert_match version.to_s, shell_output("#{bin}/tntctl --version")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ Environment:
|
||||||
RUN_INTEGRATION=1 also run full make test
|
RUN_INTEGRATION=1 also run full make test
|
||||||
PORT=12720 base port for integration tests
|
PORT=12720 base port for integration tests
|
||||||
|
|
||||||
Strict checks additionally require real package checksums and a local vX.Y.Z tag.
|
Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a
|
||||||
|
matching changelog release section, real package checksums, and non-placeholder
|
||||||
|
maintainer metadata, then build from the tagged source archive.
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +64,8 @@ version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
||||||
step "checking version metadata for $version"
|
step "checking version metadata for $version"
|
||||||
grep -q "\"TNT $version\"" tnt.1 ||
|
grep -q "\"TNT $version\"" tnt.1 ||
|
||||||
fail "tnt.1 does not mention TNT $version"
|
fail "tnt.1 does not mention TNT $version"
|
||||||
|
grep -q "\"TNT $version\"" tntctl.1 ||
|
||||||
|
fail "tntctl.1 does not mention TNT $version"
|
||||||
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
|
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
|
||||||
fail "packaging/arch/PKGBUILD pkgver does not match $version"
|
fail "packaging/arch/PKGBUILD pkgver does not match $version"
|
||||||
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
|
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
|
||||||
|
|
@ -88,11 +92,25 @@ make
|
||||||
actual_version=$(./tnt --version)
|
actual_version=$(./tnt --version)
|
||||||
[ "$actual_version" = "tnt $version" ] ||
|
[ "$actual_version" = "tnt $version" ] ||
|
||||||
fail "binary version mismatch: expected 'tnt $version', got '$actual_version'"
|
fail "binary version mismatch: expected 'tnt $version', got '$actual_version'"
|
||||||
|
tntctl_version=$(./tntctl --version)
|
||||||
|
[ "$tntctl_version" = "tntctl $version" ] ||
|
||||||
|
fail "control binary version mismatch: expected 'tntctl $version', got '$tntctl_version'"
|
||||||
|
|
||||||
step "running unit tests"
|
step "running unit tests"
|
||||||
make -C tests/unit clean
|
make -C tests/unit clean
|
||||||
make -C tests/unit run
|
make -C tests/unit run
|
||||||
|
|
||||||
|
step "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"
|
||||||
|
if grep -R "ssh_channel_write" src include | grep -v "^src/client.c:" >/dev/null; then
|
||||||
|
fail "raw SSH channel writes must stay inside src/client.c"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
|
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
|
||||||
step "running full integration tests"
|
step "running full integration tests"
|
||||||
make test PORT="${PORT:-12720}"
|
make test PORT="${PORT:-12720}"
|
||||||
|
|
@ -109,9 +127,13 @@ make DESTDIR="$tmpdir" PREFIX=/usr install
|
||||||
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
|
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
|
||||||
|
|
||||||
[ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt"
|
[ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt"
|
||||||
|
[ -x "$tmpdir/usr/bin/tntctl" ] || fail "missing executable: /usr/bin/tntctl"
|
||||||
[ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1"
|
[ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1"
|
||||||
|
[ -f "$tmpdir/usr/share/man/man1/tntctl.1" ] || fail "missing manpage: /usr/share/man/man1/tntctl.1"
|
||||||
[ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] ||
|
[ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] ||
|
||||||
fail "missing systemd unit: /usr/lib/systemd/system/tnt.service"
|
fail "missing systemd unit: /usr/lib/systemd/system/tnt.service"
|
||||||
|
grep -q "^ExecStart=/usr/bin/tnt$" "$tmpdir/usr/lib/systemd/system/tnt.service" ||
|
||||||
|
fail "systemd unit ExecStart does not match PREFIX=/usr install path"
|
||||||
|
|
||||||
step "checking installer syntax"
|
step "checking installer syntax"
|
||||||
sh -n install.sh
|
sh -n install.sh
|
||||||
|
|
@ -137,14 +159,61 @@ fi
|
||||||
|
|
||||||
if [ "$STRICT" -eq 1 ]; then
|
if [ "$STRICT" -eq 1 ]; then
|
||||||
step "checking strict release gates"
|
step "checking strict release gates"
|
||||||
|
[ -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 -q "sha256sums=('SKIP')" packaging/arch/PKGBUILD ||
|
! grep -q "sha256sums=('SKIP')" packaging/arch/PKGBUILD ||
|
||||||
fail "replace PKGBUILD sha256sums before strict release"
|
fail "replace PKGBUILD sha256sums before strict release"
|
||||||
! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO ||
|
! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO ||
|
||||||
fail "replace .SRCINFO sha256sums before strict release"
|
fail "replace .SRCINFO sha256sums before strict release"
|
||||||
! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb ||
|
! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb ||
|
||||||
fail "replace Homebrew sha256 before strict release"
|
fail "replace Homebrew sha256 before strict release"
|
||||||
git rev-parse -q --verify "refs/tags/v$version" >/dev/null ||
|
! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
|
||||||
fail "missing local tag v$version"
|
fail "replace maintainer email placeholders before strict release"
|
||||||
|
|
||||||
|
step "checking tagged source archive"
|
||||||
|
archive="$tmpdir/tnt-$version-source.tar.gz"
|
||||||
|
archive_extract="$tmpdir/source"
|
||||||
|
archive_install="$tmpdir/source-install"
|
||||||
|
archive_root="$archive_extract/TNT-$version"
|
||||||
|
|
||||||
|
git archive --format=tar.gz --prefix="TNT-$version/" \
|
||||||
|
-o "$archive" "refs/tags/v$version"
|
||||||
|
mkdir -p "$archive_extract"
|
||||||
|
tar -xzf "$archive" -C "$archive_extract"
|
||||||
|
|
||||||
|
[ -f "$archive_root/src/tntctl.c" ] ||
|
||||||
|
fail "tagged source archive is missing src/tntctl.c"
|
||||||
|
[ -f "$archive_root/tnt.1" ] ||
|
||||||
|
fail "tagged source archive is missing tnt.1"
|
||||||
|
[ -f "$archive_root/tntctl.1" ] ||
|
||||||
|
fail "tagged source archive is missing tntctl.1"
|
||||||
|
[ -f "$archive_root/LICENSE" ] ||
|
||||||
|
fail "tagged source archive is missing LICENSE"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$archive_root"
|
||||||
|
make
|
||||||
|
make DESTDIR="$archive_install" PREFIX=/usr install
|
||||||
|
make DESTDIR="$archive_install" PREFIX=/usr install-systemd
|
||||||
|
)
|
||||||
|
|
||||||
|
[ -x "$archive_install/usr/bin/tnt" ] ||
|
||||||
|
fail "tagged source install is missing /usr/bin/tnt"
|
||||||
|
[ -x "$archive_install/usr/bin/tntctl" ] ||
|
||||||
|
fail "tagged source install is missing /usr/bin/tntctl"
|
||||||
|
[ -f "$archive_install/usr/share/man/man1/tnt.1" ] ||
|
||||||
|
fail "tagged source install is missing tnt.1"
|
||||||
|
[ -f "$archive_install/usr/share/man/man1/tntctl.1" ] ||
|
||||||
|
fail "tagged source install is missing tntctl.1"
|
||||||
|
grep -q "^ExecStart=/usr/bin/tnt$" \
|
||||||
|
"$archive_install/usr/lib/systemd/system/tnt.service" ||
|
||||||
|
fail "tagged source systemd unit ExecStart does not match /usr/bin/tnt"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
step "release preflight passed"
|
step "release preflight passed"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ typedef struct {
|
||||||
int pty_width;
|
int pty_width;
|
||||||
int pty_height;
|
int pty_height;
|
||||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||||
|
bool exec_command_too_long;
|
||||||
bool auth_success;
|
bool auth_success;
|
||||||
int auth_attempts;
|
int auth_attempts;
|
||||||
bool channel_ready; /* Set when shell/exec request received */
|
bool channel_ready; /* Set when shell/exec request received */
|
||||||
|
|
@ -294,9 +295,14 @@ static int channel_exec_request(ssh_session session, ssh_channel channel,
|
||||||
|
|
||||||
/* Store exec command */
|
/* Store exec command */
|
||||||
if (command) {
|
if (command) {
|
||||||
|
if (strlen(command) >= sizeof(ctx->exec_command)) {
|
||||||
|
ctx->exec_command_too_long = true;
|
||||||
|
ctx->exec_command[0] = '\0';
|
||||||
|
} else {
|
||||||
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
|
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
|
||||||
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
|
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Mark channel as ready */
|
/* Mark channel as ready */
|
||||||
ctx->channel_ready = true;
|
ctx->channel_ready = true;
|
||||||
|
|
@ -363,6 +369,7 @@ void *bootstrap_run(void *arg) {
|
||||||
ctx->pty_width = 80;
|
ctx->pty_width = 80;
|
||||||
ctx->pty_height = 24;
|
ctx->pty_height = 24;
|
||||||
ctx->exec_command[0] = '\0';
|
ctx->exec_command[0] = '\0';
|
||||||
|
ctx->exec_command_too_long = false;
|
||||||
ctx->requested_user[0] = '\0';
|
ctx->requested_user[0] = '\0';
|
||||||
ctx->auth_success = false;
|
ctx->auth_success = false;
|
||||||
ctx->auth_attempts = 0;
|
ctx->auth_attempts = 0;
|
||||||
|
|
@ -451,6 +458,7 @@ void *bootstrap_run(void *arg) {
|
||||||
client->ref_count = 1;
|
client->ref_count = 1;
|
||||||
pthread_mutex_init(&client->ref_lock, NULL);
|
pthread_mutex_init(&client->ref_lock, NULL);
|
||||||
pthread_mutex_init(&client->io_lock, NULL);
|
pthread_mutex_init(&client->io_lock, NULL);
|
||||||
|
pthread_mutex_init(&client->whisper_lock, NULL);
|
||||||
|
|
||||||
if (ctx->requested_user[0] != '\0') {
|
if (ctx->requested_user[0] != '\0') {
|
||||||
strncpy(client->ssh_login, ctx->requested_user,
|
strncpy(client->ssh_login, ctx->requested_user,
|
||||||
|
|
@ -466,6 +474,7 @@ void *bootstrap_run(void *arg) {
|
||||||
sizeof(client->exec_command) - 1);
|
sizeof(client->exec_command) - 1);
|
||||||
client->exec_command[sizeof(client->exec_command) - 1] = '\0';
|
client->exec_command[sizeof(client->exec_command) - 1] = '\0';
|
||||||
}
|
}
|
||||||
|
client->exec_command_too_long = ctx->exec_command_too_long;
|
||||||
|
|
||||||
/* Add a ref for the channel callbacks (eof/close/window_change) so the
|
/* Add a ref for the channel callbacks (eof/close/window_change) so the
|
||||||
* client_t outlives any in-flight callback invocation. */
|
* client_t outlives any in-flight callback invocation. */
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,8 @@
|
||||||
chat_room_t *g_room = NULL;
|
chat_room_t *g_room = NULL;
|
||||||
|
|
||||||
static int room_capacity_from_env(void) {
|
static int room_capacity_from_env(void) {
|
||||||
const char *env = getenv("TNT_MAX_CONNECTIONS");
|
return env_int("TNT_MAX_CONNECTIONS", DEFAULT_MAX_CLIENTS, 1,
|
||||||
|
MAX_CONFIGURED_CLIENTS);
|
||||||
if (!env || env[0] == '\0') {
|
|
||||||
return MAX_CLIENTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
char *end;
|
|
||||||
long capacity = strtol(env, &end, 10);
|
|
||||||
if (*end != '\0' || capacity < 1 || capacity > 1024) {
|
|
||||||
return MAX_CLIENTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int)capacity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Initialize chat room */
|
/* Initialize chat room */
|
||||||
|
|
|
||||||
37
src/client.c
37
src/client.c
|
|
@ -9,6 +9,13 @@
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
static int client_send_fail(client_t *client) {
|
||||||
|
if (client) {
|
||||||
|
client->connected = false;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Send data to client via SSH channel */
|
/* Send data to client via SSH channel */
|
||||||
int client_send(client_t *client, const char *data, size_t len) {
|
int client_send(client_t *client, const char *data, size_t len) {
|
||||||
size_t total = 0;
|
size_t total = 0;
|
||||||
|
|
@ -24,11 +31,21 @@ int client_send(client_t *client, const char *data, size_t len) {
|
||||||
|
|
||||||
while (total < len) {
|
while (total < len) {
|
||||||
size_t remaining = len - total;
|
size_t remaining = len - total;
|
||||||
|
uint32_t window = ssh_channel_window_size(client->channel);
|
||||||
|
if (window == 0) {
|
||||||
|
pthread_mutex_unlock(&client->io_lock);
|
||||||
|
return client_send_fail(client);
|
||||||
|
}
|
||||||
|
|
||||||
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
|
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
|
||||||
|
if (chunk > window) {
|
||||||
|
chunk = window;
|
||||||
|
}
|
||||||
|
|
||||||
int sent = ssh_channel_write(client->channel, data + total, chunk);
|
int sent = ssh_channel_write(client->channel, data + total, chunk);
|
||||||
if (sent <= 0) {
|
if (sent <= 0) {
|
||||||
pthread_mutex_unlock(&client->io_lock);
|
pthread_mutex_unlock(&client->io_lock);
|
||||||
return -1;
|
return client_send_fail(client);
|
||||||
}
|
}
|
||||||
total += (size_t)sent;
|
total += (size_t)sent;
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +58,23 @@ int client_send(client_t *client, const char *data, size_t len) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void client_queue_bell(client_t *client) {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
atomic_store(&client->pending_bells, 1);
|
||||||
|
client->redraw_pending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int client_flush_pending_bells(client_t *client) {
|
||||||
|
if (!client) return 0;
|
||||||
|
|
||||||
|
if (atomic_exchange(&client->pending_bells, 0) <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client_send(client, "\a", 1);
|
||||||
|
}
|
||||||
|
|
||||||
void client_addref(client_t *client) {
|
void client_addref(client_t *client) {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
pthread_mutex_lock(&client->ref_lock);
|
pthread_mutex_lock(&client->ref_lock);
|
||||||
|
|
@ -75,6 +109,7 @@ void client_release(client_t *client) {
|
||||||
free(client->channel_cb);
|
free(client->channel_cb);
|
||||||
}
|
}
|
||||||
pthread_mutex_destroy(&client->io_lock);
|
pthread_mutex_destroy(&client->io_lock);
|
||||||
|
pthread_mutex_destroy(&client->whisper_lock);
|
||||||
pthread_mutex_destroy(&client->ref_lock);
|
pthread_mutex_destroy(&client->ref_lock);
|
||||||
free(client);
|
free(client);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,9 +199,9 @@ void commands_dispatch(client_t *client) {
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
/* Push into recipient's inbox. io_lock serialises so two
|
/* Push into recipient's inbox. whisper_lock serialises so
|
||||||
* senders to the same recipient don't tear the ring. */
|
* two senders to the same recipient don't tear the ring. */
|
||||||
pthread_mutex_lock(&target->io_lock);
|
pthread_mutex_lock(&target->whisper_lock);
|
||||||
int slot;
|
int slot;
|
||||||
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
|
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
|
||||||
slot = target->whisper_inbox_count++;
|
slot = target->whisper_inbox_count++;
|
||||||
|
|
@ -219,13 +219,12 @@ void commands_dispatch(client_t *client) {
|
||||||
snprintf(target->whisper_inbox[slot].content,
|
snprintf(target->whisper_inbox[slot].content,
|
||||||
sizeof(target->whisper_inbox[slot].content),
|
sizeof(target->whisper_inbox[slot].content),
|
||||||
"%s", rest);
|
"%s", rest);
|
||||||
pthread_mutex_unlock(&target->io_lock);
|
pthread_mutex_unlock(&target->whisper_lock);
|
||||||
|
|
||||||
target->unread_whispers++;
|
target->unread_whispers++;
|
||||||
target->redraw_pending = true;
|
|
||||||
/* Audible nudge — the title bar ✉ counter (UX-11 style)
|
/* Audible nudge — the title bar ✉ counter (UX-11 style)
|
||||||
* carries the persistent signal. */
|
* carries the persistent signal. */
|
||||||
client_send(target, "\a", 1);
|
client_queue_bell(target);
|
||||||
client_release(target);
|
client_release(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,15 +242,15 @@ void commands_dispatch(client_t *client) {
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (command_id == TNT_COMMAND_INBOX) {
|
} else if (command_id == TNT_COMMAND_INBOX) {
|
||||||
/* Snapshot the inbox under io_lock so a concurrent sender doesn't
|
/* Snapshot the inbox under whisper_lock so a concurrent sender doesn't
|
||||||
* tear what we're rendering. Counter reset happens after copy. */
|
* tear what we're rendering. Counter reset happens after copy. */
|
||||||
whisper_t snapshot[WHISPER_INBOX_SIZE];
|
whisper_t snapshot[WHISPER_INBOX_SIZE];
|
||||||
int snap_count;
|
int snap_count;
|
||||||
pthread_mutex_lock(&client->io_lock);
|
pthread_mutex_lock(&client->whisper_lock);
|
||||||
snap_count = client->whisper_inbox_count;
|
snap_count = client->whisper_inbox_count;
|
||||||
memcpy(snapshot, client->whisper_inbox,
|
memcpy(snapshot, client->whisper_inbox,
|
||||||
snap_count * sizeof(whisper_t));
|
snap_count * sizeof(whisper_t));
|
||||||
pthread_mutex_unlock(&client->io_lock);
|
pthread_mutex_unlock(&client->whisper_lock);
|
||||||
client->unread_whispers = 0;
|
client->unread_whispers = 0;
|
||||||
|
|
||||||
buffer_appendf(output, sizeof(output), &pos,
|
buffer_appendf(output, sizeof(output), &pos,
|
||||||
|
|
|
||||||
66
src/exec.c
66
src/exec.c
|
|
@ -123,7 +123,8 @@ static int exec_command_help(client_t *client) {
|
||||||
help_text[0] = '\0';
|
help_text[0] = '\0';
|
||||||
exec_catalog_append_help(help_text, sizeof(help_text), &pos,
|
exec_catalog_append_help(help_text, sizeof(help_text), &pos,
|
||||||
client->ui_lang);
|
client->ui_lang);
|
||||||
return client_send(client, help_text, pos) == 0 ? 0 : 1;
|
return client_send(client, help_text, pos) == 0 ? TNT_EXIT_OK
|
||||||
|
: TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
|
static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
|
||||||
|
|
@ -134,12 +135,13 @@ static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
|
||||||
exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
|
exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
|
||||||
client->ui_lang);
|
client->ui_lang);
|
||||||
client_printf(client, "%s", usage);
|
client_printf(client, "%s", usage);
|
||||||
return 64;
|
return TNT_EXIT_USAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int exec_command_health(client_t *client) {
|
static int exec_command_health(client_t *client) {
|
||||||
static const char ok[] = "ok\n";
|
static const char ok[] = "ok\n";
|
||||||
return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1;
|
return client_send(client, ok, sizeof(ok) - 1) == 0 ? TNT_EXIT_OK
|
||||||
|
: TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int exec_command_users(client_t *client, bool json) {
|
static int exec_command_users(client_t *client, bool json) {
|
||||||
|
|
@ -157,7 +159,7 @@ static int exec_command_users(client_t *client, bool json) {
|
||||||
if (!usernames) {
|
if (!usernames) {
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
client_printf(client, "users: out of memory\n");
|
client_printf(client, "users: out of memory\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
|
|
@ -177,7 +179,7 @@ static int exec_command_users(client_t *client, bool json) {
|
||||||
if (!output) {
|
if (!output) {
|
||||||
free(usernames);
|
free(usernames);
|
||||||
client_printf(client, "users: out of memory\n");
|
client_printf(client, "users: out of memory\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json) {
|
if (json) {
|
||||||
|
|
@ -195,7 +197,7 @@ static int exec_command_users(client_t *client, bool json) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = client_send(client, output, pos) == 0 ? 0 : 1;
|
rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
|
||||||
free(output);
|
free(output);
|
||||||
free(usernames);
|
free(usernames);
|
||||||
return rc;
|
return rc;
|
||||||
|
|
@ -243,10 +245,11 @@ static int exec_command_stats(client_t *client, bool json) {
|
||||||
|
|
||||||
if (len < 0 || len >= (int)sizeof(buffer)) {
|
if (len < 0 || len >= (int)sizeof(buffer)) {
|
||||||
client_printf(client, "stats: output overflow\n");
|
client_printf(client, "stats: output overflow\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
return client_send(client, buffer, (size_t)len) == 0 ? 0 : 1;
|
return client_send(client, buffer, (size_t)len) == 0 ? TNT_EXIT_OK
|
||||||
|
: TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int parse_tail_count(const char *args, int *count) {
|
static int parse_tail_count(const char *args, int *count) {
|
||||||
|
|
@ -316,7 +319,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
client_printf(client, "tail: out of memory\n");
|
client_printf(client, "tail: out of memory\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t));
|
memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t));
|
||||||
}
|
}
|
||||||
|
|
@ -328,7 +331,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
||||||
if (!output) {
|
if (!output) {
|
||||||
free(snapshot);
|
free(snapshot);
|
||||||
client_printf(client, "tail: out of memory\n");
|
client_printf(client, "tail: out of memory\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
|
|
@ -338,7 +341,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
||||||
timestamp, snapshot[i].username, snapshot[i].content);
|
timestamp, snapshot[i].username, snapshot[i].content);
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = client_send(client, output, pos) == 0 ? 0 : 1;
|
rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
|
||||||
free(output);
|
free(output);
|
||||||
free(snapshot);
|
free(snapshot);
|
||||||
return rc;
|
return rc;
|
||||||
|
|
@ -355,6 +358,12 @@ static int exec_command_post(client_t *client, const char *args) {
|
||||||
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
|
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (strlen(args) >= sizeof(content)) {
|
||||||
|
client_printf(client, "%s",
|
||||||
|
i18n_text(client->ui_lang, I18N_EXEC_POST_TOO_LONG));
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
strncpy(content, args, sizeof(content) - 1);
|
strncpy(content, args, sizeof(content) - 1);
|
||||||
content[sizeof(content) - 1] = '\0';
|
content[sizeof(content) - 1] = '\0';
|
||||||
trim_ascii_whitespace(content);
|
trim_ascii_whitespace(content);
|
||||||
|
|
@ -362,14 +371,14 @@ static int exec_command_post(client_t *client, const char *args) {
|
||||||
if (content[0] == '\0') {
|
if (content[0] == '\0') {
|
||||||
client_printf(client, "%s",
|
client_printf(client, "%s",
|
||||||
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
|
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
|
||||||
return 64;
|
return TNT_EXIT_USAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!utf8_is_valid_string(content)) {
|
if (!utf8_is_valid_string(content)) {
|
||||||
client_printf(client, "%s",
|
client_printf(client, "%s",
|
||||||
i18n_text(client->ui_lang,
|
i18n_text(client->ui_lang,
|
||||||
I18N_EXEC_POST_INVALID_UTF8));
|
I18N_EXEC_POST_INVALID_UTF8));
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_exec_username(client, username, sizeof(username));
|
resolve_exec_username(client, username, sizeof(username));
|
||||||
|
|
@ -388,18 +397,22 @@ static int exec_command_post(client_t *client, const char *args) {
|
||||||
msg.content[sizeof(msg.content) - 1] = '\0';
|
msg.content[sizeof(msg.content) - 1] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
room_broadcast(g_room, &msg);
|
|
||||||
if (client_send(client, "posted\n", 7) != 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
notify_mentions(msg.content, client);
|
|
||||||
if (message_save(&msg) < 0) {
|
if (message_save(&msg) < 0) {
|
||||||
fprintf(stderr, "post: failed to persist message\n");
|
fprintf(stderr, "post: failed to persist message\n");
|
||||||
return 1;
|
client_printf(client, "%s",
|
||||||
|
i18n_text(client->ui_lang,
|
||||||
|
I18N_EXEC_POST_PERSIST_FAILED));
|
||||||
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
room_broadcast(g_room, &msg);
|
||||||
|
notify_mentions(msg.content, client);
|
||||||
|
|
||||||
|
if (client_send(client, "posted\n", 7) != 0) {
|
||||||
|
return TNT_EXIT_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TNT_EXIT_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
int exec_dispatch(client_t *client) {
|
int exec_dispatch(client_t *client) {
|
||||||
|
|
@ -407,6 +420,13 @@ int exec_dispatch(client_t *client) {
|
||||||
tnt_exec_command_id_t command_id;
|
tnt_exec_command_id_t command_id;
|
||||||
const char *args = NULL;
|
const char *args = NULL;
|
||||||
|
|
||||||
|
if (client->exec_command_too_long) {
|
||||||
|
client_printf(client, "%s",
|
||||||
|
i18n_text(client->ui_lang,
|
||||||
|
I18N_EXEC_COMMAND_TOO_LONG));
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
|
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
|
||||||
command_copy[sizeof(command_copy) - 1] = '\0';
|
command_copy[sizeof(command_copy) - 1] = '\0';
|
||||||
trim_ascii_whitespace(command_copy);
|
trim_ascii_whitespace(command_copy);
|
||||||
|
|
@ -434,7 +454,7 @@ int exec_dispatch(client_t *client) {
|
||||||
case TNT_EXEC_COMMAND_POST:
|
case TNT_EXEC_COMMAND_POST:
|
||||||
return exec_command_post(client, args);
|
return exec_command_post(client, args);
|
||||||
case TNT_EXEC_COMMAND_EXIT:
|
case TNT_EXEC_COMMAND_EXIT:
|
||||||
return 0;
|
return TNT_EXIT_OK;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,5 +468,5 @@ int exec_dispatch(client_t *client) {
|
||||||
i18n_text(client->ui_lang,
|
i18n_text(client->ui_lang,
|
||||||
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||||
command_copy);
|
command_copy);
|
||||||
return 64;
|
return TNT_EXIT_USAGE;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
|
||||||
"post: invalid UTF-8 input\n",
|
"post: invalid UTF-8 input\n",
|
||||||
"post: 输入不是有效 UTF-8\n"
|
"post: 输入不是有效 UTF-8\n"
|
||||||
),
|
),
|
||||||
|
[I18N_EXEC_POST_TOO_LONG] = I18N_STRING(
|
||||||
|
"post: message too long\n",
|
||||||
|
"post: 消息过长\n"
|
||||||
|
),
|
||||||
|
[I18N_EXEC_POST_PERSIST_FAILED] = I18N_STRING(
|
||||||
|
"post: failed to persist message\n",
|
||||||
|
"post: 消息持久化失败\n"
|
||||||
|
),
|
||||||
|
[I18N_EXEC_COMMAND_TOO_LONG] = I18N_STRING(
|
||||||
|
"exec: command too long\n",
|
||||||
|
"exec: 命令过长\n"
|
||||||
|
),
|
||||||
[I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
|
[I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
|
||||||
"Unknown command: %s\n",
|
"Unknown command: %s\n",
|
||||||
"未知命令: %s\n"
|
"未知命令: %s\n"
|
||||||
|
|
|
||||||
20
src/input.c
20
src/input.c
|
|
@ -134,9 +134,17 @@ static int read_username(client_t *client) {
|
||||||
void notify_mentions(const char *content, const client_t *sender) {
|
void notify_mentions(const char *content, const client_t *sender) {
|
||||||
pthread_rwlock_rdlock(&g_room->lock);
|
pthread_rwlock_rdlock(&g_room->lock);
|
||||||
int count = g_room->client_count;
|
int count = g_room->client_count;
|
||||||
client_t *targets[MAX_CLIENTS];
|
client_t **targets = NULL;
|
||||||
int target_count = 0;
|
int target_count = 0;
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
targets = calloc((size_t)count, sizeof(*targets));
|
||||||
|
if (!targets) {
|
||||||
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
client_t *c = g_room->clients[i];
|
client_t *c = g_room->clients[i];
|
||||||
if (c == sender) continue;
|
if (c == sender) continue;
|
||||||
|
|
@ -150,11 +158,11 @@ void notify_mentions(const char *content, const client_t *sender) {
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
|
|
||||||
for (int i = 0; i < target_count; i++) {
|
for (int i = 0; i < target_count; i++) {
|
||||||
client_send(targets[i], "\a", 1);
|
|
||||||
targets[i]->unread_mentions++;
|
targets[i]->unread_mentions++;
|
||||||
targets[i]->redraw_pending = true;
|
client_queue_bell(targets[i]);
|
||||||
client_release(targets[i]);
|
client_release(targets[i]);
|
||||||
}
|
}
|
||||||
|
free(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int read_channel_exact(client_t *client, char *buf, size_t len,
|
static int read_channel_exact(client_t *client, char *buf, size_t len,
|
||||||
|
|
@ -731,7 +739,7 @@ void input_run_session(client_t *client) {
|
||||||
client->last_active = time(NULL);
|
client->last_active = time(NULL);
|
||||||
|
|
||||||
/* Check for exec command */
|
/* Check for exec command */
|
||||||
if (client->exec_command[0] != '\0') {
|
if (client->exec_command[0] != '\0' || client->exec_command_too_long) {
|
||||||
int exit_status = exec_dispatch(client);
|
int exit_status = exec_dispatch(client);
|
||||||
ssh_channel_request_send_exit_status(client->channel, exit_status);
|
ssh_channel_request_send_exit_status(client->channel, exit_status);
|
||||||
ssh_channel_send_eof(client->channel);
|
ssh_channel_send_eof(client->channel);
|
||||||
|
|
@ -811,6 +819,10 @@ main_loop:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (client_flush_pending_bells(client) != 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (current_update_seq != seen_update_seq) {
|
if (current_update_seq != seen_update_seq) {
|
||||||
seen_update_seq = current_update_seq;
|
seen_update_seq = current_update_seq;
|
||||||
room_updated = true;
|
room_updated = true;
|
||||||
|
|
|
||||||
16
src/main.c
16
src/main.c
|
|
@ -41,7 +41,7 @@ int main(int argc, char **argv) {
|
||||||
if (*end != '\0' || val <= 0 || val > 65535) {
|
if (*end != '\0' || val <= 0 || val > 65535) {
|
||||||
fprintf(stderr, cli_text_invalid_port_format(lang),
|
fprintf(stderr, cli_text_invalid_port_format(lang),
|
||||||
argv[i + 1]);
|
argv[i + 1]);
|
||||||
return 1;
|
return TNT_EXIT_USAGE;
|
||||||
}
|
}
|
||||||
port = (int)val;
|
port = (int)val;
|
||||||
i++;
|
i++;
|
||||||
|
|
@ -49,23 +49,23 @@ int main(int argc, char **argv) {
|
||||||
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) {
|
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) {
|
||||||
if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) {
|
if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) {
|
||||||
perror("setenv TNT_STATE_DIR");
|
perror("setenv TNT_STATE_DIR");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
i++;
|
i++;
|
||||||
} else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
|
} else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
|
||||||
printf("tnt %s\n", TNT_VERSION);
|
printf("tnt %s\n", TNT_VERSION);
|
||||||
return 0;
|
return TNT_EXIT_OK;
|
||||||
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
||||||
char output[2048] = {0};
|
char output[2048] = {0};
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
|
|
||||||
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
|
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
|
||||||
fputs(output, stdout);
|
fputs(output, stdout);
|
||||||
return 0;
|
return TNT_EXIT_OK;
|
||||||
} else {
|
} else {
|
||||||
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
|
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
|
||||||
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
|
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
|
||||||
return 1;
|
return TNT_EXIT_USAGE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ int main(int argc, char **argv) {
|
||||||
/* Initialize subsystems */
|
/* Initialize subsystems */
|
||||||
if (tnt_ensure_state_dir() < 0) {
|
if (tnt_ensure_state_dir() < 0) {
|
||||||
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
|
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
message_init();
|
message_init();
|
||||||
|
|
@ -86,14 +86,14 @@ int main(int argc, char **argv) {
|
||||||
g_room = room_create();
|
g_room = room_create();
|
||||||
if (!g_room) {
|
if (!g_room) {
|
||||||
fprintf(stderr, "Failed to create chat room\n");
|
fprintf(stderr, "Failed to create chat room\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Initialize server */
|
/* Initialize server */
|
||||||
if (ssh_server_init(port) < 0) {
|
if (ssh_server_init(port) < 0) {
|
||||||
fprintf(stderr, "Failed to initialize server\n");
|
fprintf(stderr, "Failed to initialize server\n");
|
||||||
room_destroy(g_room);
|
room_destroy(g_room);
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Start server (blocking) */
|
/* Start server (blocking) */
|
||||||
|
|
|
||||||
296
src/tntctl.c
Normal file
296
src/tntctl.c
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
#include "common.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
static void print_usage(FILE *stream) {
|
||||||
|
fprintf(stream,
|
||||||
|
"Usage: tntctl [options] host command [args...]\n"
|
||||||
|
"\n"
|
||||||
|
"Options:\n"
|
||||||
|
" -p, --port PORT SSH port (default: 2222)\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 mirror the TNT SSH exec interface: health, stats, users,\n"
|
||||||
|
"tail, post, help, and exit.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 command &&
|
||||||
|
(strcmp(command, "health") == 0 ||
|
||||||
|
strcmp(command, "stats") == 0 ||
|
||||||
|
strcmp(command, "users") == 0 ||
|
||||||
|
strcmp(command, "tail") == 0 ||
|
||||||
|
strcmp(command, "post") == 0 ||
|
||||||
|
strcmp(command, "help") == 0 ||
|
||||||
|
strcmp(command, "exit") == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "2222";
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
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])) {
|
||||||
|
fprintf(stderr, "tntctl: invalid port\n");
|
||||||
|
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], '@')) {
|
||||||
|
fprintf(stderr, "tntctl: invalid login\n");
|
||||||
|
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])) {
|
||||||
|
fprintf(stderr, "tntctl: invalid host-key checking mode\n");
|
||||||
|
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])) {
|
||||||
|
fprintf(stderr, "tntctl: invalid known_hosts path\n");
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
known_hosts = argv[++i];
|
||||||
|
} else if (argv[i][0] == '-') {
|
||||||
|
fprintf(stderr, "tntctl: unknown option: %s\n", argv[i]);
|
||||||
|
print_usage(stderr);
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= argc) {
|
||||||
|
fprintf(stderr, "tntctl: missing host\n");
|
||||||
|
print_usage(stderr);
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
host = argv[i++];
|
||||||
|
if (is_safe_ssh_token(host)) {
|
||||||
|
fprintf(stderr, "tntctl: invalid host\n");
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
if (login && strchr(host, '@')) {
|
||||||
|
fprintf(stderr, "tntctl: use either --login or user@host, not both\n");
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= argc || !is_known_exec_command(argv[i])) {
|
||||||
|
fprintf(stderr, "tntctl: unknown or missing command\n");
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (build_remote_command(remote_command, sizeof(remote_command), argc,
|
||||||
|
argv, i) < 0) {
|
||||||
|
fprintf(stderr, "tntctl: invalid or too-long command\n");
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (login) {
|
||||||
|
int n = snprintf(destination, sizeof(destination), "%s@%s", login,
|
||||||
|
host);
|
||||||
|
if (n < 0 || n >= (int)sizeof(destination)) {
|
||||||
|
fprintf(stderr, "tntctl: destination too long\n");
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
int n = snprintf(destination, sizeof(destination), "%s", host);
|
||||||
|
if (n < 0 || n >= (int)sizeof(destination)) {
|
||||||
|
fprintf(stderr, "tntctl: destination too long\n");
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (destination[0] == '-') {
|
||||||
|
fprintf(stderr, "tntctl: invalid destination\n");
|
||||||
|
return TNT_EXIT_USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_argv = calloc((size_t)argc * 2u + 8u, sizeof(*ssh_argv));
|
||||||
|
if (!ssh_argv) {
|
||||||
|
fprintf(stderr, "tntctl: out of memory\n");
|
||||||
|
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)) {
|
||||||
|
fprintf(stderr, "tntctl: host-key option too long\n");
|
||||||
|
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)) {
|
||||||
|
fprintf(stderr, "tntctl: known_hosts option too long\n");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ if [ ! -f "$BIN" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
||||||
|
TNTCTL_OPTS="--host-key-checking no --known-hosts /dev/null"
|
||||||
|
|
||||||
echo "=== TNT Exec Mode Tests ==="
|
echo "=== TNT Exec Mode Tests ==="
|
||||||
|
|
||||||
|
|
@ -51,14 +52,16 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null || true)
|
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null)
|
||||||
|
HEALTH_USAGE_STATUS=$?
|
||||||
printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$'
|
printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$'
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ] && [ "$HEALTH_USAGE_STATUS" -eq 64 ]; then
|
||||||
echo "✓ no-arg exec usage follows TNT_LANG"
|
echo "✓ no-arg exec usage follows TNT_LANG and exits 64"
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
echo "✗ no-arg exec usage output unexpected"
|
echo "✗ no-arg exec usage output unexpected"
|
||||||
printf '%s\n' "$HEALTH_USAGE"
|
printf '%s\n' "$HEALTH_USAGE"
|
||||||
|
echo "exit status: $HEALTH_USAGE_STATUS"
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -98,36 +101,42 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null || true)
|
UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null)
|
||||||
|
UNKNOWN_STATUS=$?
|
||||||
printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$'
|
printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$'
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ] && [ "$UNKNOWN_STATUS" -eq 64 ]; then
|
||||||
echo "✓ unknown command follows TNT_LANG"
|
echo "✓ unknown command follows TNT_LANG and exits 64"
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
echo "✗ unknown command output unexpected"
|
echo "✗ unknown command output unexpected"
|
||||||
printf '%s\n' "$UNKNOWN_OUTPUT"
|
printf '%s\n' "$UNKNOWN_OUTPUT"
|
||||||
|
echo "exit status: $UNKNOWN_STATUS"
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null || true)
|
POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null)
|
||||||
|
POST_USAGE_STATUS=$?
|
||||||
printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$'
|
printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$'
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ] && [ "$POST_USAGE_STATUS" -eq 64 ]; then
|
||||||
echo "✓ post usage follows TNT_LANG"
|
echo "✓ post usage follows TNT_LANG and exits 64"
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
echo "✗ post usage output unexpected"
|
echo "✗ post usage output unexpected"
|
||||||
printf '%s\n' "$POST_USAGE"
|
printf '%s\n' "$POST_USAGE"
|
||||||
|
echo "exit status: $POST_USAGE_STATUS"
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null || true)
|
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null)
|
||||||
|
USERS_USAGE_STATUS=$?
|
||||||
printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
|
printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ] && [ "$USERS_USAGE_STATUS" -eq 64 ]; then
|
||||||
echo "✓ users usage follows TNT_LANG"
|
echo "✓ users usage follows TNT_LANG and exits 64"
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
echo "✗ users usage output unexpected"
|
echo "✗ users usage output unexpected"
|
||||||
printf '%s\n' "$USERS_USAGE"
|
printf '%s\n' "$USERS_USAGE"
|
||||||
|
echo "exit status: $USERS_USAGE_STATUS"
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -152,6 +161,106 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
PERSIST_FAIL_MARKER="persist-fail-marker"
|
||||||
|
rm -f "$STATE_DIR/messages.log"
|
||||||
|
mkdir "$STATE_DIR/messages.log"
|
||||||
|
PERSIST_FAIL_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "$PERSIST_FAIL_MARKER" 2>/dev/null)
|
||||||
|
PERSIST_FAIL_STATUS=$?
|
||||||
|
rmdir "$STATE_DIR/messages.log"
|
||||||
|
printf '%s\n' "$PERSIST_FAIL_OUTPUT" | grep -q 'posted'
|
||||||
|
PERSIST_FAIL_POSTED=$?
|
||||||
|
PERSIST_FAIL_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
|
||||||
|
printf '%s\n' "$PERSIST_FAIL_TAIL" | grep -q "$PERSIST_FAIL_MARKER"
|
||||||
|
PERSIST_FAIL_VISIBLE=$?
|
||||||
|
if [ "$PERSIST_FAIL_STATUS" -eq 1 ] &&
|
||||||
|
[ "$PERSIST_FAIL_POSTED" -ne 0 ] &&
|
||||||
|
[ "$PERSIST_FAIL_VISIBLE" -ne 0 ]; then
|
||||||
|
echo "✓ post persistence failure is not broadcast or acknowledged"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ post persistence failure handling unexpected"
|
||||||
|
printf '%s\n' "$PERSIST_FAIL_OUTPUT"
|
||||||
|
printf '%s\n' "$PERSIST_FAIL_TAIL"
|
||||||
|
echo "exit status: $PERSIST_FAIL_STATUS"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
LONG_MARKER="too-long-exec-marker"
|
||||||
|
LONG_COMMAND=$(printf 'post %s %01020d' "$LONG_MARKER" 0)
|
||||||
|
LONG_OUTPUT=$(ssh $SSH_OPTS localhost "$LONG_COMMAND" 2>/dev/null)
|
||||||
|
LONG_STATUS=$?
|
||||||
|
printf '%s\n' "$LONG_OUTPUT" | grep -q '命令过长'
|
||||||
|
LONG_ERROR=$?
|
||||||
|
LONG_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
|
||||||
|
printf '%s\n' "$LONG_TAIL" | grep -q "$LONG_MARKER"
|
||||||
|
LONG_VISIBLE=$?
|
||||||
|
if [ "$LONG_STATUS" -eq 64 ] &&
|
||||||
|
[ "$LONG_ERROR" -eq 0 ] &&
|
||||||
|
[ "$LONG_VISIBLE" -ne 0 ]; then
|
||||||
|
echo "✓ overlong exec command is rejected without truncation"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ overlong exec command handling unexpected"
|
||||||
|
printf '%s\n' "$LONG_OUTPUT"
|
||||||
|
printf '%s\n' "$LONG_TAIL"
|
||||||
|
echo "exit status: $LONG_STATUS"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
TNTCTL_HEALTH=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost health 2>/dev/null || true)
|
||||||
|
if [ "$TNTCTL_HEALTH" = "ok" ]; then
|
||||||
|
echo "✓ tntctl health uses exec interface"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ tntctl health failed: $TNTCTL_HEALTH"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
TNTCTL_STATS=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost stats --json 2>/dev/null || true)
|
||||||
|
printf '%s\n' "$TNTCTL_STATS" | grep -q '"status":"ok"'
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ tntctl stats --json returns JSON"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ tntctl stats --json output unexpected"
|
||||||
|
printf '%s\n' "$TNTCTL_STATS"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
TNTCTL_USERS_USAGE=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost users --xml 2>/dev/null)
|
||||||
|
TNTCTL_USERS_STATUS=$?
|
||||||
|
printf '%s\n' "$TNTCTL_USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
|
||||||
|
if [ $? -eq 0 ] && [ "$TNTCTL_USERS_STATUS" -eq 64 ]; then
|
||||||
|
echo "✓ tntctl preserves remote usage exit 64"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ tntctl users usage output unexpected"
|
||||||
|
printf '%s\n' "$TNTCTL_USERS_USAGE"
|
||||||
|
echo "exit status: $TNTCTL_USERS_STATUS"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
TNTCTL_POST=$("../tntctl" -p "$PORT" $TNTCTL_OPTS -l ctlposter localhost post "hello from tntctl" 2>/dev/null || true)
|
||||||
|
if [ "$TNTCTL_POST" = "posted" ]; then
|
||||||
|
echo "✓ tntctl post publishes a message"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ tntctl post failed: $TNTCTL_POST"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
TNTCTL_TAIL=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "tail" "-n" "1" 2>/dev/null || true)
|
||||||
|
printf '%s\n' "$TNTCTL_TAIL" | grep -q 'ctlposter' &&
|
||||||
|
printf '%s\n' "$TNTCTL_TAIL" | grep -q 'hello from tntctl'
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ tntctl tail returns recent messages"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ tntctl tail output unexpected"
|
||||||
|
printf '%s\n' "$TNTCTL_TAIL"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
|
EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
|
||||||
WATCHER_READY="${STATE_DIR}/watcher.ready"
|
WATCHER_READY="${STATE_DIR}/watcher.ready"
|
||||||
cat >"$EXPECT_SCRIPT" <<EOF
|
cat >"$EXPECT_SCRIPT" <<EOF
|
||||||
|
|
@ -160,7 +269,7 @@ spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT w
|
||||||
expect "请输入用户名"
|
expect "请输入用户名"
|
||||||
send "watcher\r"
|
send "watcher\r"
|
||||||
exec touch "$WATCHER_READY"
|
exec touch "$WATCHER_READY"
|
||||||
sleep 8
|
sleep 12
|
||||||
send "\003"
|
send "\003"
|
||||||
expect eof
|
expect eof
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -213,6 +322,45 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
MENTION_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "@watcher hello from exec mention" 2>/dev/null || true)
|
||||||
|
if [ "$MENTION_OUTPUT" = "posted" ]; then
|
||||||
|
echo "✓ post returns while notifying an interactive mention target"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ mention post failed: $MENTION_OUTPUT"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
MSG_SCRIPT="${STATE_DIR}/private-message.expect"
|
||||||
|
cat >"$MSG_SCRIPT" <<EOF
|
||||||
|
set timeout 10
|
||||||
|
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT sender@localhost
|
||||||
|
expect "请输入用户名"
|
||||||
|
send "sender\r"
|
||||||
|
expect ":help"
|
||||||
|
send "\033"
|
||||||
|
expect "NORMAL"
|
||||||
|
send ":"
|
||||||
|
expect ":"
|
||||||
|
send "msg watcher hello from private message\r"
|
||||||
|
expect "私信已发送给 watcher"
|
||||||
|
expect "q:关闭"
|
||||||
|
send "q"
|
||||||
|
sleep 0.2
|
||||||
|
send "\003"
|
||||||
|
expect eof
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if expect "$MSG_SCRIPT" >"${STATE_DIR}/private-message.log" 2>&1; then
|
||||||
|
echo "✓ :msg returns while queuing recipient notification"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ :msg notification path failed"
|
||||||
|
sed -n '1,120p' "${STATE_DIR}/private-message.log"
|
||||||
|
sed -n '1,120p' "${STATE_DIR}/server.log"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
wait "${INTERACTIVE_PID}" 2>/dev/null || true
|
wait "${INTERACTIVE_PID}" 2>/dev/null || true
|
||||||
INTERACTIVE_PID=""
|
INTERACTIVE_PID=""
|
||||||
|
|
||||||
|
|
|
||||||
133
tests/test_tntctl_cli.sh
Executable file
133
tests/test_tntctl_cli.sh
Executable file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Local CLI-shape tests for tntctl. Uses a fake ssh in PATH.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
BIN="../tntctl"
|
||||||
|
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tntctl-cli-test.XXXXXX")
|
||||||
|
FAKE_BIN="${STATE_DIR}/bin"
|
||||||
|
SSH_LOG="${STATE_DIR}/ssh.argv"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$STATE_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$FAKE_BIN"
|
||||||
|
cat >"$FAKE_BIN/ssh" <<'FAKESSH'
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\n' "$#" > "$TNTCTL_SSH_LOG"
|
||||||
|
for arg in "$@"; do
|
||||||
|
printf '%s\n' "$arg" >> "$TNTCTL_SSH_LOG"
|
||||||
|
done
|
||||||
|
case "$*" in
|
||||||
|
*" users --xml") exit 64 ;;
|
||||||
|
*) printf 'fake-ok\n'; exit 0 ;;
|
||||||
|
esac
|
||||||
|
FAKESSH
|
||||||
|
chmod +x "$FAKE_BIN/ssh"
|
||||||
|
|
||||||
|
run_ok() {
|
||||||
|
label=$1
|
||||||
|
shift
|
||||||
|
: > "$SSH_LOG"
|
||||||
|
PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$@" >/dev/null 2>&1
|
||||||
|
status=$?
|
||||||
|
if [ "$status" -eq 0 ]; then
|
||||||
|
echo "✓ $label"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ $label (exit $status)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_usage() {
|
||||||
|
label=$1
|
||||||
|
shift
|
||||||
|
rm -f "$SSH_LOG"
|
||||||
|
PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$@" >/dev/null 2>&1
|
||||||
|
status=$?
|
||||||
|
if [ "$status" -eq 64 ] && [ ! -f "$SSH_LOG" ]; then
|
||||||
|
echo "✓ $label"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ $label (exit $status)"
|
||||||
|
[ -f "$SSH_LOG" ] && echo "fake ssh was invoked"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== TNTCTL CLI Tests ==="
|
||||||
|
|
||||||
|
if [ ! -x "$BIN" ]; then
|
||||||
|
echo "Error: Binary $BIN not found. Run make first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION_OUTPUT=$("$BIN" --version 2>/dev/null || true)
|
||||||
|
case "$VERSION_OUTPUT" in
|
||||||
|
"tntctl "*) echo "✓ version prints"; PASS=$((PASS + 1)) ;;
|
||||||
|
*) echo "✗ version output unexpected: $VERSION_OUTPUT"; FAIL=$((FAIL + 1)) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
run_ok "basic argv shape" "$BIN" -p 2222 example.com health
|
||||||
|
grep -q '^example.com$' "$SSH_LOG" &&
|
||||||
|
grep -q '^health$' "$SSH_LOG"
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ fake ssh receives host and command as separate argv"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ fake ssh argv unexpected"
|
||||||
|
cat "$SSH_LOG"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_ok "bounded host-key options are passed safely" "$BIN" --host-key-checking accept-new --known-hosts "$STATE_DIR/known_hosts" example.com health
|
||||||
|
grep -q '^StrictHostKeyChecking=accept-new$' "$SSH_LOG" &&
|
||||||
|
grep -q "^UserKnownHostsFile=$STATE_DIR/known_hosts$" "$SSH_LOG"
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ bounded host-key options are explicit"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ bounded host-key options missing"
|
||||||
|
cat "$SSH_LOG"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_ok "login builds user@host destination" "$BIN" -l operator example.com post "hello"
|
||||||
|
grep -q '^operator@example.com$' "$SSH_LOG"
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ login destination is explicit"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ login destination unexpected"
|
||||||
|
cat "$SSH_LOG"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$BIN" example.com users --xml >/dev/null 2>&1
|
||||||
|
REMOTE_STATUS=$?
|
||||||
|
if [ "$REMOTE_STATUS" -eq 64 ]; then
|
||||||
|
echo "✓ remote usage status is preserved"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ remote usage status unexpected: $REMOTE_STATUS"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_usage "rejects login starting with dash" "$BIN" -l -V example.com health
|
||||||
|
run_usage "rejects host starting with dash" "$BIN" -bad health
|
||||||
|
run_usage "rejects unknown command locally" "$BIN" example.com 'health;id'
|
||||||
|
run_usage "rejects newline command arg locally" "$BIN" example.com post "hello
|
||||||
|
world"
|
||||||
|
run_usage "rejects arbitrary ssh option" "$BIN" --ssh-option ProxyCommand=id example.com health
|
||||||
|
run_usage "rejects invalid host-key mode" "$BIN" --host-key-checking maybe example.com health
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "PASSED: $PASS"
|
||||||
|
echo "FAILED: $FAIL"
|
||||||
|
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
|
||||||
|
exit "$FAIL"
|
||||||
|
|
@ -159,8 +159,9 @@ TEST(room_remove_nonexistent_client) {
|
||||||
|
|
||||||
TEST(room_add_client_full) {
|
TEST(room_add_client_full) {
|
||||||
chat_room_t *room = room_create();
|
chat_room_t *room = room_create();
|
||||||
client_t clients[MAX_CLIENTS + 1];
|
client_t *clients = calloc((size_t)room->client_capacity + 1,
|
||||||
memset(clients, 0, sizeof(clients));
|
sizeof(*clients));
|
||||||
|
assert(clients != NULL);
|
||||||
|
|
||||||
for (int i = 0; i < room->client_capacity; i++) {
|
for (int i = 0; i < room->client_capacity; i++) {
|
||||||
assert(room_add_client(room, &clients[i]) == 0);
|
assert(room_add_client(room, &clients[i]) == 0);
|
||||||
|
|
@ -169,6 +170,23 @@ TEST(room_add_client_full) {
|
||||||
assert(room_add_client(room, &clients[room->client_capacity]) == -1);
|
assert(room_add_client(room, &clients[room->client_capacity]) == -1);
|
||||||
assert(room_get_client_count(room) == room->client_capacity);
|
assert(room_get_client_count(room) == room->client_capacity);
|
||||||
|
|
||||||
|
free(clients);
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_capacity_follows_tnt_max_connections) {
|
||||||
|
setenv("TNT_MAX_CONNECTIONS", "3", 1);
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
unsetenv("TNT_MAX_CONNECTIONS");
|
||||||
|
client_t clients[4];
|
||||||
|
memset(clients, 0, sizeof(clients));
|
||||||
|
|
||||||
|
assert(room->client_capacity == 3);
|
||||||
|
assert(room_add_client(room, &clients[0]) == 0);
|
||||||
|
assert(room_add_client(room, &clients[1]) == 0);
|
||||||
|
assert(room_add_client(room, &clients[2]) == 0);
|
||||||
|
assert(room_add_client(room, &clients[3]) == -1);
|
||||||
|
|
||||||
room_destroy(room);
|
room_destroy(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,6 +219,7 @@ int main(void) {
|
||||||
RUN_TEST(room_client_count);
|
RUN_TEST(room_client_count);
|
||||||
RUN_TEST(room_remove_nonexistent_client);
|
RUN_TEST(room_remove_nonexistent_client);
|
||||||
RUN_TEST(room_add_client_full);
|
RUN_TEST(room_add_client_full);
|
||||||
|
RUN_TEST(room_capacity_follows_tnt_max_connections);
|
||||||
RUN_TEST(room_message_count_threadsafe);
|
RUN_TEST(room_message_count_threadsafe);
|
||||||
|
|
||||||
printf("\nAll %d tests passed!\n", tests_passed);
|
printf("\nAll %d tests passed!\n", tests_passed);
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,10 @@ TEST(text_lookup_matches_language) {
|
||||||
"message cannot be empty") != NULL);
|
"message cannot be empty") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY),
|
||||||
"消息不能为空") != NULL);
|
"消息不能为空") != NULL);
|
||||||
|
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_COMMAND_TOO_LONG),
|
||||||
|
"command too long") != NULL);
|
||||||
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_COMMAND_TOO_LONG),
|
||||||
|
"命令过长") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||||
"Unknown command") != NULL);
|
"Unknown command") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||||
|
|
|
||||||
24
tnt.1
24
tnt.1
|
|
@ -144,6 +144,30 @@ ssh host \-p 2222 health
|
||||||
Exit codes follow
|
Exit codes follow
|
||||||
.BR sysexits (3)
|
.BR sysexits (3)
|
||||||
conventions.
|
conventions.
|
||||||
|
.SH EXIT STATUS
|
||||||
|
.TP
|
||||||
|
.B 0
|
||||||
|
Success.
|
||||||
|
.TP
|
||||||
|
.B 1
|
||||||
|
Runtime error, such as I/O failure, allocation failure, or persistence failure.
|
||||||
|
.TP
|
||||||
|
.B 64
|
||||||
|
Usage error, such as an unknown command, invalid option, or invalid argument
|
||||||
|
shape.
|
||||||
|
.TP
|
||||||
|
.B 69
|
||||||
|
Reserved for the local
|
||||||
|
.BR tntctl (1)
|
||||||
|
wrapper when SSH transport is unavailable.
|
||||||
|
.TP
|
||||||
|
.B 78
|
||||||
|
Reserved for future local
|
||||||
|
.BR tntctl (1)
|
||||||
|
configuration errors.
|
||||||
|
.PP
|
||||||
|
The SSH exec JSON field contract is documented in
|
||||||
|
.IR docs/INTERFACE.md .
|
||||||
.SH ENVIRONMENT
|
.SH ENVIRONMENT
|
||||||
.TP
|
.TP
|
||||||
.B PORT
|
.B PORT
|
||||||
|
|
|
||||||
119
tntctl.1
Normal file
119
tntctl.1
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
.\" tntctl(1) - TNT control client
|
||||||
|
.TH TNTCTL 1 "2026-05-24" "TNT 1.0.1" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
tntctl \- thin control client for a TNT server
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B tntctl
|
||||||
|
.RB [ \-p | \-\-port
|
||||||
|
.IR port ]
|
||||||
|
.RB [ \-l | \-\-login
|
||||||
|
.IR user ]
|
||||||
|
.RB [ \-\-host\-key\-checking
|
||||||
|
.IR mode ]
|
||||||
|
.RB [ \-\-known\-hosts
|
||||||
|
.IR file ]
|
||||||
|
.I host
|
||||||
|
.I command
|
||||||
|
.RI [ args ...]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
.B tntctl
|
||||||
|
runs TNT's documented SSH exec commands through the local
|
||||||
|
.BR ssh (1)
|
||||||
|
client.
|
||||||
|
It is intentionally a thin wrapper: it does not introduce a second control
|
||||||
|
protocol and does not bypass SSH host-key checking or authentication.
|
||||||
|
.PP
|
||||||
|
The command names, exit statuses, and JSON fields are shared with the SSH exec
|
||||||
|
interface documented in
|
||||||
|
.IR docs/INTERFACE.md .
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
.BR \-p ", " \-\-port " " \fIport\fR
|
||||||
|
Connect to
|
||||||
|
.I port
|
||||||
|
instead of the default 2222.
|
||||||
|
.TP
|
||||||
|
.BR \-l ", " \-\-login " " \fIuser\fR
|
||||||
|
Use
|
||||||
|
.I user
|
||||||
|
as the SSH login name.
|
||||||
|
For
|
||||||
|
.B post
|
||||||
|
commands, TNT uses this login name as the exec message identity.
|
||||||
|
.TP
|
||||||
|
.BR \-\-host\-key\-checking " " \fIyes|accept-new|no\fR
|
||||||
|
Set OpenSSH
|
||||||
|
.B StrictHostKeyChecking
|
||||||
|
to one of the listed modes.
|
||||||
|
.TP
|
||||||
|
.BR \-\-known\-hosts " " \fIfile\fR
|
||||||
|
Set the OpenSSH
|
||||||
|
.B UserKnownHostsFile
|
||||||
|
path.
|
||||||
|
.TP
|
||||||
|
.BR \-V ", " \-\-version
|
||||||
|
Print version and exit.
|
||||||
|
.TP
|
||||||
|
.BR \-h ", " \-\-help
|
||||||
|
Print a short usage summary and exit.
|
||||||
|
.SH COMMANDS
|
||||||
|
.TP
|
||||||
|
.B health
|
||||||
|
Print service health.
|
||||||
|
.TP
|
||||||
|
.B stats [--json]
|
||||||
|
Print room statistics.
|
||||||
|
.TP
|
||||||
|
.B users [--json]
|
||||||
|
List online users.
|
||||||
|
.TP
|
||||||
|
.B tail [N]
|
||||||
|
Print recent messages.
|
||||||
|
.TP
|
||||||
|
.B tail -n N
|
||||||
|
Print recent messages.
|
||||||
|
.TP
|
||||||
|
.B post MESSAGE
|
||||||
|
Post a message non-interactively.
|
||||||
|
.TP
|
||||||
|
.B help
|
||||||
|
Print the server exec help.
|
||||||
|
.SH EXAMPLES
|
||||||
|
.nf
|
||||||
|
tntctl chat.example.com health
|
||||||
|
tntctl -p 2222 chat.example.com stats --json
|
||||||
|
tntctl -l operator chat.example.com post "service notice"
|
||||||
|
tntctl --host-key-checking accept-new chat.example.com users
|
||||||
|
.fi
|
||||||
|
.SH EXIT STATUS
|
||||||
|
.TP
|
||||||
|
.B 0
|
||||||
|
Success.
|
||||||
|
.TP
|
||||||
|
.B 1
|
||||||
|
Runtime error in the local wrapper.
|
||||||
|
.TP
|
||||||
|
.B 64
|
||||||
|
Usage error, either from
|
||||||
|
.B tntctl
|
||||||
|
or the remote TNT exec command.
|
||||||
|
.TP
|
||||||
|
.B 69
|
||||||
|
The local
|
||||||
|
.BR ssh (1)
|
||||||
|
client could not be executed or exited with OpenSSH's transport-failure status.
|
||||||
|
.TP
|
||||||
|
.B 78
|
||||||
|
Reserved for future local configuration errors.
|
||||||
|
.SH SECURITY
|
||||||
|
.B tntctl
|
||||||
|
passes arguments directly to
|
||||||
|
.BR ssh (1)
|
||||||
|
without invoking a local shell.
|
||||||
|
It does not accept arbitrary SSH options or a password option.
|
||||||
|
Only the bounded host-key options above are exposed. Use normal SSH
|
||||||
|
configuration for jump hosts, identity files, and authentication. If the server
|
||||||
|
requires an access token, enter it through the normal SSH password prompt.
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR tnt (1),
|
||||||
|
.BR ssh (1)
|
||||||
Loading…
Reference in a new issue