mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 03:24: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:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
|
|
|||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
|
|
@ -5,6 +5,9 @@ on:
|
|||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
|
|
@ -15,15 +18,19 @@ jobs:
|
|||
- os: ubuntu-24.04
|
||||
target: linux-amd64
|
||||
artifact: tnt-linux-amd64
|
||||
ctl_artifact: tntctl-linux-amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
target: linux-arm64
|
||||
artifact: tnt-linux-arm64
|
||||
ctl_artifact: tntctl-linux-arm64
|
||||
- os: macos-15-intel
|
||||
target: darwin-amd64
|
||||
artifact: tnt-darwin-amd64
|
||||
ctl_artifact: tntctl-darwin-amd64
|
||||
- os: macos-15
|
||||
target: darwin-arm64
|
||||
artifact: tnt-darwin-arm64
|
||||
ctl_artifact: tntctl-darwin-arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -48,29 +55,38 @@ jobs:
|
|||
- name: Verify artifact architecture
|
||||
run: |
|
||||
file tnt
|
||||
file tntctl
|
||||
case "${{ matrix.target }}" in
|
||||
linux-amd64)
|
||||
file tnt | grep -E 'ELF 64-bit.*x86-64'
|
||||
file tntctl | grep -E 'ELF 64-bit.*x86-64'
|
||||
;;
|
||||
linux-arm64)
|
||||
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||
file tntctl | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||
;;
|
||||
darwin-amd64)
|
||||
file tnt | grep -E 'Mach-O 64-bit.*x86_64'
|
||||
file tntctl | grep -E 'Mach-O 64-bit.*x86_64'
|
||||
;;
|
||||
darwin-arm64)
|
||||
file tnt | grep -E 'Mach-O 64-bit.*arm64'
|
||||
file tntctl | grep -E 'Mach-O 64-bit.*arm64'
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Rename binary
|
||||
run: mv tnt ${{ matrix.artifact }}
|
||||
run: |
|
||||
mv tnt ${{ matrix.artifact }}
|
||||
mv tntctl ${{ matrix.ctl_artifact }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: ${{ matrix.artifact }}
|
||||
path: |
|
||||
${{ matrix.artifact }}
|
||||
${{ matrix.ctl_artifact }}
|
||||
|
||||
release:
|
||||
needs: build
|
||||
|
|
@ -90,7 +106,8 @@ jobs:
|
|||
run: |
|
||||
cd artifacts
|
||||
: > checksums.txt
|
||||
for artifact in */tnt-*; do
|
||||
for artifact in */tnt-* */tntctl-*; do
|
||||
[ -f "$artifact" ] || continue
|
||||
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
|
||||
done
|
||||
cat checksums.txt
|
||||
|
|
@ -100,6 +117,7 @@ jobs:
|
|||
with:
|
||||
files: |
|
||||
artifacts/*/tnt-*
|
||||
artifacts/*/tntctl-*
|
||||
artifacts/checksums.txt
|
||||
body: |
|
||||
## Installation
|
||||
|
|
@ -109,29 +127,41 @@ jobs:
|
|||
**Linux AMD64:**
|
||||
```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 }}/tntctl-linux-amd64
|
||||
chmod +x tnt-linux-amd64
|
||||
chmod +x tntctl-linux-amd64
|
||||
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
|
||||
sudo mv tntctl-linux-amd64 /usr/local/bin/tntctl
|
||||
```
|
||||
|
||||
**Linux ARM64:**
|
||||
```bash
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-arm64
|
||||
chmod +x tnt-linux-arm64
|
||||
chmod +x tntctl-linux-arm64
|
||||
sudo mv tnt-linux-arm64 /usr/local/bin/tnt
|
||||
sudo mv tntctl-linux-arm64 /usr/local/bin/tntctl
|
||||
```
|
||||
|
||||
**macOS Intel:**
|
||||
```bash
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-amd64
|
||||
chmod +x tnt-darwin-amd64
|
||||
chmod +x tntctl-darwin-amd64
|
||||
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
|
||||
sudo mv tntctl-darwin-amd64 /usr/local/bin/tntctl
|
||||
```
|
||||
|
||||
**macOS Apple Silicon:**
|
||||
```bash
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
|
||||
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-arm64
|
||||
chmod +x tnt-darwin-arm64
|
||||
chmod +x tntctl-darwin-arm64
|
||||
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
|
||||
sudo mv tntctl-darwin-arm64 /usr/local/bin/tntctl
|
||||
```
|
||||
|
||||
**Verify checksums:**
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
*.o
|
||||
obj/
|
||||
tnt
|
||||
tntctl
|
||||
messages.log
|
||||
host_key
|
||||
host_key.pub
|
||||
|
|
|
|||
32
Makefile
32
Makefile
|
|
@ -20,10 +20,13 @@ SRC_DIR = src
|
|||
INC_DIR = include
|
||||
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)
|
||||
DEPS = $(OBJECTS:.o=.d)
|
||||
DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d)
|
||||
TARGET = tnt
|
||||
CTL_TARGET = tntctl
|
||||
CTL_OBJECTS = $(OBJ_DIR)/tntctl.o
|
||||
TARGETS = $(TARGET) $(CTL_TARGET)
|
||||
|
||||
PREFIX ?= /usr/local
|
||||
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
|
||||
|
||||
all: $(TARGET)
|
||||
all: $(TARGETS)
|
||||
|
||||
$(TARGET): $(OBJECTS)
|
||||
$(CC) $(OBJECTS) -o $@ $(LDFLAGS)
|
||||
@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)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
|
||||
|
||||
|
|
@ -46,34 +53,40 @@ $(OBJ_DIR):
|
|||
mkdir -p $(OBJ_DIR)
|
||||
|
||||
clean:
|
||||
rm -rf $(OBJ_DIR) $(TARGET)
|
||||
rm -rf $(OBJ_DIR) $(TARGETS)
|
||||
rm -f tests/*.log tests/host_key* tests/messages.log
|
||||
@echo "Clean complete"
|
||||
|
||||
install: $(TARGET)
|
||||
install: $(TARGETS)
|
||||
install -d $(DESTDIR)$(BINDIR)
|
||||
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
|
||||
install -m 755 $(CTL_TARGET) $(DESTDIR)$(BINDIR)/
|
||||
install -d $(DESTDIR)$(MANDIR)/man1
|
||||
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
|
||||
install -m 644 tntctl.1 $(DESTDIR)$(MANDIR)/man1/
|
||||
|
||||
install-systemd:
|
||||
install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR)
|
||||
install -m 644 tnt.service $(DESTDIR)$(SYSTEMD_UNIT_DIR)/
|
||||
sed 's#^ExecStart=.*#ExecStart=$(BINDIR)/$(TARGET)#' tnt.service > "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
|
||||
chmod 644 "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
|
||||
rm -f $(DESTDIR)$(BINDIR)/$(CTL_TARGET)
|
||||
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
|
||||
rm -f $(DESTDIR)$(MANDIR)/man1/tntctl.1
|
||||
|
||||
uninstall-systemd:
|
||||
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
|
||||
|
||||
# Development targets
|
||||
debug: CFLAGS += -g -DDEBUG
|
||||
debug: clean $(TARGET)
|
||||
debug: clean $(TARGETS)
|
||||
|
||||
release: CFLAGS += -O3 -DNDEBUG
|
||||
release: clean $(TARGET)
|
||||
release: clean $(TARGETS)
|
||||
strip $(TARGET)
|
||||
strip $(CTL_TARGET)
|
||||
|
||||
release-check:
|
||||
./scripts/release_check.sh
|
||||
|
|
@ -83,7 +96,7 @@ release-check-strict:
|
|||
|
||||
asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
|
||||
asan: LDFLAGS += -fsanitize=address
|
||||
asan: clean $(TARGET)
|
||||
asan: clean $(TARGETS)
|
||||
@echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt"
|
||||
|
||||
valgrind: debug
|
||||
|
|
@ -112,6 +125,7 @@ integration-test: all
|
|||
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh
|
||||
@cd tests && ./test_tntctl_cli.sh
|
||||
|
||||
anonymous-access-test: all
|
||||
@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
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
The installer verifies the downloaded release binary against `checksums.txt`
|
||||
before installing it.
|
||||
The installer verifies downloaded release binaries against `checksums.txt`
|
||||
before installing them. Older releases may provide only `tnt`; newer releases
|
||||
also install `tntctl`.
|
||||
|
||||
**From source:**
|
||||
```sh
|
||||
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
### Building
|
||||
|
|
@ -254,6 +267,7 @@ TNT/
|
|||
│ ├── commands.c # COMMAND-mode command dispatch
|
||||
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
|
||||
│ ├── exec.c # SSH exec command dispatch
|
||||
│ ├── tntctl.c # local wrapper around the SSH exec interface
|
||||
│ ├── ssh_server.c # SSH server implementation
|
||||
│ ├── bootstrap.c # SSH authentication and session bootstrap
|
||||
│ ├── 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
|
||||
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
|
||||
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
|
||||
- [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields
|
||||
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
|
||||
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
|
||||
- [Changelog](docs/CHANGELOG.md) - Version history
|
||||
|
|
|
|||
61
SECURITY.md
Normal file
61
SECURITY.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
TNT currently supports security fixes for the latest published release and the
|
||||
current `main` branch.
|
||||
|
||||
| Version | Supported |
|
||||
|---|---|
|
||||
| latest release | yes |
|
||||
| `main` | best effort |
|
||||
| older releases | no |
|
||||
|
||||
This policy will become stricter after TNT has a longer stable release history.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Do not open a public issue for a security vulnerability.
|
||||
|
||||
Report privately through one of these paths:
|
||||
|
||||
- GitHub private vulnerability reporting, when available on the repository
|
||||
- email: `contact@m1ng.space`
|
||||
|
||||
Include:
|
||||
|
||||
- affected version or commit
|
||||
- operating system and deployment shape
|
||||
- reproduction steps or proof of concept
|
||||
- expected impact
|
||||
- whether the issue is already public
|
||||
|
||||
## Response
|
||||
|
||||
The maintainer will try to acknowledge valid reports within 7 days. Fixes may
|
||||
land on `main` before a release is published. For serious issues, the release
|
||||
notes will mention the security impact after users have a reasonable upgrade
|
||||
path.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- remote crashes or memory-safety bugs
|
||||
- authentication or access-token bypass
|
||||
- unintended file writes outside `TNT_STATE_DIR`
|
||||
- privilege escalation in packaged service configuration
|
||||
- release artifact tampering or installer verification bypass
|
||||
|
||||
Out of scope:
|
||||
|
||||
- denial of service from an operator intentionally disabling rate limits
|
||||
- identity spoofing in the documented anonymous-access mode
|
||||
- vulnerabilities requiring local administrator access to the host
|
||||
|
||||
## Release Integrity
|
||||
|
||||
Release binaries are published with `checksums.txt`. The installer verifies
|
||||
the selected binary against that file before installation. Future releases
|
||||
should add a detached signature for `checksums.txt` before package recipes are
|
||||
submitted to public registries.
|
||||
|
|
@ -2,7 +2,48 @@
|
|||
|
||||
## 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
|
||||
- `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
|
||||
locale/code parsing, while `src/i18n_text.c` owns the table-driven text
|
||||
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
|
||||
-----------------
|
||||
Release policy:
|
||||
- Use SemVer-style tags: vMAJOR.MINOR.PATCH.
|
||||
- Bump PATCH for compatible bug fixes and release hardening.
|
||||
- Bump MINOR for new commands, new documented flags, JSON field additions,
|
||||
or visible user-interface behavior changes.
|
||||
- Bump MAJOR for incompatible command, config, storage, or package behavior.
|
||||
- Keep GitHub draft release review manual. Do not auto-publish releases.
|
||||
- Keep production deployment manual. Do not SSH into production from CI.
|
||||
|
||||
1. Update version metadata:
|
||||
- include/common.h
|
||||
- tnt.1
|
||||
- docs/CHANGELOG.md
|
||||
- packaging/arch/PKGBUILD
|
||||
- packaging/homebrew/tnt-chat.rb
|
||||
- packaging/debian/debian/changelog
|
||||
- package checksums and maintainer metadata, when preparing public package
|
||||
recipes
|
||||
|
||||
2. Run the local preflight:
|
||||
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
|
||||
|
||||
4. Create and push tag:
|
||||
git tag v1.0.1
|
||||
Strict mode requires the local `vX.Y.Z` tag to point at HEAD. It also
|
||||
builds from the tagged source archive, so it catches files that were left
|
||||
untracked and would be missing from GitHub's source archive.
|
||||
|
||||
5. Push the tag:
|
||||
git push origin v1.0.1
|
||||
|
||||
5. GitHub Actions automatically:
|
||||
- Builds binaries (Linux/macOS, AMD64/ARM64)
|
||||
6. GitHub Actions automatically:
|
||||
- Builds `tnt` and `tntctl` binaries (Linux/macOS, AMD64/ARM64)
|
||||
- Creates a draft release
|
||||
- Uploads binaries
|
||||
- Generates one `checksums.txt` file
|
||||
- Verifies that artifact architecture matches the asset name
|
||||
|
||||
6. Review the draft release, smoke-test downloaded assets, then publish it
|
||||
7. Review the draft release, smoke-test downloaded assets, then publish it
|
||||
manually from GitHub.
|
||||
|
||||
7. Release appears at:
|
||||
8. Release appears at:
|
||||
https://github.com/m1ngsama/TNT/releases
|
||||
|
||||
|
||||
RELEASE REVIEW CHECKLIST
|
||||
------------------------
|
||||
Before publishing a draft release:
|
||||
- Confirm `git tag` points at the intended commit.
|
||||
- Download every release asset from GitHub, not from the local workspace.
|
||||
- Verify `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
|
||||
--------------------
|
||||
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
|
||||
exec_catalog.c → SSH exec command matching, usage, and argument shape
|
||||
exec.c → SSH exec command dispatch
|
||||
tntctl.c → local wrapper around the SSH exec interface
|
||||
ssh_server.c → SSH listener setup
|
||||
bootstrap.c → SSH authentication/session bootstrap
|
||||
input.c → interactive session loop
|
||||
|
|
@ -69,7 +70,7 @@ utf8.c → UTF-8 string handling
|
|||
|
||||
## Known Limits
|
||||
|
||||
- Max 64 clients (MAX_CLIENTS)
|
||||
- Default 64 clients, configurable with `TNT_MAX_CONNECTIONS`
|
||||
- Max 100 messages in memory (MAX_MESSAGES)
|
||||
- Max 1024 bytes per message (MAX_MESSAGE_LEN)
|
||||
- Max 64 bytes username (MAX_USERNAME_LEN)
|
||||
|
|
|
|||
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.
|
||||
|
||||
- split the current surface into `tntd` (daemon) and `tntctl` (control client)
|
||||
- keep SSH exec support, but treat it as a transport for stable commands rather than the primary API shape
|
||||
- define stable subcommands and exit codes for:
|
||||
- ✅ introduce `tntctl` as a thin control client over the stable SSH exec surface
|
||||
- keep SSH exec support, but treat it as a transport for stable commands rather
|
||||
than an ad hoc command surface
|
||||
- ✅ define stable subcommands and exit codes for:
|
||||
- `health`
|
||||
- `stats`
|
||||
- `users`
|
||||
- `tail`
|
||||
- `post`
|
||||
- support text and JSON output modes where machine use is likely
|
||||
- normalize command parsing, help text, and error reporting
|
||||
- ✅ support text and JSON output modes where machine use is likely
|
||||
- ✅ 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 a man page for `tntd` and `tntctl`
|
||||
- ✅ add man pages for `tnt` and `tntctl`
|
||||
|
||||
## Stage 2: Runtime Model
|
||||
|
||||
Goal: make long-running operation boring and reliable.
|
||||
|
||||
- move client state to a clearer ownership model with one release path
|
||||
- finish replacing ad hoc cross-thread UI mutation with per-client event delivery
|
||||
- add bounded outbound queues so slow clients cannot stall other users
|
||||
- ✅ remove cross-client SSH channel writes from mention and private-message
|
||||
notifications
|
||||
- continue replacing ad hoc cross-thread UI mutation with per-client event
|
||||
delivery
|
||||
- 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
|
||||
- make room/client capacity fully runtime-configurable with no hidden compile-time ceiling
|
||||
- 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
|
||||
- add admin-only controls for read-only mode, mute, and ban
|
||||
- expose a minimal health and stats surface suitable for monitoring
|
||||
- ✅ expose a minimal health and stats surface suitable for monitoring
|
||||
- support systemd-friendly readiness and watchdog behavior
|
||||
- document recommended production defaults for public, private, and localhost-only deployments
|
||||
- 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.
|
||||
|
||||
1. Introduce `tntctl` and move stable command handling behind it.
|
||||
2. Define exit codes and JSON schemas for `health`, `stats`, `users`, `tail`, and `post`.
|
||||
3. Add per-client outbound queues and finish untangling client-state ownership.
|
||||
4. Remove the remaining hidden runtime limits and make them explicit configuration.
|
||||
5. Add a long-running soak test that exercises idle sessions, reconnects, and slow consumers.
|
||||
1. Decide the daemon naming path: keep `tnt` as the server binary for 1.x, or
|
||||
introduce `tntd` later with a compatibility plan.
|
||||
2. Add per-client outbound queues and finish untangling client-state ownership.
|
||||
3. Remove the remaining hidden runtime limits and make them explicit
|
||||
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. */
|
||||
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
|
||||
* fit in 2048 bytes; truncation or encoding errors return -1. */
|
||||
int client_printf(client_t *client, const char *fmt, ...);
|
||||
|
|
|
|||
|
|
@ -15,9 +15,8 @@
|
|||
* - Toggles client->mute_joins on `:mute-joins`
|
||||
* - May broadcast a system rename message on `:nick`
|
||||
*
|
||||
* Reads g_room. Caller must already hold the channel I/O serialisation
|
||||
* established by handle_key() — this function calls back into client_send
|
||||
* (via tui_render_command_output) which acquires client->io_lock. */
|
||||
* Reads g_room. Renders command output through the normal client_send()
|
||||
* path; callers must not hold client->io_lock before dispatching. */
|
||||
void commands_dispatch(client_t *client);
|
||||
|
||||
#endif /* COMMANDS_H */
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@
|
|||
/* Project Metadata */
|
||||
#define TNT_VERSION "1.0.1"
|
||||
|
||||
/* Public process/exec exit statuses. TNT follows the common sysexits(3)
|
||||
* convention for usage errors while keeping runtime failures portable. */
|
||||
#define TNT_EXIT_OK 0
|
||||
#define TNT_EXIT_ERROR 1
|
||||
#define TNT_EXIT_USAGE 64
|
||||
#define TNT_EXIT_UNAVAILABLE 69
|
||||
#define TNT_EXIT_CONFIG 78
|
||||
|
||||
/* Configuration constants */
|
||||
#define DEFAULT_PORT 2222
|
||||
#define MAX_MESSAGES 100
|
||||
|
|
@ -21,7 +29,8 @@
|
|||
#define MAX_MESSAGE_LEN 1024
|
||||
#define MAX_EXEC_COMMAND_LEN 1024
|
||||
#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 MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
|
||||
#define HOST_KEY_FILE "host_key"
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
/* Dispatch the non-interactive SSH exec command stored in
|
||||
* client->exec_command. Returns the exit status to send back to the
|
||||
* SSH client:
|
||||
* 0 = success
|
||||
* 1 = runtime error (I/O, OOM, persistence failure)
|
||||
* 64 = usage error (unknown command, bad args)
|
||||
* TNT_EXIT_OK = success
|
||||
* TNT_EXIT_ERROR = runtime error (I/O, OOM, persistence failure)
|
||||
* TNT_EXIT_USAGE = usage error (unknown command, bad args)
|
||||
*
|
||||
* Reads g_room and shared client state. Safe to call once per
|
||||
* exec-mode session before the channel is closed. */
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ typedef enum {
|
|||
I18N_UNKNOWN_GUIDANCE,
|
||||
I18N_EXEC_POST_EMPTY,
|
||||
I18N_EXEC_POST_INVALID_UTF8,
|
||||
I18N_EXEC_POST_TOO_LONG,
|
||||
I18N_EXEC_POST_PERSIST_FAILED,
|
||||
I18N_EXEC_COMMAND_TOO_LONG,
|
||||
I18N_EXEC_UNKNOWN_COMMAND_FORMAT,
|
||||
I18N_TEXT_COUNT
|
||||
} i18n_text_id_t;
|
||||
|
|
|
|||
|
|
@ -44,14 +44,16 @@ typedef struct client {
|
|||
int command_output_scroll;
|
||||
bool show_motd; /* command_output holds MOTD text */
|
||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||
bool exec_command_too_long;
|
||||
char ssh_login[MAX_USERNAME_LEN];
|
||||
time_t connect_time;
|
||||
time_t last_active;
|
||||
atomic_bool redraw_pending;
|
||||
_Atomic int pending_bells; /* Bell nudges for this client's loop */
|
||||
_Atomic int unread_mentions; /* @-mentions received since last reset */
|
||||
_Atomic int unread_whispers; /* whispers received since last :inbox view */
|
||||
/* Per-client whisper inbox. Pushes serialise on io_lock; readers are
|
||||
* the client's own thread inside :inbox handling. */
|
||||
/* Per-client whisper inbox. Protected separately from SSH channel I/O
|
||||
* so slow writes do not block in-memory private-message delivery. */
|
||||
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
|
||||
int whisper_inbox_count;
|
||||
bool mute_joins;
|
||||
|
|
@ -60,6 +62,7 @@ typedef struct client {
|
|||
int ref_count; /* Reference count for safe cleanup */
|
||||
pthread_mutex_t ref_lock; /* Lock for ref_count */
|
||||
pthread_mutex_t io_lock; /* Serialize SSH channel writes */
|
||||
pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */
|
||||
struct ssh_channel_callbacks_struct *channel_cb;
|
||||
} client_t;
|
||||
|
||||
|
|
|
|||
63
install.sh
63
install.sh
|
|
@ -45,7 +45,8 @@ case "$ARCH" in
|
|||
*) fail "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
BINARY="tnt-${OS}-${ARCH}"
|
||||
SERVER_BINARY="tnt-${OS}-${ARCH}"
|
||||
CTL_BINARY="tntctl-${OS}-${ARCH}"
|
||||
|
||||
echo "=== TNT Installer ==="
|
||||
echo "OS: $OS"
|
||||
|
|
@ -65,51 +66,81 @@ fi
|
|||
echo "Installing version: $VERSION"
|
||||
|
||||
# Download
|
||||
URL="https://github.com/$REPO/releases/download/$VERSION/$BINARY"
|
||||
SERVER_URL="https://github.com/$REPO/releases/download/$VERSION/$SERVER_BINARY"
|
||||
CTL_URL="https://github.com/$REPO/releases/download/$VERSION/$CTL_BINARY"
|
||||
CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt"
|
||||
echo "Downloading from: $URL"
|
||||
echo "Downloading from: $SERVER_URL"
|
||||
|
||||
TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
|
||||
SERVER_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
|
||||
CTL_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tntctl.XXXXXX")
|
||||
CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX")
|
||||
INSTALL_CTL=0
|
||||
cleanup() {
|
||||
rm -f "$TMP_FILE" "$CHECKSUM_FILE"
|
||||
rm -f "$SERVER_TMP_FILE" "$CTL_TMP_FILE" "$CHECKSUM_FILE"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
curl -fsSL -o "$TMP_FILE" "$URL" || fail "Failed to download $BINARY"
|
||||
curl -fsSL -o "$SERVER_TMP_FILE" "$SERVER_URL" ||
|
||||
fail "Failed to download $SERVER_BINARY"
|
||||
|
||||
echo "Downloading checksums from: $CHECKSUM_URL"
|
||||
curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" ||
|
||||
fail "Failed to download checksums.txt"
|
||||
|
||||
EXPECTED_SHA=$(awk -v name="$BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||
[ -n "$EXPECTED_SHA" ] || fail "No checksum entry found for $BINARY"
|
||||
EXPECTED_SERVER_SHA=$(awk -v name="$SERVER_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||
[ -n "$EXPECTED_SERVER_SHA" ] || fail "No checksum entry found for $SERVER_BINARY"
|
||||
EXPECTED_CTL_SHA=$(awk -v name="$CTL_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||
|
||||
ACTUAL_SHA=$(sha256_of "$TMP_FILE") ||
|
||||
ACTUAL_SERVER_SHA=$(sha256_of "$SERVER_TMP_FILE") ||
|
||||
fail "sha256sum or shasum is required for checksum verification"
|
||||
|
||||
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] ||
|
||||
fail "Checksum mismatch for $BINARY"
|
||||
[ "$ACTUAL_SERVER_SHA" = "$EXPECTED_SERVER_SHA" ] ||
|
||||
fail "Checksum mismatch for $SERVER_BINARY"
|
||||
|
||||
echo "Checksum verified: $ACTUAL_SHA"
|
||||
echo "Checksum verified: $SERVER_BINARY $ACTUAL_SERVER_SHA"
|
||||
if [ -n "$EXPECTED_CTL_SHA" ]; then
|
||||
echo "Downloading from: $CTL_URL"
|
||||
curl -fsSL -o "$CTL_TMP_FILE" "$CTL_URL" ||
|
||||
fail "Failed to download $CTL_BINARY"
|
||||
ACTUAL_CTL_SHA=$(sha256_of "$CTL_TMP_FILE") ||
|
||||
fail "sha256sum or shasum is required for checksum verification"
|
||||
[ "$ACTUAL_CTL_SHA" = "$EXPECTED_CTL_SHA" ] ||
|
||||
fail "Checksum mismatch for $CTL_BINARY"
|
||||
echo "Checksum verified: $CTL_BINARY $ACTUAL_CTL_SHA"
|
||||
INSTALL_CTL=1
|
||||
else
|
||||
echo "No checksum entry found for $CTL_BINARY; skipping tntctl for this release"
|
||||
fi
|
||||
|
||||
# Install
|
||||
chmod +x "$TMP_FILE"
|
||||
chmod +x "$SERVER_TMP_FILE"
|
||||
[ "$INSTALL_CTL" -eq 0 ] || chmod +x "$CTL_TMP_FILE"
|
||||
|
||||
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
|
||||
install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
[ "$INSTALL_CTL" -eq 0 ] || install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
|
||||
else
|
||||
echo "Need sudo for installation to $INSTALL_DIR"
|
||||
need_cmd sudo
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
sudo install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
[ "$INSTALL_CTL" -eq 0 ] || sudo install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "TNT installed successfully to $INSTALL_DIR/tnt"
|
||||
if [ "$INSTALL_CTL" -eq 1 ]; then
|
||||
echo "TNT installed successfully to $INSTALL_DIR/tnt and $INSTALL_DIR/tntctl"
|
||||
else
|
||||
echo "TNT installed successfully to $INSTALL_DIR/tnt"
|
||||
fi
|
||||
echo ""
|
||||
echo "Run with:"
|
||||
echo " tnt"
|
||||
echo ""
|
||||
echo "Or specify port:"
|
||||
echo " PORT=3333 tnt"
|
||||
if [ "$INSTALL_CTL" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "Control a server with:"
|
||||
echo " tntctl localhost health"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ any public registry.
|
|||
- `homebrew/` - Homebrew tap formula draft and maintainer notes.
|
||||
- `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata.
|
||||
|
||||
Package installs include both `tnt` and `tntctl`. `tnt` is the server process;
|
||||
`tntctl` is a thin wrapper around the documented SSH exec interface.
|
||||
|
||||
## Release checklist
|
||||
|
||||
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,6 @@ debuild -S
|
|||
## Package shape
|
||||
|
||||
- Binary package name: `tnt-chat`
|
||||
- Installed command: `/usr/bin/tnt`
|
||||
- Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl`
|
||||
- Runtime dependency: `libssh`
|
||||
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service`
|
||||
|
|
|
|||
|
|
@ -12,10 +12,13 @@ class TntChat < Formula
|
|||
system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}"
|
||||
|
||||
bin.install "#{buildpath}/stage#{prefix}/bin/tnt"
|
||||
bin.install "#{buildpath}/stage#{prefix}/bin/tntctl"
|
||||
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1"
|
||||
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tntctl.1"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/tnt --version")
|
||||
assert_match version.to_s, shell_output("#{bin}/tntctl --version")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ Environment:
|
|||
RUN_INTEGRATION=1 also run full make test
|
||||
PORT=12720 base port for integration tests
|
||||
|
||||
Strict checks additionally require real package checksums and a local vX.Y.Z tag.
|
||||
Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a
|
||||
matching changelog release section, real package checksums, and non-placeholder
|
||||
maintainer metadata, then build from the tagged source archive.
|
||||
USAGE
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +64,8 @@ version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
|||
step "checking version metadata for $version"
|
||||
grep -q "\"TNT $version\"" tnt.1 ||
|
||||
fail "tnt.1 does not mention TNT $version"
|
||||
grep -q "\"TNT $version\"" tntctl.1 ||
|
||||
fail "tntctl.1 does not mention TNT $version"
|
||||
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
|
||||
fail "packaging/arch/PKGBUILD pkgver does not match $version"
|
||||
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
|
||||
|
|
@ -88,11 +92,25 @@ make
|
|||
actual_version=$(./tnt --version)
|
||||
[ "$actual_version" = "tnt $version" ] ||
|
||||
fail "binary version mismatch: expected 'tnt $version', got '$actual_version'"
|
||||
tntctl_version=$(./tntctl --version)
|
||||
[ "$tntctl_version" = "tntctl $version" ] ||
|
||||
fail "control binary version mismatch: expected 'tntctl $version', got '$tntctl_version'"
|
||||
|
||||
step "running unit tests"
|
||||
make -C tests/unit clean
|
||||
make -C tests/unit run
|
||||
|
||||
step "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
|
||||
step "running full integration tests"
|
||||
make test PORT="${PORT:-12720}"
|
||||
|
|
@ -109,9 +127,13 @@ make DESTDIR="$tmpdir" PREFIX=/usr install
|
|||
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
|
||||
|
||||
[ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt"
|
||||
[ -x "$tmpdir/usr/bin/tntctl" ] || fail "missing executable: /usr/bin/tntctl"
|
||||
[ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1"
|
||||
[ -f "$tmpdir/usr/share/man/man1/tntctl.1" ] || fail "missing manpage: /usr/share/man/man1/tntctl.1"
|
||||
[ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] ||
|
||||
fail "missing systemd unit: /usr/lib/systemd/system/tnt.service"
|
||||
grep -q "^ExecStart=/usr/bin/tnt$" "$tmpdir/usr/lib/systemd/system/tnt.service" ||
|
||||
fail "systemd unit ExecStart does not match PREFIX=/usr install path"
|
||||
|
||||
step "checking installer syntax"
|
||||
sh -n install.sh
|
||||
|
|
@ -137,14 +159,61 @@ fi
|
|||
|
||||
if [ "$STRICT" -eq 1 ]; then
|
||||
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 ||
|
||||
fail "replace PKGBUILD sha256sums before strict release"
|
||||
! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO ||
|
||||
fail "replace .SRCINFO sha256sums before strict release"
|
||||
! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "replace Homebrew sha256 before strict release"
|
||||
git rev-parse -q --verify "refs/tags/v$version" >/dev/null ||
|
||||
fail "missing local tag v$version"
|
||||
! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
|
||||
fail "replace maintainer email placeholders before strict release"
|
||||
|
||||
step "checking tagged source archive"
|
||||
archive="$tmpdir/tnt-$version-source.tar.gz"
|
||||
archive_extract="$tmpdir/source"
|
||||
archive_install="$tmpdir/source-install"
|
||||
archive_root="$archive_extract/TNT-$version"
|
||||
|
||||
git archive --format=tar.gz --prefix="TNT-$version/" \
|
||||
-o "$archive" "refs/tags/v$version"
|
||||
mkdir -p "$archive_extract"
|
||||
tar -xzf "$archive" -C "$archive_extract"
|
||||
|
||||
[ -f "$archive_root/src/tntctl.c" ] ||
|
||||
fail "tagged source archive is missing src/tntctl.c"
|
||||
[ -f "$archive_root/tnt.1" ] ||
|
||||
fail "tagged source archive is missing tnt.1"
|
||||
[ -f "$archive_root/tntctl.1" ] ||
|
||||
fail "tagged source archive is missing tntctl.1"
|
||||
[ -f "$archive_root/LICENSE" ] ||
|
||||
fail "tagged source archive is missing LICENSE"
|
||||
|
||||
(
|
||||
cd "$archive_root"
|
||||
make
|
||||
make DESTDIR="$archive_install" PREFIX=/usr install
|
||||
make DESTDIR="$archive_install" PREFIX=/usr install-systemd
|
||||
)
|
||||
|
||||
[ -x "$archive_install/usr/bin/tnt" ] ||
|
||||
fail "tagged source install is missing /usr/bin/tnt"
|
||||
[ -x "$archive_install/usr/bin/tntctl" ] ||
|
||||
fail "tagged source install is missing /usr/bin/tntctl"
|
||||
[ -f "$archive_install/usr/share/man/man1/tnt.1" ] ||
|
||||
fail "tagged source install is missing tnt.1"
|
||||
[ -f "$archive_install/usr/share/man/man1/tntctl.1" ] ||
|
||||
fail "tagged source install is missing tntctl.1"
|
||||
grep -q "^ExecStart=/usr/bin/tnt$" \
|
||||
"$archive_install/usr/lib/systemd/system/tnt.service" ||
|
||||
fail "tagged source systemd unit ExecStart does not match /usr/bin/tnt"
|
||||
fi
|
||||
|
||||
step "release preflight passed"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ typedef struct {
|
|||
int pty_width;
|
||||
int pty_height;
|
||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||
bool exec_command_too_long;
|
||||
bool auth_success;
|
||||
int auth_attempts;
|
||||
bool channel_ready; /* Set when shell/exec request received */
|
||||
|
|
@ -294,8 +295,13 @@ static int channel_exec_request(ssh_session session, ssh_channel channel,
|
|||
|
||||
/* Store exec command */
|
||||
if (command) {
|
||||
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
|
||||
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
|
||||
if (strlen(command) >= sizeof(ctx->exec_command)) {
|
||||
ctx->exec_command_too_long = true;
|
||||
ctx->exec_command[0] = '\0';
|
||||
} else {
|
||||
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
|
||||
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* Mark channel as ready */
|
||||
|
|
@ -363,6 +369,7 @@ void *bootstrap_run(void *arg) {
|
|||
ctx->pty_width = 80;
|
||||
ctx->pty_height = 24;
|
||||
ctx->exec_command[0] = '\0';
|
||||
ctx->exec_command_too_long = false;
|
||||
ctx->requested_user[0] = '\0';
|
||||
ctx->auth_success = false;
|
||||
ctx->auth_attempts = 0;
|
||||
|
|
@ -451,6 +458,7 @@ void *bootstrap_run(void *arg) {
|
|||
client->ref_count = 1;
|
||||
pthread_mutex_init(&client->ref_lock, NULL);
|
||||
pthread_mutex_init(&client->io_lock, NULL);
|
||||
pthread_mutex_init(&client->whisper_lock, NULL);
|
||||
|
||||
if (ctx->requested_user[0] != '\0') {
|
||||
strncpy(client->ssh_login, ctx->requested_user,
|
||||
|
|
@ -466,6 +474,7 @@ void *bootstrap_run(void *arg) {
|
|||
sizeof(client->exec_command) - 1);
|
||||
client->exec_command[sizeof(client->exec_command) - 1] = '\0';
|
||||
}
|
||||
client->exec_command_too_long = ctx->exec_command_too_long;
|
||||
|
||||
/* Add a ref for the channel callbacks (eof/close/window_change) so the
|
||||
* client_t outlives any in-flight callback invocation. */
|
||||
|
|
|
|||
|
|
@ -4,19 +4,8 @@
|
|||
chat_room_t *g_room = NULL;
|
||||
|
||||
static int room_capacity_from_env(void) {
|
||||
const char *env = getenv("TNT_MAX_CONNECTIONS");
|
||||
|
||||
if (!env || env[0] == '\0') {
|
||||
return MAX_CLIENTS;
|
||||
}
|
||||
|
||||
char *end;
|
||||
long capacity = strtol(env, &end, 10);
|
||||
if (*end != '\0' || capacity < 1 || capacity > 1024) {
|
||||
return MAX_CLIENTS;
|
||||
}
|
||||
|
||||
return (int)capacity;
|
||||
return env_int("TNT_MAX_CONNECTIONS", DEFAULT_MAX_CLIENTS, 1,
|
||||
MAX_CONFIGURED_CLIENTS);
|
||||
}
|
||||
|
||||
/* Initialize chat room */
|
||||
|
|
|
|||
37
src/client.c
37
src/client.c
|
|
@ -9,6 +9,13 @@
|
|||
#include <stdio.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 */
|
||||
int client_send(client_t *client, const char *data, size_t len) {
|
||||
size_t total = 0;
|
||||
|
|
@ -24,11 +31,21 @@ int client_send(client_t *client, const char *data, size_t len) {
|
|||
|
||||
while (total < len) {
|
||||
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;
|
||||
if (chunk > window) {
|
||||
chunk = window;
|
||||
}
|
||||
|
||||
int sent = ssh_channel_write(client->channel, data + total, chunk);
|
||||
if (sent <= 0) {
|
||||
pthread_mutex_unlock(&client->io_lock);
|
||||
return -1;
|
||||
return client_send_fail(client);
|
||||
}
|
||||
total += (size_t)sent;
|
||||
}
|
||||
|
|
@ -41,6 +58,23 @@ int client_send(client_t *client, const char *data, size_t len) {
|
|||
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) {
|
||||
if (!client) return;
|
||||
pthread_mutex_lock(&client->ref_lock);
|
||||
|
|
@ -75,6 +109,7 @@ void client_release(client_t *client) {
|
|||
free(client->channel_cb);
|
||||
}
|
||||
pthread_mutex_destroy(&client->io_lock);
|
||||
pthread_mutex_destroy(&client->whisper_lock);
|
||||
pthread_mutex_destroy(&client->ref_lock);
|
||||
free(client);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,9 +199,9 @@ void commands_dispatch(client_t *client) {
|
|||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
if (target) {
|
||||
/* Push into recipient's inbox. io_lock serialises so two
|
||||
* senders to the same recipient don't tear the ring. */
|
||||
pthread_mutex_lock(&target->io_lock);
|
||||
/* Push into recipient's inbox. whisper_lock serialises so
|
||||
* two senders to the same recipient don't tear the ring. */
|
||||
pthread_mutex_lock(&target->whisper_lock);
|
||||
int slot;
|
||||
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
|
||||
slot = target->whisper_inbox_count++;
|
||||
|
|
@ -219,13 +219,12 @@ void commands_dispatch(client_t *client) {
|
|||
snprintf(target->whisper_inbox[slot].content,
|
||||
sizeof(target->whisper_inbox[slot].content),
|
||||
"%s", rest);
|
||||
pthread_mutex_unlock(&target->io_lock);
|
||||
pthread_mutex_unlock(&target->whisper_lock);
|
||||
|
||||
target->unread_whispers++;
|
||||
target->redraw_pending = true;
|
||||
/* Audible nudge — the title bar ✉ counter (UX-11 style)
|
||||
* carries the persistent signal. */
|
||||
client_send(target, "\a", 1);
|
||||
client_queue_bell(target);
|
||||
client_release(target);
|
||||
}
|
||||
|
||||
|
|
@ -243,15 +242,15 @@ void commands_dispatch(client_t *client) {
|
|||
}
|
||||
|
||||
} else if (command_id == TNT_COMMAND_INBOX) {
|
||||
/* Snapshot the inbox under io_lock so a concurrent sender doesn't
|
||||
/* Snapshot the inbox under whisper_lock so a concurrent sender doesn't
|
||||
* tear what we're rendering. Counter reset happens after copy. */
|
||||
whisper_t snapshot[WHISPER_INBOX_SIZE];
|
||||
int snap_count;
|
||||
pthread_mutex_lock(&client->io_lock);
|
||||
pthread_mutex_lock(&client->whisper_lock);
|
||||
snap_count = client->whisper_inbox_count;
|
||||
memcpy(snapshot, client->whisper_inbox,
|
||||
snap_count * sizeof(whisper_t));
|
||||
pthread_mutex_unlock(&client->io_lock);
|
||||
pthread_mutex_unlock(&client->whisper_lock);
|
||||
client->unread_whispers = 0;
|
||||
|
||||
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';
|
||||
exec_catalog_append_help(help_text, sizeof(help_text), &pos,
|
||||
client->ui_lang);
|
||||
return client_send(client, help_text, pos) == 0 ? 0 : 1;
|
||||
return client_send(client, help_text, pos) == 0 ? TNT_EXIT_OK
|
||||
: TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
|
||||
|
|
@ -134,12 +135,13 @@ static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
|
|||
exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
|
||||
client->ui_lang);
|
||||
client_printf(client, "%s", usage);
|
||||
return 64;
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
||||
static int exec_command_health(client_t *client) {
|
||||
static const char ok[] = "ok\n";
|
||||
return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1;
|
||||
return client_send(client, ok, sizeof(ok) - 1) == 0 ? TNT_EXIT_OK
|
||||
: TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
static int exec_command_users(client_t *client, bool json) {
|
||||
|
|
@ -157,7 +159,7 @@ static int exec_command_users(client_t *client, bool json) {
|
|||
if (!usernames) {
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
client_printf(client, "users: out of memory\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
|
|
@ -177,7 +179,7 @@ static int exec_command_users(client_t *client, bool json) {
|
|||
if (!output) {
|
||||
free(usernames);
|
||||
client_printf(client, "users: out of memory\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
if (json) {
|
||||
|
|
@ -195,7 +197,7 @@ static int exec_command_users(client_t *client, bool json) {
|
|||
}
|
||||
}
|
||||
|
||||
rc = client_send(client, output, pos) == 0 ? 0 : 1;
|
||||
rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
|
||||
free(output);
|
||||
free(usernames);
|
||||
return rc;
|
||||
|
|
@ -243,10 +245,11 @@ static int exec_command_stats(client_t *client, bool json) {
|
|||
|
||||
if (len < 0 || len >= (int)sizeof(buffer)) {
|
||||
client_printf(client, "stats: output overflow\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
return client_send(client, buffer, (size_t)len) == 0 ? 0 : 1;
|
||||
return client_send(client, buffer, (size_t)len) == 0 ? TNT_EXIT_OK
|
||||
: TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
static int parse_tail_count(const char *args, int *count) {
|
||||
|
|
@ -316,7 +319,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
|||
if (!snapshot) {
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
client_printf(client, "tail: out of memory\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t));
|
||||
}
|
||||
|
|
@ -328,7 +331,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
|||
if (!output) {
|
||||
free(snapshot);
|
||||
client_printf(client, "tail: out of memory\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
|
|
@ -338,7 +341,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
|||
timestamp, snapshot[i].username, snapshot[i].content);
|
||||
}
|
||||
|
||||
rc = client_send(client, output, pos) == 0 ? 0 : 1;
|
||||
rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
|
||||
free(output);
|
||||
free(snapshot);
|
||||
return rc;
|
||||
|
|
@ -355,6 +358,12 @@ static int exec_command_post(client_t *client, const char *args) {
|
|||
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
|
||||
}
|
||||
|
||||
if (strlen(args) >= sizeof(content)) {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->ui_lang, I18N_EXEC_POST_TOO_LONG));
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
||||
strncpy(content, args, sizeof(content) - 1);
|
||||
content[sizeof(content) - 1] = '\0';
|
||||
trim_ascii_whitespace(content);
|
||||
|
|
@ -362,14 +371,14 @@ static int exec_command_post(client_t *client, const char *args) {
|
|||
if (content[0] == '\0') {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
|
||||
return 64;
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
||||
if (!utf8_is_valid_string(content)) {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_EXEC_POST_INVALID_UTF8));
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
resolve_exec_username(client, username, sizeof(username));
|
||||
|
|
@ -388,18 +397,22 @@ static int exec_command_post(client_t *client, const char *args) {
|
|||
msg.content[sizeof(msg.content) - 1] = '\0';
|
||||
}
|
||||
|
||||
room_broadcast(g_room, &msg);
|
||||
if (client_send(client, "posted\n", 7) != 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
notify_mentions(msg.content, client);
|
||||
if (message_save(&msg) < 0) {
|
||||
fprintf(stderr, "post: failed to persist message\n");
|
||||
return 1;
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_EXEC_POST_PERSIST_FAILED));
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
return 0;
|
||||
room_broadcast(g_room, &msg);
|
||||
notify_mentions(msg.content, client);
|
||||
|
||||
if (client_send(client, "posted\n", 7) != 0) {
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
return TNT_EXIT_OK;
|
||||
}
|
||||
|
||||
int exec_dispatch(client_t *client) {
|
||||
|
|
@ -407,6 +420,13 @@ int exec_dispatch(client_t *client) {
|
|||
tnt_exec_command_id_t command_id;
|
||||
const char *args = NULL;
|
||||
|
||||
if (client->exec_command_too_long) {
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_EXEC_COMMAND_TOO_LONG));
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
|
||||
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
|
||||
command_copy[sizeof(command_copy) - 1] = '\0';
|
||||
trim_ascii_whitespace(command_copy);
|
||||
|
|
@ -434,7 +454,7 @@ int exec_dispatch(client_t *client) {
|
|||
case TNT_EXEC_COMMAND_POST:
|
||||
return exec_command_post(client, args);
|
||||
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_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||
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: 输入不是有效 UTF-8\n"
|
||||
),
|
||||
[I18N_EXEC_POST_TOO_LONG] = I18N_STRING(
|
||||
"post: message too long\n",
|
||||
"post: 消息过长\n"
|
||||
),
|
||||
[I18N_EXEC_POST_PERSIST_FAILED] = I18N_STRING(
|
||||
"post: failed to persist message\n",
|
||||
"post: 消息持久化失败\n"
|
||||
),
|
||||
[I18N_EXEC_COMMAND_TOO_LONG] = I18N_STRING(
|
||||
"exec: command too long\n",
|
||||
"exec: 命令过长\n"
|
||||
),
|
||||
[I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
|
||||
"Unknown command: %s\n",
|
||||
"未知命令: %s\n"
|
||||
|
|
|
|||
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) {
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
int count = g_room->client_count;
|
||||
client_t *targets[MAX_CLIENTS];
|
||||
client_t **targets = NULL;
|
||||
int target_count = 0;
|
||||
|
||||
if (count > 0) {
|
||||
targets = calloc((size_t)count, sizeof(*targets));
|
||||
if (!targets) {
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
client_t *c = g_room->clients[i];
|
||||
if (c == sender) continue;
|
||||
|
|
@ -150,11 +158,11 @@ void notify_mentions(const char *content, const client_t *sender) {
|
|||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
for (int i = 0; i < target_count; i++) {
|
||||
client_send(targets[i], "\a", 1);
|
||||
targets[i]->unread_mentions++;
|
||||
targets[i]->redraw_pending = true;
|
||||
client_queue_bell(targets[i]);
|
||||
client_release(targets[i]);
|
||||
}
|
||||
free(targets);
|
||||
}
|
||||
|
||||
static int read_channel_exact(client_t *client, char *buf, size_t len,
|
||||
|
|
@ -731,7 +739,7 @@ void input_run_session(client_t *client) {
|
|||
client->last_active = time(NULL);
|
||||
|
||||
/* Check for exec command */
|
||||
if (client->exec_command[0] != '\0') {
|
||||
if (client->exec_command[0] != '\0' || client->exec_command_too_long) {
|
||||
int exit_status = exec_dispatch(client);
|
||||
ssh_channel_request_send_exit_status(client->channel, exit_status);
|
||||
ssh_channel_send_eof(client->channel);
|
||||
|
|
@ -811,6 +819,10 @@ main_loop:
|
|||
break;
|
||||
}
|
||||
|
||||
if (client_flush_pending_bells(client) != 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (current_update_seq != seen_update_seq) {
|
||||
seen_update_seq = current_update_seq;
|
||||
room_updated = true;
|
||||
|
|
|
|||
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) {
|
||||
fprintf(stderr, cli_text_invalid_port_format(lang),
|
||||
argv[i + 1]);
|
||||
return 1;
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
port = (int)val;
|
||||
i++;
|
||||
|
|
@ -49,23 +49,23 @@ int main(int argc, char **argv) {
|
|||
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) {
|
||||
if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) {
|
||||
perror("setenv TNT_STATE_DIR");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
i++;
|
||||
} else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
|
||||
printf("tnt %s\n", TNT_VERSION);
|
||||
return 0;
|
||||
return TNT_EXIT_OK;
|
||||
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
||||
char output[2048] = {0};
|
||||
size_t pos = 0;
|
||||
|
||||
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
|
||||
fputs(output, stdout);
|
||||
return 0;
|
||||
return TNT_EXIT_OK;
|
||||
} else {
|
||||
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
|
||||
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
|
||||
return 1;
|
||||
return TNT_EXIT_USAGE;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ int main(int argc, char **argv) {
|
|||
/* Initialize subsystems */
|
||||
if (tnt_ensure_state_dir() < 0) {
|
||||
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
message_init();
|
||||
|
|
@ -86,14 +86,14 @@ int main(int argc, char **argv) {
|
|||
g_room = room_create();
|
||||
if (!g_room) {
|
||||
fprintf(stderr, "Failed to create chat room\n");
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
/* Initialize server */
|
||||
if (ssh_server_init(port) < 0) {
|
||||
fprintf(stderr, "Failed to initialize server\n");
|
||||
room_destroy(g_room);
|
||||
return 1;
|
||||
return TNT_EXIT_ERROR;
|
||||
}
|
||||
|
||||
/* Start server (blocking) */
|
||||
|
|
|
|||
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
|
||||
|
||||
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
||||
TNTCTL_OPTS="--host-key-checking no --known-hosts /dev/null"
|
||||
|
||||
echo "=== TNT Exec Mode Tests ==="
|
||||
|
||||
|
|
@ -51,14 +52,16 @@ else
|
|||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null || true)
|
||||
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null)
|
||||
HEALTH_USAGE_STATUS=$?
|
||||
printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ no-arg exec usage follows TNT_LANG"
|
||||
if [ $? -eq 0 ] && [ "$HEALTH_USAGE_STATUS" -eq 64 ]; then
|
||||
echo "✓ no-arg exec usage follows TNT_LANG and exits 64"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ no-arg exec usage output unexpected"
|
||||
printf '%s\n' "$HEALTH_USAGE"
|
||||
echo "exit status: $HEALTH_USAGE_STATUS"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
|
|
@ -98,36 +101,42 @@ else
|
|||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null || true)
|
||||
UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null)
|
||||
UNKNOWN_STATUS=$?
|
||||
printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ unknown command follows TNT_LANG"
|
||||
if [ $? -eq 0 ] && [ "$UNKNOWN_STATUS" -eq 64 ]; then
|
||||
echo "✓ unknown command follows TNT_LANG and exits 64"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ unknown command output unexpected"
|
||||
printf '%s\n' "$UNKNOWN_OUTPUT"
|
||||
echo "exit status: $UNKNOWN_STATUS"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null || true)
|
||||
POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null)
|
||||
POST_USAGE_STATUS=$?
|
||||
printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ post usage follows TNT_LANG"
|
||||
if [ $? -eq 0 ] && [ "$POST_USAGE_STATUS" -eq 64 ]; then
|
||||
echo "✓ post usage follows TNT_LANG and exits 64"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ post usage output unexpected"
|
||||
printf '%s\n' "$POST_USAGE"
|
||||
echo "exit status: $POST_USAGE_STATUS"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null || true)
|
||||
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null)
|
||||
USERS_USAGE_STATUS=$?
|
||||
printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ users usage follows TNT_LANG"
|
||||
if [ $? -eq 0 ] && [ "$USERS_USAGE_STATUS" -eq 64 ]; then
|
||||
echo "✓ users usage follows TNT_LANG and exits 64"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ users usage output unexpected"
|
||||
printf '%s\n' "$USERS_USAGE"
|
||||
echo "exit status: $USERS_USAGE_STATUS"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
|
|
@ -152,6 +161,106 @@ else
|
|||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
PERSIST_FAIL_MARKER="persist-fail-marker"
|
||||
rm -f "$STATE_DIR/messages.log"
|
||||
mkdir "$STATE_DIR/messages.log"
|
||||
PERSIST_FAIL_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "$PERSIST_FAIL_MARKER" 2>/dev/null)
|
||||
PERSIST_FAIL_STATUS=$?
|
||||
rmdir "$STATE_DIR/messages.log"
|
||||
printf '%s\n' "$PERSIST_FAIL_OUTPUT" | grep -q 'posted'
|
||||
PERSIST_FAIL_POSTED=$?
|
||||
PERSIST_FAIL_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
|
||||
printf '%s\n' "$PERSIST_FAIL_TAIL" | grep -q "$PERSIST_FAIL_MARKER"
|
||||
PERSIST_FAIL_VISIBLE=$?
|
||||
if [ "$PERSIST_FAIL_STATUS" -eq 1 ] &&
|
||||
[ "$PERSIST_FAIL_POSTED" -ne 0 ] &&
|
||||
[ "$PERSIST_FAIL_VISIBLE" -ne 0 ]; then
|
||||
echo "✓ post persistence failure is not broadcast or acknowledged"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ post persistence failure handling unexpected"
|
||||
printf '%s\n' "$PERSIST_FAIL_OUTPUT"
|
||||
printf '%s\n' "$PERSIST_FAIL_TAIL"
|
||||
echo "exit status: $PERSIST_FAIL_STATUS"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
LONG_MARKER="too-long-exec-marker"
|
||||
LONG_COMMAND=$(printf 'post %s %01020d' "$LONG_MARKER" 0)
|
||||
LONG_OUTPUT=$(ssh $SSH_OPTS localhost "$LONG_COMMAND" 2>/dev/null)
|
||||
LONG_STATUS=$?
|
||||
printf '%s\n' "$LONG_OUTPUT" | grep -q '命令过长'
|
||||
LONG_ERROR=$?
|
||||
LONG_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
|
||||
printf '%s\n' "$LONG_TAIL" | grep -q "$LONG_MARKER"
|
||||
LONG_VISIBLE=$?
|
||||
if [ "$LONG_STATUS" -eq 64 ] &&
|
||||
[ "$LONG_ERROR" -eq 0 ] &&
|
||||
[ "$LONG_VISIBLE" -ne 0 ]; then
|
||||
echo "✓ overlong exec command is rejected without truncation"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ overlong exec command handling unexpected"
|
||||
printf '%s\n' "$LONG_OUTPUT"
|
||||
printf '%s\n' "$LONG_TAIL"
|
||||
echo "exit status: $LONG_STATUS"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
TNTCTL_HEALTH=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost health 2>/dev/null || true)
|
||||
if [ "$TNTCTL_HEALTH" = "ok" ]; then
|
||||
echo "✓ tntctl health uses exec interface"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ tntctl health failed: $TNTCTL_HEALTH"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
TNTCTL_STATS=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost stats --json 2>/dev/null || true)
|
||||
printf '%s\n' "$TNTCTL_STATS" | grep -q '"status":"ok"'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ tntctl stats --json returns JSON"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ tntctl stats --json output unexpected"
|
||||
printf '%s\n' "$TNTCTL_STATS"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
TNTCTL_USERS_USAGE=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost users --xml 2>/dev/null)
|
||||
TNTCTL_USERS_STATUS=$?
|
||||
printf '%s\n' "$TNTCTL_USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
|
||||
if [ $? -eq 0 ] && [ "$TNTCTL_USERS_STATUS" -eq 64 ]; then
|
||||
echo "✓ tntctl preserves remote usage exit 64"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ tntctl users usage output unexpected"
|
||||
printf '%s\n' "$TNTCTL_USERS_USAGE"
|
||||
echo "exit status: $TNTCTL_USERS_STATUS"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
TNTCTL_POST=$("../tntctl" -p "$PORT" $TNTCTL_OPTS -l ctlposter localhost post "hello from tntctl" 2>/dev/null || true)
|
||||
if [ "$TNTCTL_POST" = "posted" ]; then
|
||||
echo "✓ tntctl post publishes a message"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ tntctl post failed: $TNTCTL_POST"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
TNTCTL_TAIL=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "tail" "-n" "1" 2>/dev/null || true)
|
||||
printf '%s\n' "$TNTCTL_TAIL" | grep -q 'ctlposter' &&
|
||||
printf '%s\n' "$TNTCTL_TAIL" | grep -q 'hello from tntctl'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ tntctl tail returns recent messages"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ tntctl tail output unexpected"
|
||||
printf '%s\n' "$TNTCTL_TAIL"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
|
||||
WATCHER_READY="${STATE_DIR}/watcher.ready"
|
||||
cat >"$EXPECT_SCRIPT" <<EOF
|
||||
|
|
@ -160,7 +269,7 @@ spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT w
|
|||
expect "请输入用户名"
|
||||
send "watcher\r"
|
||||
exec touch "$WATCHER_READY"
|
||||
sleep 8
|
||||
sleep 12
|
||||
send "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
|
@ -213,6 +322,45 @@ else
|
|||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
MENTION_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "@watcher hello from exec mention" 2>/dev/null || true)
|
||||
if [ "$MENTION_OUTPUT" = "posted" ]; then
|
||||
echo "✓ post returns while notifying an interactive mention target"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ mention post failed: $MENTION_OUTPUT"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
MSG_SCRIPT="${STATE_DIR}/private-message.expect"
|
||||
cat >"$MSG_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT sender@localhost
|
||||
expect "请输入用户名"
|
||||
send "sender\r"
|
||||
expect ":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
|
||||
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) {
|
||||
chat_room_t *room = room_create();
|
||||
client_t clients[MAX_CLIENTS + 1];
|
||||
memset(clients, 0, sizeof(clients));
|
||||
client_t *clients = calloc((size_t)room->client_capacity + 1,
|
||||
sizeof(*clients));
|
||||
assert(clients != NULL);
|
||||
|
||||
for (int i = 0; i < room->client_capacity; i++) {
|
||||
assert(room_add_client(room, &clients[i]) == 0);
|
||||
|
|
@ -169,6 +170,23 @@ TEST(room_add_client_full) {
|
|||
assert(room_add_client(room, &clients[room->client_capacity]) == -1);
|
||||
assert(room_get_client_count(room) == room->client_capacity);
|
||||
|
||||
free(clients);
|
||||
room_destroy(room);
|
||||
}
|
||||
|
||||
TEST(room_capacity_follows_tnt_max_connections) {
|
||||
setenv("TNT_MAX_CONNECTIONS", "3", 1);
|
||||
chat_room_t *room = room_create();
|
||||
unsetenv("TNT_MAX_CONNECTIONS");
|
||||
client_t clients[4];
|
||||
memset(clients, 0, sizeof(clients));
|
||||
|
||||
assert(room->client_capacity == 3);
|
||||
assert(room_add_client(room, &clients[0]) == 0);
|
||||
assert(room_add_client(room, &clients[1]) == 0);
|
||||
assert(room_add_client(room, &clients[2]) == 0);
|
||||
assert(room_add_client(room, &clients[3]) == -1);
|
||||
|
||||
room_destroy(room);
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +219,7 @@ int main(void) {
|
|||
RUN_TEST(room_client_count);
|
||||
RUN_TEST(room_remove_nonexistent_client);
|
||||
RUN_TEST(room_add_client_full);
|
||||
RUN_TEST(room_capacity_follows_tnt_max_connections);
|
||||
RUN_TEST(room_message_count_threadsafe);
|
||||
|
||||
printf("\nAll %d tests passed!\n", tests_passed);
|
||||
|
|
|
|||
|
|
@ -147,6 +147,10 @@ TEST(text_lookup_matches_language) {
|
|||
"message cannot be empty") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY),
|
||||
"消息不能为空") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_COMMAND_TOO_LONG),
|
||||
"command too long") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_COMMAND_TOO_LONG),
|
||||
"命令过长") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||
"Unknown command") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||
|
|
|
|||
24
tnt.1
24
tnt.1
|
|
@ -144,6 +144,30 @@ ssh host \-p 2222 health
|
|||
Exit codes follow
|
||||
.BR sysexits (3)
|
||||
conventions.
|
||||
.SH EXIT STATUS
|
||||
.TP
|
||||
.B 0
|
||||
Success.
|
||||
.TP
|
||||
.B 1
|
||||
Runtime error, such as I/O failure, allocation failure, or persistence failure.
|
||||
.TP
|
||||
.B 64
|
||||
Usage error, such as an unknown command, invalid option, or invalid argument
|
||||
shape.
|
||||
.TP
|
||||
.B 69
|
||||
Reserved for the local
|
||||
.BR tntctl (1)
|
||||
wrapper when SSH transport is unavailable.
|
||||
.TP
|
||||
.B 78
|
||||
Reserved for future local
|
||||
.BR tntctl (1)
|
||||
configuration errors.
|
||||
.PP
|
||||
The SSH exec JSON field contract is documented in
|
||||
.IR docs/INTERFACE.md .
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B PORT
|
||||
|
|
|
|||
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