Build public release readiness foundation

This commit is contained in:
m1ngsama 2026-05-26 09:42:14 +08:00
parent 94b602613f
commit 33e2dc4f13
40 changed files with 1570 additions and 140 deletions

64
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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
View 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.

View 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.

View file

@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [ main ] branches: [ main ]
permissions:
contents: read
jobs: jobs:
build-and-test: build-and-test:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}

View file

@ -5,6 +5,9 @@ on:
tags: tags:
- 'v*' - 'v*'
permissions:
contents: read
jobs: jobs:
build: build:
name: Build ${{ matrix.target }} name: Build ${{ matrix.target }}
@ -15,15 +18,19 @@ jobs:
- os: ubuntu-24.04 - os: ubuntu-24.04
target: linux-amd64 target: linux-amd64
artifact: tnt-linux-amd64 artifact: tnt-linux-amd64
ctl_artifact: tntctl-linux-amd64
- os: ubuntu-24.04-arm - os: ubuntu-24.04-arm
target: linux-arm64 target: linux-arm64
artifact: tnt-linux-arm64 artifact: tnt-linux-arm64
ctl_artifact: tntctl-linux-arm64
- os: macos-15-intel - os: macos-15-intel
target: darwin-amd64 target: darwin-amd64
artifact: tnt-darwin-amd64 artifact: tnt-darwin-amd64
ctl_artifact: tntctl-darwin-amd64
- os: macos-15 - os: macos-15
target: darwin-arm64 target: darwin-arm64
artifact: tnt-darwin-arm64 artifact: tnt-darwin-arm64
ctl_artifact: tntctl-darwin-arm64
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -48,29 +55,38 @@ jobs:
- name: Verify artifact architecture - name: Verify artifact architecture
run: | run: |
file tnt file tnt
file tntctl
case "${{ matrix.target }}" in case "${{ matrix.target }}" in
linux-amd64) linux-amd64)
file tnt | grep -E 'ELF 64-bit.*x86-64' file tnt | grep -E 'ELF 64-bit.*x86-64'
file tntctl | grep -E 'ELF 64-bit.*x86-64'
;; ;;
linux-arm64) linux-arm64)
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)' file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
file tntctl | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
;; ;;
darwin-amd64) darwin-amd64)
file tnt | grep -E 'Mach-O 64-bit.*x86_64' file tnt | grep -E 'Mach-O 64-bit.*x86_64'
file tntctl | grep -E 'Mach-O 64-bit.*x86_64'
;; ;;
darwin-arm64) darwin-arm64)
file tnt | grep -E 'Mach-O 64-bit.*arm64' file tnt | grep -E 'Mach-O 64-bit.*arm64'
file tntctl | grep -E 'Mach-O 64-bit.*arm64'
;; ;;
esac esac
- name: Rename binary - name: Rename binary
run: mv tnt ${{ matrix.artifact }} run: |
mv tnt ${{ matrix.artifact }}
mv tntctl ${{ matrix.ctl_artifact }}
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.artifact }} name: ${{ matrix.artifact }}
path: ${{ matrix.artifact }} path: |
${{ matrix.artifact }}
${{ matrix.ctl_artifact }}
release: release:
needs: build needs: build
@ -90,7 +106,8 @@ jobs:
run: | run: |
cd artifacts cd artifacts
: > checksums.txt : > checksums.txt
for artifact in */tnt-*; do for artifact in */tnt-* */tntctl-*; do
[ -f "$artifact" ] || continue
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
done done
cat checksums.txt cat checksums.txt
@ -100,6 +117,7 @@ jobs:
with: with:
files: | files: |
artifacts/*/tnt-* artifacts/*/tnt-*
artifacts/*/tntctl-*
artifacts/checksums.txt artifacts/checksums.txt
body: | body: |
## Installation ## Installation
@ -109,29 +127,41 @@ jobs:
**Linux AMD64:** **Linux AMD64:**
```bash ```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64 wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-amd64
chmod +x tnt-linux-amd64 chmod +x tnt-linux-amd64
chmod +x tntctl-linux-amd64
sudo mv tnt-linux-amd64 /usr/local/bin/tnt sudo mv tnt-linux-amd64 /usr/local/bin/tnt
sudo mv tntctl-linux-amd64 /usr/local/bin/tntctl
``` ```
**Linux ARM64:** **Linux ARM64:**
```bash ```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64 wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-arm64
chmod +x tnt-linux-arm64 chmod +x tnt-linux-arm64
chmod +x tntctl-linux-arm64
sudo mv tnt-linux-arm64 /usr/local/bin/tnt sudo mv tnt-linux-arm64 /usr/local/bin/tnt
sudo mv tntctl-linux-arm64 /usr/local/bin/tntctl
``` ```
**macOS Intel:** **macOS Intel:**
```bash ```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64 wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-amd64
chmod +x tnt-darwin-amd64 chmod +x tnt-darwin-amd64
chmod +x tntctl-darwin-amd64
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
sudo mv tntctl-darwin-amd64 /usr/local/bin/tntctl
``` ```
**macOS Apple Silicon:** **macOS Apple Silicon:**
```bash ```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64 wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-arm64
chmod +x tnt-darwin-arm64 chmod +x tnt-darwin-arm64
chmod +x tntctl-darwin-arm64
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
sudo mv tntctl-darwin-arm64 /usr/local/bin/tntctl
``` ```
**Verify checksums:** **Verify checksums:**

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
*.o *.o
obj/ obj/
tnt tnt
tntctl
messages.log messages.log
host_key host_key
host_key.pub host_key.pub

View file

@ -20,10 +20,13 @@ SRC_DIR = src
INC_DIR = include INC_DIR = include
OBJ_DIR = obj OBJ_DIR = obj
SOURCES = $(wildcard $(SRC_DIR)/*.c) SOURCES = $(filter-out $(SRC_DIR)/tntctl.c,$(wildcard $(SRC_DIR)/*.c))
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
DEPS = $(OBJECTS:.o=.d) DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d)
TARGET = tnt TARGET = tnt
CTL_TARGET = tntctl
CTL_OBJECTS = $(OBJ_DIR)/tntctl.o
TARGETS = $(TARGET) $(CTL_TARGET)
PREFIX ?= /usr/local PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin BINDIR ?= $(PREFIX)/bin
@ -33,12 +36,16 @@ CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222)
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info .PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info
all: $(TARGET) all: $(TARGETS)
$(TARGET): $(OBJECTS) $(TARGET): $(OBJECTS)
$(CC) $(OBJECTS) -o $@ $(LDFLAGS) $(CC) $(OBJECTS) -o $@ $(LDFLAGS)
@echo "Build complete: $(TARGET)" @echo "Build complete: $(TARGET)"
$(CTL_TARGET): $(CTL_OBJECTS)
$(CC) $(CTL_OBJECTS) -o $@
@echo "Build complete: $(CTL_TARGET)"
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@ $(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
@ -46,34 +53,40 @@ $(OBJ_DIR):
mkdir -p $(OBJ_DIR) mkdir -p $(OBJ_DIR)
clean: clean:
rm -rf $(OBJ_DIR) $(TARGET) rm -rf $(OBJ_DIR) $(TARGETS)
rm -f tests/*.log tests/host_key* tests/messages.log rm -f tests/*.log tests/host_key* tests/messages.log
@echo "Clean complete" @echo "Clean complete"
install: $(TARGET) install: $(TARGETS)
install -d $(DESTDIR)$(BINDIR) install -d $(DESTDIR)$(BINDIR)
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/ install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
install -m 755 $(CTL_TARGET) $(DESTDIR)$(BINDIR)/
install -d $(DESTDIR)$(MANDIR)/man1 install -d $(DESTDIR)$(MANDIR)/man1
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/ install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
install -m 644 tntctl.1 $(DESTDIR)$(MANDIR)/man1/
install-systemd: install-systemd:
install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR) install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR)
install -m 644 tnt.service $(DESTDIR)$(SYSTEMD_UNIT_DIR)/ sed 's#^ExecStart=.*#ExecStart=$(BINDIR)/$(TARGET)#' tnt.service > "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
chmod 644 "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
uninstall: uninstall:
rm -f $(DESTDIR)$(BINDIR)/$(TARGET) rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
rm -f $(DESTDIR)$(BINDIR)/$(CTL_TARGET)
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1 rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
rm -f $(DESTDIR)$(MANDIR)/man1/tntctl.1
uninstall-systemd: uninstall-systemd:
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
# Development targets # Development targets
debug: CFLAGS += -g -DDEBUG debug: CFLAGS += -g -DDEBUG
debug: clean $(TARGET) debug: clean $(TARGETS)
release: CFLAGS += -O3 -DNDEBUG release: CFLAGS += -O3 -DNDEBUG
release: clean $(TARGET) release: clean $(TARGETS)
strip $(TARGET) strip $(TARGET)
strip $(CTL_TARGET)
release-check: release-check:
./scripts/release_check.sh ./scripts/release_check.sh
@ -83,7 +96,7 @@ release-check-strict:
asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
asan: LDFLAGS += -fsanitize=address asan: LDFLAGS += -fsanitize=address
asan: clean $(TARGET) asan: clean $(TARGETS)
@echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt" @echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt"
valgrind: debug valgrind: debug
@ -112,6 +125,7 @@ integration-test: all
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh @cd tests && PORT=$${PORT:-2222} ./test_basic.sh
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh @cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh @cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh
@cd tests && ./test_tntctl_cli.sh
anonymous-access-test: all anonymous-access-test: all
@echo "Running anonymous access tests..." @echo "Running anonymous access tests..."

View file

@ -21,8 +21,9 @@ A minimalist terminal chat server with Vim-style interface over SSH.
```sh ```sh
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
``` ```
The installer verifies the downloaded release binary against `checksums.txt` The installer verifies downloaded release binaries against `checksums.txt`
before installing it. before installing them. Older releases may provide only `tnt`; newer releases
also install `tntctl`.
**From source:** **From source:**
```sh ```sh
@ -183,6 +184,18 @@ ssh -p 2222 chat.example.com post "/me deploys v2.0"
**`post` identity**: the message is attributed to the SSH login name (the `user@` part of the URL, falling back to `anonymous`). In the default anonymous-access configuration there is no identity check, so any client can post as any name. Set `TNT_ACCESS_TOKEN` if you need authenticated posting. **`post` identity**: the message is attributed to the SSH login name (the `user@` part of the URL, falling back to `anonymous`). In the default anonymous-access configuration there is no identity check, so any client can post as any name. Set `TNT_ACCESS_TOKEN` if you need authenticated posting.
See [docs/INTERFACE.md](docs/INTERFACE.md) for the stable exec command
contract, exit statuses, and JSON field definitions.
Source and package-manager installs also include `tntctl`, a thin wrapper
around the same SSH exec interface:
```sh
tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json
tntctl -l operator chat.example.com post "service notice"
```
## Development ## Development
### Building ### Building
@ -254,6 +267,7 @@ TNT/
│ ├── commands.c # COMMAND-mode command dispatch │ ├── commands.c # COMMAND-mode command dispatch
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape │ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
│ ├── exec.c # SSH exec command dispatch │ ├── exec.c # SSH exec command dispatch
│ ├── tntctl.c # local wrapper around the SSH exec interface
│ ├── ssh_server.c # SSH server implementation │ ├── ssh_server.c # SSH server implementation
│ ├── bootstrap.c # SSH authentication and session bootstrap │ ├── bootstrap.c # SSH authentication and session bootstrap
│ ├── chat_room.c # chat room logic │ ├── chat_room.c # chat room logic
@ -358,6 +372,7 @@ Delete `motd.txt` to disable the MOTD.
- [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual - [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide - [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages - [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
- [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference - [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
- [Contributing](docs/CONTRIBUTING.md) - How to contribute - [Contributing](docs/CONTRIBUTING.md) - How to contribute
- [Changelog](docs/CHANGELOG.md) - Version history - [Changelog](docs/CHANGELOG.md) - Version history

61
SECURITY.md Normal file
View 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.

View file

@ -2,7 +2,48 @@
## Unreleased ## Unreleased
### Added
- Documented the stable SSH exec interface contract, including exit statuses
and JSON field shapes for package tests, scripts, and future `tntctl` work.
- Added a public security policy, supported-version guidance, and GitHub issue
templates for bug reports and feature requests.
- Added `tntctl`, a thin local wrapper around the documented SSH exec
interface for health, stats, users, tail, post, help, and exit commands.
### Changed ### Changed
- `make install-systemd` now rewrites the installed unit's `ExecStart` to match
the selected `PREFIX`/`BINDIR`, so package builds that install to `/usr`
produce a unit pointing at `/usr/bin/tnt`.
- Release preflight now checks the staged systemd unit path, and strict release
checks also require a clean tree, tag-at-HEAD, changelog release section, and
non-placeholder maintainer metadata.
- CI and release workflows now use explicit least-privilege repository
permissions.
- The release guide now documents SemVer expectations, manual release review,
smoke testing, and rollback steps.
- Package installs now include `tntctl` and its man page alongside `tnt`.
- SSH exec commands longer than the command buffer are now rejected with a
usage error instead of being truncated and executed.
- SSH exec `post` now persists the message before broadcasting or returning
`posted`, so persistence failures are not visible as successful room events.
- Mention and private-message bell notifications are now queued on the target
client and flushed by that client's own session loop, so slow SSH writes do
not block the sender's message path.
- Private-message inbox access now uses its own mutex instead of sharing the
SSH channel write lock, reducing unrelated contention on slow clients.
- Client writes now check the SSH channel's remote window before writing and
mark the client disconnected when the window is closed, avoiding the most
direct slow-reader blocking path.
- Room capacity and mention notification bookkeeping now follow
`TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit.
- Updated the roadmap to reflect completed `tntctl`, stable exec contract, and
monitoring-interface work, leaving the remaining daemon naming and runtime
queue work explicit.
- Strict release preflight now builds and installs from the local `vX.Y.Z` tag
source archive, catching untracked files that would be missing from a GitHub
source release.
- Release documentation now creates the local tag before strict release checks,
matching the strict gate's tag-at-HEAD requirement.
- Split UI-language parsing from localized text lookup: `src/i18n.c` now owns - Split UI-language parsing from localized text lookup: `src/i18n.c` now owns
locale/code parsing, while `src/i18n_text.c` owns the table-driven text locale/code parsing, while `src/i18n_text.c` owns the table-driven text
catalog with coverage checks for every message ID. catalog with coverage checks for every message ID.

View file

@ -19,37 +19,87 @@ into production or restart services on push.
CREATING RELEASES CREATING RELEASES
----------------- -----------------
Release policy:
- Use SemVer-style tags: vMAJOR.MINOR.PATCH.
- Bump PATCH for compatible bug fixes and release hardening.
- Bump MINOR for new commands, new documented flags, JSON field additions,
or visible user-interface behavior changes.
- Bump MAJOR for incompatible command, config, storage, or package behavior.
- Keep GitHub draft release review manual. Do not auto-publish releases.
- Keep production deployment manual. Do not SSH into production from CI.
1. Update version metadata: 1. Update version metadata:
- include/common.h - include/common.h
- tnt.1 - tnt.1
- docs/CHANGELOG.md - docs/CHANGELOG.md
- packaging/arch/PKGBUILD - packaging/arch/PKGBUILD
- packaging/homebrew/tnt-chat.rb - packaging/homebrew/tnt-chat.rb
- packaging/debian/debian/changelog
- package checksums and maintainer metadata, when preparing public package
recipes
2. Run the local preflight: 2. Run the local preflight:
make release-check make release-check
3. Replace package checksum placeholders and run: 3. Commit the release changes and create a local tag. Do not push the tag
until strict checks pass:
git tag v1.0.1
4. Run strict release checks:
make release-check-strict make release-check-strict
4. Create and push tag: Strict mode requires the local `vX.Y.Z` tag to point at HEAD. It also
git tag v1.0.1 builds from the tagged source archive, so it catches files that were left
untracked and would be missing from GitHub's source archive.
5. Push the tag:
git push origin v1.0.1 git push origin v1.0.1
5. GitHub Actions automatically: 6. GitHub Actions automatically:
- Builds binaries (Linux/macOS, AMD64/ARM64) - Builds `tnt` and `tntctl` binaries (Linux/macOS, AMD64/ARM64)
- Creates a draft release - Creates a draft release
- Uploads binaries - Uploads binaries
- Generates one `checksums.txt` file - Generates one `checksums.txt` file
- Verifies that artifact architecture matches the asset name - Verifies that artifact architecture matches the asset name
6. Review the draft release, smoke-test downloaded assets, then publish it 7. Review the draft release, smoke-test downloaded assets, then publish it
manually from GitHub. manually from GitHub.
7. Release appears at: 8. Release appears at:
https://github.com/m1ngsama/TNT/releases https://github.com/m1ngsama/TNT/releases
RELEASE REVIEW CHECKLIST
------------------------
Before publishing a draft release:
- Confirm `git tag` points at the intended commit.
- Download every release asset from GitHub, not from the local workspace.
- Verify `checksums.txt` with `sha256sum -c checksums.txt`.
- Run downloaded `tnt --version` and `tntctl --version`.
- Start a temporary server and check:
ssh -p 2222 server health
ssh -p 2222 server stats --json
ssh -p 2222 server users --json
ssh -p 2222 operator@server post "release smoke"
ssh -p 2222 server "tail -n 1"
- Check runtime dynamic links (`ldd` on Linux, `otool -L` on macOS) and make
sure `libssh` is documented for the target install path.
- Confirm `make release-check-strict` passed after package checksums were
replaced.
ROLLBACK
--------
Production rollback stays manual:
1. Keep the previous binary before replacing it.
2. Stop or restart only the intended `tnt` service.
3. Restore the previous binary if smoke checks fail.
4. Re-run `health`, `stats --json`, and one post/tail smoke test.
Do not overwrite `TNT_STATE_DIR` during rollback. If a future release changes
the message log format, its release notes must include the downgrade behavior.
DEPLOYING TO SERVERS DEPLOYING TO SERVERS
-------------------- --------------------
Deployments are operator-driven: Deployments are operator-driven:

View file

@ -41,6 +41,7 @@ command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
commands.c → COMMAND-mode command dispatch commands.c → COMMAND-mode command dispatch
exec_catalog.c → SSH exec command matching, usage, and argument shape exec_catalog.c → SSH exec command matching, usage, and argument shape
exec.c → SSH exec command dispatch exec.c → SSH exec command dispatch
tntctl.c → local wrapper around the SSH exec interface
ssh_server.c → SSH listener setup ssh_server.c → SSH listener setup
bootstrap.c → SSH authentication/session bootstrap bootstrap.c → SSH authentication/session bootstrap
input.c → interactive session loop input.c → interactive session loop
@ -69,7 +70,7 @@ utf8.c → UTF-8 string handling
## Known Limits ## Known Limits
- Max 64 clients (MAX_CLIENTS) - Default 64 clients, configurable with `TNT_MAX_CONNECTIONS`
- Max 100 messages in memory (MAX_MESSAGES) - Max 100 messages in memory (MAX_MESSAGES)
- Max 1024 bytes per message (MAX_MESSAGE_LEN) - Max 1024 bytes per message (MAX_MESSAGE_LEN)
- Max 64 bytes username (MAX_USERNAME_LEN) - Max 64 bytes username (MAX_USERNAME_LEN)

150
docs/INTERFACE.md Normal file
View 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.

View file

@ -17,26 +17,33 @@ This roadmap is intentionally strict. Each stage should leave the project easier
Goal: make TNT predictable for operators, scripts, and package maintainers. Goal: make TNT predictable for operators, scripts, and package maintainers.
- split the current surface into `tntd` (daemon) and `tntctl` (control client) - ✅ introduce `tntctl` as a thin control client over the stable SSH exec surface
- keep SSH exec support, but treat it as a transport for stable commands rather than the primary API shape - keep SSH exec support, but treat it as a transport for stable commands rather
- define stable subcommands and exit codes for: than an ad hoc command surface
- ✅ define stable subcommands and exit codes for:
- `health` - `health`
- `stats` - `stats`
- `users` - `users`
- `tail` - `tail`
- `post` - `post`
- support text and JSON output modes where machine use is likely - ✅ support text and JSON output modes where machine use is likely
- normalize command parsing, help text, and error reporting - ✅ normalize command parsing, help text, and error reporting
- decide whether the server binary should remain `tnt` or split later into a
separate `tntd` daemon name
- add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently - add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently
- add a man page for `tntd` and `tntctl` - add man pages for `tnt` and `tntctl`
## Stage 2: Runtime Model ## Stage 2: Runtime Model
Goal: make long-running operation boring and reliable. Goal: make long-running operation boring and reliable.
- move client state to a clearer ownership model with one release path - move client state to a clearer ownership model with one release path
- finish replacing ad hoc cross-thread UI mutation with per-client event delivery - ✅ remove cross-client SSH channel writes from mention and private-message
- add bounded outbound queues so slow clients cannot stall other users notifications
- continue replacing ad hoc cross-thread UI mutation with per-client event
delivery
- add bounded outbound queues so slow clients cannot stall their own session
loop indefinitely
- separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly - separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly
- make room/client capacity fully runtime-configurable with no hidden compile-time ceiling - make room/client capacity fully runtime-configurable with no hidden compile-time ceiling
- document hard guarantees and soft limits - document hard guarantees and soft limits
@ -73,7 +80,7 @@ Goal: make public deployment manageable.
- provide clear distinction between concurrent session limits and connection-rate limits - provide clear distinction between concurrent session limits and connection-rate limits
- add admin-only controls for read-only mode, mute, and ban - add admin-only controls for read-only mode, mute, and ban
- expose a minimal health and stats surface suitable for monitoring - expose a minimal health and stats surface suitable for monitoring
- support systemd-friendly readiness and watchdog behavior - support systemd-friendly readiness and watchdog behavior
- document recommended production defaults for public, private, and localhost-only deployments - document recommended production defaults for public, private, and localhost-only deployments
- tighten CI around authentication, limits, and restart behavior - tighten CI around authentication, limits, and restart behavior
@ -92,8 +99,12 @@ Goal: make regressions harder to introduce.
These are the next changes that should happen before new feature work expands the surface area. These are the next changes that should happen before new feature work expands the surface area.
1. Introduce `tntctl` and move stable command handling behind it. 1. Decide the daemon naming path: keep `tnt` as the server binary for 1.x, or
2. Define exit codes and JSON schemas for `health`, `stats`, `users`, `tail`, and `post`. introduce `tntd` later with a compatibility plan.
3. Add per-client outbound queues and finish untangling client-state ownership. 2. Add per-client outbound queues and finish untangling client-state ownership.
4. Remove the remaining hidden runtime limits and make them explicit configuration. 3. Remove the remaining hidden runtime limits and make them explicit
5. Add a long-running soak test that exercises idle sessions, reconnects, and slow consumers. configuration.
4. Add a long-running soak test that exercises idle sessions, reconnects, and
slow consumers.
5. Replace remaining release placeholders with real maintainer metadata and
source-archive checksums when cutting a public package release.

View file

@ -8,6 +8,14 @@
* success, -1 if the channel is gone or a partial write fails. */ * success, -1 if the channel is gone or a partial write fails. */
int client_send(client_t *client, const char *data, size_t len); int client_send(client_t *client, const char *data, size_t len);
/* Queue an audible bell for the client's own session loop to send. This
* avoids writing to another client's SSH channel from the sender's thread. */
void client_queue_bell(client_t *client);
/* Send one queued bell, if present, from the client's own session loop.
* Returns 0 when no bell was pending or it was written successfully. */
int client_flush_pending_bells(client_t *client);
/* printf-style wrapper around client_send(). The formatted string must /* printf-style wrapper around client_send(). The formatted string must
* fit in 2048 bytes; truncation or encoding errors return -1. */ * fit in 2048 bytes; truncation or encoding errors return -1. */
int client_printf(client_t *client, const char *fmt, ...); int client_printf(client_t *client, const char *fmt, ...);

View file

@ -15,9 +15,8 @@
* - Toggles client->mute_joins on `:mute-joins` * - Toggles client->mute_joins on `:mute-joins`
* - May broadcast a system rename message on `:nick` * - May broadcast a system rename message on `:nick`
* *
* Reads g_room. Caller must already hold the channel I/O serialisation * Reads g_room. Renders command output through the normal client_send()
* established by handle_key() this function calls back into client_send * path; callers must not hold client->io_lock before dispatching. */
* (via tui_render_command_output) which acquires client->io_lock. */
void commands_dispatch(client_t *client); void commands_dispatch(client_t *client);
#endif /* COMMANDS_H */ #endif /* COMMANDS_H */

View file

@ -14,6 +14,14 @@
/* Project Metadata */ /* Project Metadata */
#define TNT_VERSION "1.0.1" #define TNT_VERSION "1.0.1"
/* Public process/exec exit statuses. TNT follows the common sysexits(3)
* convention for usage errors while keeping runtime failures portable. */
#define TNT_EXIT_OK 0
#define TNT_EXIT_ERROR 1
#define TNT_EXIT_USAGE 64
#define TNT_EXIT_UNAVAILABLE 69
#define TNT_EXIT_CONFIG 78
/* Configuration constants */ /* Configuration constants */
#define DEFAULT_PORT 2222 #define DEFAULT_PORT 2222
#define MAX_MESSAGES 100 #define MAX_MESSAGES 100
@ -21,7 +29,8 @@
#define MAX_MESSAGE_LEN 1024 #define MAX_MESSAGE_LEN 1024
#define MAX_EXEC_COMMAND_LEN 1024 #define MAX_EXEC_COMMAND_LEN 1024
#define MAX_COMMAND_OUTPUT_LEN 8192 #define MAX_COMMAND_OUTPUT_LEN 8192
#define MAX_CLIENTS 64 #define DEFAULT_MAX_CLIENTS 64
#define MAX_CONFIGURED_CLIENTS 1024
#define LOG_FILE "messages.log" #define LOG_FILE "messages.log"
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */ #define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
#define HOST_KEY_FILE "host_key" #define HOST_KEY_FILE "host_key"

View file

@ -6,9 +6,9 @@
/* Dispatch the non-interactive SSH exec command stored in /* Dispatch the non-interactive SSH exec command stored in
* client->exec_command. Returns the exit status to send back to the * client->exec_command. Returns the exit status to send back to the
* SSH client: * SSH client:
* 0 = success * TNT_EXIT_OK = success
* 1 = runtime error (I/O, OOM, persistence failure) * TNT_EXIT_ERROR = runtime error (I/O, OOM, persistence failure)
* 64 = usage error (unknown command, bad args) * TNT_EXIT_USAGE = usage error (unknown command, bad args)
* *
* Reads g_room and shared client state. Safe to call once per * Reads g_room and shared client state. Safe to call once per
* exec-mode session before the channel is closed. */ * exec-mode session before the channel is closed. */

View file

@ -58,6 +58,9 @@ typedef enum {
I18N_UNKNOWN_GUIDANCE, I18N_UNKNOWN_GUIDANCE,
I18N_EXEC_POST_EMPTY, I18N_EXEC_POST_EMPTY,
I18N_EXEC_POST_INVALID_UTF8, I18N_EXEC_POST_INVALID_UTF8,
I18N_EXEC_POST_TOO_LONG,
I18N_EXEC_POST_PERSIST_FAILED,
I18N_EXEC_COMMAND_TOO_LONG,
I18N_EXEC_UNKNOWN_COMMAND_FORMAT, I18N_EXEC_UNKNOWN_COMMAND_FORMAT,
I18N_TEXT_COUNT I18N_TEXT_COUNT
} i18n_text_id_t; } i18n_text_id_t;

View file

@ -44,14 +44,16 @@ typedef struct client {
int command_output_scroll; int command_output_scroll;
bool show_motd; /* command_output holds MOTD text */ bool show_motd; /* command_output holds MOTD text */
char exec_command[MAX_EXEC_COMMAND_LEN]; char exec_command[MAX_EXEC_COMMAND_LEN];
bool exec_command_too_long;
char ssh_login[MAX_USERNAME_LEN]; char ssh_login[MAX_USERNAME_LEN];
time_t connect_time; time_t connect_time;
time_t last_active; time_t last_active;
atomic_bool redraw_pending; atomic_bool redraw_pending;
_Atomic int pending_bells; /* Bell nudges for this client's loop */
_Atomic int unread_mentions; /* @-mentions received since last reset */ _Atomic int unread_mentions; /* @-mentions received since last reset */
_Atomic int unread_whispers; /* whispers received since last :inbox view */ _Atomic int unread_whispers; /* whispers received since last :inbox view */
/* Per-client whisper inbox. Pushes serialise on io_lock; readers are /* Per-client whisper inbox. Protected separately from SSH channel I/O
* the client's own thread inside :inbox handling. */ * so slow writes do not block in-memory private-message delivery. */
whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
int whisper_inbox_count; int whisper_inbox_count;
bool mute_joins; bool mute_joins;
@ -60,6 +62,7 @@ typedef struct client {
int ref_count; /* Reference count for safe cleanup */ int ref_count; /* Reference count for safe cleanup */
pthread_mutex_t ref_lock; /* Lock for ref_count */ pthread_mutex_t ref_lock; /* Lock for ref_count */
pthread_mutex_t io_lock; /* Serialize SSH channel writes */ pthread_mutex_t io_lock; /* Serialize SSH channel writes */
pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */
struct ssh_channel_callbacks_struct *channel_cb; struct ssh_channel_callbacks_struct *channel_cb;
} client_t; } client_t;

View file

@ -45,7 +45,8 @@ case "$ARCH" in
*) fail "Unsupported architecture: $ARCH" ;; *) fail "Unsupported architecture: $ARCH" ;;
esac esac
BINARY="tnt-${OS}-${ARCH}" SERVER_BINARY="tnt-${OS}-${ARCH}"
CTL_BINARY="tntctl-${OS}-${ARCH}"
echo "=== TNT Installer ===" echo "=== TNT Installer ==="
echo "OS: $OS" echo "OS: $OS"
@ -65,51 +66,81 @@ fi
echo "Installing version: $VERSION" echo "Installing version: $VERSION"
# Download # Download
URL="https://github.com/$REPO/releases/download/$VERSION/$BINARY" SERVER_URL="https://github.com/$REPO/releases/download/$VERSION/$SERVER_BINARY"
CTL_URL="https://github.com/$REPO/releases/download/$VERSION/$CTL_BINARY"
CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt" CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt"
echo "Downloading from: $URL" echo "Downloading from: $SERVER_URL"
TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX") SERVER_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
CTL_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tntctl.XXXXXX")
CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX") CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX")
INSTALL_CTL=0
cleanup() { cleanup() {
rm -f "$TMP_FILE" "$CHECKSUM_FILE" rm -f "$SERVER_TMP_FILE" "$CTL_TMP_FILE" "$CHECKSUM_FILE"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
curl -fsSL -o "$TMP_FILE" "$URL" || fail "Failed to download $BINARY" curl -fsSL -o "$SERVER_TMP_FILE" "$SERVER_URL" ||
fail "Failed to download $SERVER_BINARY"
echo "Downloading checksums from: $CHECKSUM_URL" echo "Downloading checksums from: $CHECKSUM_URL"
curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" || curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" ||
fail "Failed to download checksums.txt" fail "Failed to download checksums.txt"
EXPECTED_SHA=$(awk -v name="$BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE") EXPECTED_SERVER_SHA=$(awk -v name="$SERVER_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
[ -n "$EXPECTED_SHA" ] || fail "No checksum entry found for $BINARY" [ -n "$EXPECTED_SERVER_SHA" ] || fail "No checksum entry found for $SERVER_BINARY"
EXPECTED_CTL_SHA=$(awk -v name="$CTL_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
ACTUAL_SHA=$(sha256_of "$TMP_FILE") || ACTUAL_SERVER_SHA=$(sha256_of "$SERVER_TMP_FILE") ||
fail "sha256sum or shasum is required for checksum verification" fail "sha256sum or shasum is required for checksum verification"
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || [ "$ACTUAL_SERVER_SHA" = "$EXPECTED_SERVER_SHA" ] ||
fail "Checksum mismatch for $BINARY" fail "Checksum mismatch for $SERVER_BINARY"
echo "Checksum verified: $ACTUAL_SHA" echo "Checksum verified: $SERVER_BINARY $ACTUAL_SERVER_SHA"
if [ -n "$EXPECTED_CTL_SHA" ]; then
echo "Downloading from: $CTL_URL"
curl -fsSL -o "$CTL_TMP_FILE" "$CTL_URL" ||
fail "Failed to download $CTL_BINARY"
ACTUAL_CTL_SHA=$(sha256_of "$CTL_TMP_FILE") ||
fail "sha256sum or shasum is required for checksum verification"
[ "$ACTUAL_CTL_SHA" = "$EXPECTED_CTL_SHA" ] ||
fail "Checksum mismatch for $CTL_BINARY"
echo "Checksum verified: $CTL_BINARY $ACTUAL_CTL_SHA"
INSTALL_CTL=1
else
echo "No checksum entry found for $CTL_BINARY; skipping tntctl for this release"
fi
# Install # Install
chmod +x "$TMP_FILE" chmod +x "$SERVER_TMP_FILE"
[ "$INSTALL_CTL" -eq 0 ] || chmod +x "$CTL_TMP_FILE"
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt" install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
[ "$INSTALL_CTL" -eq 0 ] || install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
else else
echo "Need sudo for installation to $INSTALL_DIR" echo "Need sudo for installation to $INSTALL_DIR"
need_cmd sudo need_cmd sudo
sudo mkdir -p "$INSTALL_DIR" sudo mkdir -p "$INSTALL_DIR"
sudo install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt" sudo install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
[ "$INSTALL_CTL" -eq 0 ] || sudo install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
fi fi
echo "" echo ""
echo "TNT installed successfully to $INSTALL_DIR/tnt" if [ "$INSTALL_CTL" -eq 1 ]; then
echo "TNT installed successfully to $INSTALL_DIR/tnt and $INSTALL_DIR/tntctl"
else
echo "TNT installed successfully to $INSTALL_DIR/tnt"
fi
echo "" echo ""
echo "Run with:" echo "Run with:"
echo " tnt" echo " tnt"
echo "" echo ""
echo "Or specify port:" echo "Or specify port:"
echo " PORT=3333 tnt" echo " PORT=3333 tnt"
if [ "$INSTALL_CTL" -eq 1 ]; then
echo ""
echo "Control a server with:"
echo " tntctl localhost health"
fi

View file

@ -10,6 +10,9 @@ any public registry.
- `homebrew/` - Homebrew tap formula draft and maintainer notes. - `homebrew/` - Homebrew tap formula draft and maintainer notes.
- `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata. - `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata.
Package installs include both `tnt` and `tntctl`. `tnt` is the server process;
`tntctl` is a thin wrapper around the documented SSH exec interface.
## Release checklist ## Release checklist
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match. 1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.

View file

@ -44,6 +44,6 @@ debuild -S
## Package shape ## Package shape
- Binary package name: `tnt-chat` - Binary package name: `tnt-chat`
- Installed command: `/usr/bin/tnt` - Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl`
- Runtime dependency: `libssh` - Runtime dependency: `libssh`
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service` - Optional systemd unit: `/usr/lib/systemd/system/tnt.service`

View file

@ -12,10 +12,13 @@ class TntChat < Formula
system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}" system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}"
bin.install "#{buildpath}/stage#{prefix}/bin/tnt" bin.install "#{buildpath}/stage#{prefix}/bin/tnt"
bin.install "#{buildpath}/stage#{prefix}/bin/tntctl"
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1" man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1"
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tntctl.1"
end end
test do test do
assert_match version.to_s, shell_output("#{bin}/tnt --version") assert_match version.to_s, shell_output("#{bin}/tnt --version")
assert_match version.to_s, shell_output("#{bin}/tntctl --version")
end end
end end

View file

@ -22,7 +22,9 @@ Environment:
RUN_INTEGRATION=1 also run full make test RUN_INTEGRATION=1 also run full make test
PORT=12720 base port for integration tests PORT=12720 base port for integration tests
Strict checks additionally require real package checksums and a local vX.Y.Z tag. Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a
matching changelog release section, real package checksums, and non-placeholder
maintainer metadata, then build from the tagged source archive.
USAGE USAGE
} }
@ -62,6 +64,8 @@ version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
step "checking version metadata for $version" step "checking version metadata for $version"
grep -q "\"TNT $version\"" tnt.1 || grep -q "\"TNT $version\"" tnt.1 ||
fail "tnt.1 does not mention TNT $version" fail "tnt.1 does not mention TNT $version"
grep -q "\"TNT $version\"" tntctl.1 ||
fail "tntctl.1 does not mention TNT $version"
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD || grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
fail "packaging/arch/PKGBUILD pkgver does not match $version" fail "packaging/arch/PKGBUILD pkgver does not match $version"
grep -q "pkgver = $version" packaging/arch/.SRCINFO || grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
@ -88,11 +92,25 @@ make
actual_version=$(./tnt --version) actual_version=$(./tnt --version)
[ "$actual_version" = "tnt $version" ] || [ "$actual_version" = "tnt $version" ] ||
fail "binary version mismatch: expected 'tnt $version', got '$actual_version'" fail "binary version mismatch: expected 'tnt $version', got '$actual_version'"
tntctl_version=$(./tntctl --version)
[ "$tntctl_version" = "tntctl $version" ] ||
fail "control binary version mismatch: expected 'tntctl $version', got '$tntctl_version'"
step "running unit tests" step "running unit tests"
make -C tests/unit clean make -C tests/unit clean
make -C tests/unit run make -C tests/unit run
step "checking client I/O ownership boundaries"
! grep -R "client_send(target" src include >/dev/null ||
fail "cross-client target writes must be queued through client_queue_bell"
! grep -R "client_send(targets" src include >/dev/null ||
fail "cross-client target-array writes must be queued through client_queue_bell"
! grep -n "pthread_mutex_lock(&.*->io_lock)" src/commands.c >/dev/null ||
fail "commands.c must not use SSH io_lock for in-memory command state"
if grep -R "ssh_channel_write" src include | grep -v "^src/client.c:" >/dev/null; then
fail "raw SSH channel writes must stay inside src/client.c"
fi
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
step "running full integration tests" step "running full integration tests"
make test PORT="${PORT:-12720}" make test PORT="${PORT:-12720}"
@ -109,9 +127,13 @@ make DESTDIR="$tmpdir" PREFIX=/usr install
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
[ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt" [ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt"
[ -x "$tmpdir/usr/bin/tntctl" ] || fail "missing executable: /usr/bin/tntctl"
[ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1" [ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1"
[ -f "$tmpdir/usr/share/man/man1/tntctl.1" ] || fail "missing manpage: /usr/share/man/man1/tntctl.1"
[ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] || [ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] ||
fail "missing systemd unit: /usr/lib/systemd/system/tnt.service" fail "missing systemd unit: /usr/lib/systemd/system/tnt.service"
grep -q "^ExecStart=/usr/bin/tnt$" "$tmpdir/usr/lib/systemd/system/tnt.service" ||
fail "systemd unit ExecStart does not match PREFIX=/usr install path"
step "checking installer syntax" step "checking installer syntax"
sh -n install.sh sh -n install.sh
@ -137,14 +159,61 @@ fi
if [ "$STRICT" -eq 1 ]; then if [ "$STRICT" -eq 1 ]; then
step "checking strict release gates" step "checking strict release gates"
[ -z "$(git status --short)" ] ||
fail "working tree must be clean for strict release"
git rev-parse -q --verify "refs/tags/v$version" >/dev/null ||
fail "missing local tag v$version"
[ "$(git rev-parse "refs/tags/v$version^{}")" = "$(git rev-parse HEAD)" ] ||
fail "local tag v$version does not point at HEAD"
grep -q "^## $version " docs/CHANGELOG.md ||
fail "docs/CHANGELOG.md does not contain a release section for $version"
! grep -q "sha256sums=('SKIP')" packaging/arch/PKGBUILD || ! grep -q "sha256sums=('SKIP')" packaging/arch/PKGBUILD ||
fail "replace PKGBUILD sha256sums before strict release" fail "replace PKGBUILD sha256sums before strict release"
! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO || ! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO ||
fail "replace .SRCINFO sha256sums before strict release" fail "replace .SRCINFO sha256sums before strict release"
! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb || ! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb ||
fail "replace Homebrew sha256 before strict release" fail "replace Homebrew sha256 before strict release"
git rev-parse -q --verify "refs/tags/v$version" >/dev/null || ! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
fail "missing local tag v$version" fail "replace maintainer email placeholders before strict release"
step "checking tagged source archive"
archive="$tmpdir/tnt-$version-source.tar.gz"
archive_extract="$tmpdir/source"
archive_install="$tmpdir/source-install"
archive_root="$archive_extract/TNT-$version"
git archive --format=tar.gz --prefix="TNT-$version/" \
-o "$archive" "refs/tags/v$version"
mkdir -p "$archive_extract"
tar -xzf "$archive" -C "$archive_extract"
[ -f "$archive_root/src/tntctl.c" ] ||
fail "tagged source archive is missing src/tntctl.c"
[ -f "$archive_root/tnt.1" ] ||
fail "tagged source archive is missing tnt.1"
[ -f "$archive_root/tntctl.1" ] ||
fail "tagged source archive is missing tntctl.1"
[ -f "$archive_root/LICENSE" ] ||
fail "tagged source archive is missing LICENSE"
(
cd "$archive_root"
make
make DESTDIR="$archive_install" PREFIX=/usr install
make DESTDIR="$archive_install" PREFIX=/usr install-systemd
)
[ -x "$archive_install/usr/bin/tnt" ] ||
fail "tagged source install is missing /usr/bin/tnt"
[ -x "$archive_install/usr/bin/tntctl" ] ||
fail "tagged source install is missing /usr/bin/tntctl"
[ -f "$archive_install/usr/share/man/man1/tnt.1" ] ||
fail "tagged source install is missing tnt.1"
[ -f "$archive_install/usr/share/man/man1/tntctl.1" ] ||
fail "tagged source install is missing tntctl.1"
grep -q "^ExecStart=/usr/bin/tnt$" \
"$archive_install/usr/lib/systemd/system/tnt.service" ||
fail "tagged source systemd unit ExecStart does not match /usr/bin/tnt"
fi fi
step "release preflight passed" step "release preflight passed"

View file

@ -25,6 +25,7 @@ typedef struct {
int pty_width; int pty_width;
int pty_height; int pty_height;
char exec_command[MAX_EXEC_COMMAND_LEN]; char exec_command[MAX_EXEC_COMMAND_LEN];
bool exec_command_too_long;
bool auth_success; bool auth_success;
int auth_attempts; int auth_attempts;
bool channel_ready; /* Set when shell/exec request received */ bool channel_ready; /* Set when shell/exec request received */
@ -294,8 +295,13 @@ static int channel_exec_request(ssh_session session, ssh_channel channel,
/* Store exec command */ /* Store exec command */
if (command) { if (command) {
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1); if (strlen(command) >= sizeof(ctx->exec_command)) {
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0'; ctx->exec_command_too_long = true;
ctx->exec_command[0] = '\0';
} else {
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
}
} }
/* Mark channel as ready */ /* Mark channel as ready */
@ -363,6 +369,7 @@ void *bootstrap_run(void *arg) {
ctx->pty_width = 80; ctx->pty_width = 80;
ctx->pty_height = 24; ctx->pty_height = 24;
ctx->exec_command[0] = '\0'; ctx->exec_command[0] = '\0';
ctx->exec_command_too_long = false;
ctx->requested_user[0] = '\0'; ctx->requested_user[0] = '\0';
ctx->auth_success = false; ctx->auth_success = false;
ctx->auth_attempts = 0; ctx->auth_attempts = 0;
@ -451,6 +458,7 @@ void *bootstrap_run(void *arg) {
client->ref_count = 1; client->ref_count = 1;
pthread_mutex_init(&client->ref_lock, NULL); pthread_mutex_init(&client->ref_lock, NULL);
pthread_mutex_init(&client->io_lock, NULL); pthread_mutex_init(&client->io_lock, NULL);
pthread_mutex_init(&client->whisper_lock, NULL);
if (ctx->requested_user[0] != '\0') { if (ctx->requested_user[0] != '\0') {
strncpy(client->ssh_login, ctx->requested_user, strncpy(client->ssh_login, ctx->requested_user,
@ -466,6 +474,7 @@ void *bootstrap_run(void *arg) {
sizeof(client->exec_command) - 1); sizeof(client->exec_command) - 1);
client->exec_command[sizeof(client->exec_command) - 1] = '\0'; client->exec_command[sizeof(client->exec_command) - 1] = '\0';
} }
client->exec_command_too_long = ctx->exec_command_too_long;
/* Add a ref for the channel callbacks (eof/close/window_change) so the /* Add a ref for the channel callbacks (eof/close/window_change) so the
* client_t outlives any in-flight callback invocation. */ * client_t outlives any in-flight callback invocation. */

View file

@ -4,19 +4,8 @@
chat_room_t *g_room = NULL; chat_room_t *g_room = NULL;
static int room_capacity_from_env(void) { static int room_capacity_from_env(void) {
const char *env = getenv("TNT_MAX_CONNECTIONS"); return env_int("TNT_MAX_CONNECTIONS", DEFAULT_MAX_CLIENTS, 1,
MAX_CONFIGURED_CLIENTS);
if (!env || env[0] == '\0') {
return MAX_CLIENTS;
}
char *end;
long capacity = strtol(env, &end, 10);
if (*end != '\0' || capacity < 1 || capacity > 1024) {
return MAX_CLIENTS;
}
return (int)capacity;
} }
/* Initialize chat room */ /* Initialize chat room */

View file

@ -9,6 +9,13 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
static int client_send_fail(client_t *client) {
if (client) {
client->connected = false;
}
return -1;
}
/* Send data to client via SSH channel */ /* Send data to client via SSH channel */
int client_send(client_t *client, const char *data, size_t len) { int client_send(client_t *client, const char *data, size_t len) {
size_t total = 0; size_t total = 0;
@ -24,11 +31,21 @@ int client_send(client_t *client, const char *data, size_t len) {
while (total < len) { while (total < len) {
size_t remaining = len - total; size_t remaining = len - total;
uint32_t window = ssh_channel_window_size(client->channel);
if (window == 0) {
pthread_mutex_unlock(&client->io_lock);
return client_send_fail(client);
}
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining; uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
if (chunk > window) {
chunk = window;
}
int sent = ssh_channel_write(client->channel, data + total, chunk); int sent = ssh_channel_write(client->channel, data + total, chunk);
if (sent <= 0) { if (sent <= 0) {
pthread_mutex_unlock(&client->io_lock); pthread_mutex_unlock(&client->io_lock);
return -1; return client_send_fail(client);
} }
total += (size_t)sent; total += (size_t)sent;
} }
@ -41,6 +58,23 @@ int client_send(client_t *client, const char *data, size_t len) {
return 0; return 0;
} }
void client_queue_bell(client_t *client) {
if (!client) return;
atomic_store(&client->pending_bells, 1);
client->redraw_pending = true;
}
int client_flush_pending_bells(client_t *client) {
if (!client) return 0;
if (atomic_exchange(&client->pending_bells, 0) <= 0) {
return 0;
}
return client_send(client, "\a", 1);
}
void client_addref(client_t *client) { void client_addref(client_t *client) {
if (!client) return; if (!client) return;
pthread_mutex_lock(&client->ref_lock); pthread_mutex_lock(&client->ref_lock);
@ -75,6 +109,7 @@ void client_release(client_t *client) {
free(client->channel_cb); free(client->channel_cb);
} }
pthread_mutex_destroy(&client->io_lock); pthread_mutex_destroy(&client->io_lock);
pthread_mutex_destroy(&client->whisper_lock);
pthread_mutex_destroy(&client->ref_lock); pthread_mutex_destroy(&client->ref_lock);
free(client); free(client);
} }

View file

@ -199,9 +199,9 @@ void commands_dispatch(client_t *client) {
pthread_rwlock_unlock(&g_room->lock); pthread_rwlock_unlock(&g_room->lock);
if (target) { if (target) {
/* Push into recipient's inbox. io_lock serialises so two /* Push into recipient's inbox. whisper_lock serialises so
* senders to the same recipient don't tear the ring. */ * two senders to the same recipient don't tear the ring. */
pthread_mutex_lock(&target->io_lock); pthread_mutex_lock(&target->whisper_lock);
int slot; int slot;
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) { if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
slot = target->whisper_inbox_count++; slot = target->whisper_inbox_count++;
@ -219,13 +219,12 @@ void commands_dispatch(client_t *client) {
snprintf(target->whisper_inbox[slot].content, snprintf(target->whisper_inbox[slot].content,
sizeof(target->whisper_inbox[slot].content), sizeof(target->whisper_inbox[slot].content),
"%s", rest); "%s", rest);
pthread_mutex_unlock(&target->io_lock); pthread_mutex_unlock(&target->whisper_lock);
target->unread_whispers++; target->unread_whispers++;
target->redraw_pending = true;
/* Audible nudge — the title bar ✉ counter (UX-11 style) /* Audible nudge — the title bar ✉ counter (UX-11 style)
* carries the persistent signal. */ * carries the persistent signal. */
client_send(target, "\a", 1); client_queue_bell(target);
client_release(target); client_release(target);
} }
@ -243,15 +242,15 @@ void commands_dispatch(client_t *client) {
} }
} else if (command_id == TNT_COMMAND_INBOX) { } else if (command_id == TNT_COMMAND_INBOX) {
/* Snapshot the inbox under io_lock so a concurrent sender doesn't /* Snapshot the inbox under whisper_lock so a concurrent sender doesn't
* tear what we're rendering. Counter reset happens after copy. */ * tear what we're rendering. Counter reset happens after copy. */
whisper_t snapshot[WHISPER_INBOX_SIZE]; whisper_t snapshot[WHISPER_INBOX_SIZE];
int snap_count; int snap_count;
pthread_mutex_lock(&client->io_lock); pthread_mutex_lock(&client->whisper_lock);
snap_count = client->whisper_inbox_count; snap_count = client->whisper_inbox_count;
memcpy(snapshot, client->whisper_inbox, memcpy(snapshot, client->whisper_inbox,
snap_count * sizeof(whisper_t)); snap_count * sizeof(whisper_t));
pthread_mutex_unlock(&client->io_lock); pthread_mutex_unlock(&client->whisper_lock);
client->unread_whispers = 0; client->unread_whispers = 0;
buffer_appendf(output, sizeof(output), &pos, buffer_appendf(output, sizeof(output), &pos,

View file

@ -123,7 +123,8 @@ static int exec_command_help(client_t *client) {
help_text[0] = '\0'; help_text[0] = '\0';
exec_catalog_append_help(help_text, sizeof(help_text), &pos, exec_catalog_append_help(help_text, sizeof(help_text), &pos,
client->ui_lang); client->ui_lang);
return client_send(client, help_text, pos) == 0 ? 0 : 1; return client_send(client, help_text, pos) == 0 ? TNT_EXIT_OK
: TNT_EXIT_ERROR;
} }
static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) { static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
@ -134,12 +135,13 @@ static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
exec_catalog_append_usage(usage, sizeof(usage), &pos, id, exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
client->ui_lang); client->ui_lang);
client_printf(client, "%s", usage); client_printf(client, "%s", usage);
return 64; return TNT_EXIT_USAGE;
} }
static int exec_command_health(client_t *client) { static int exec_command_health(client_t *client) {
static const char ok[] = "ok\n"; static const char ok[] = "ok\n";
return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1; return client_send(client, ok, sizeof(ok) - 1) == 0 ? TNT_EXIT_OK
: TNT_EXIT_ERROR;
} }
static int exec_command_users(client_t *client, bool json) { static int exec_command_users(client_t *client, bool json) {
@ -157,7 +159,7 @@ static int exec_command_users(client_t *client, bool json) {
if (!usernames) { if (!usernames) {
pthread_rwlock_unlock(&g_room->lock); pthread_rwlock_unlock(&g_room->lock);
client_printf(client, "users: out of memory\n"); client_printf(client, "users: out of memory\n");
return 1; return TNT_EXIT_ERROR;
} }
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
@ -177,7 +179,7 @@ static int exec_command_users(client_t *client, bool json) {
if (!output) { if (!output) {
free(usernames); free(usernames);
client_printf(client, "users: out of memory\n"); client_printf(client, "users: out of memory\n");
return 1; return TNT_EXIT_ERROR;
} }
if (json) { if (json) {
@ -195,7 +197,7 @@ static int exec_command_users(client_t *client, bool json) {
} }
} }
rc = client_send(client, output, pos) == 0 ? 0 : 1; rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
free(output); free(output);
free(usernames); free(usernames);
return rc; return rc;
@ -243,10 +245,11 @@ static int exec_command_stats(client_t *client, bool json) {
if (len < 0 || len >= (int)sizeof(buffer)) { if (len < 0 || len >= (int)sizeof(buffer)) {
client_printf(client, "stats: output overflow\n"); client_printf(client, "stats: output overflow\n");
return 1; return TNT_EXIT_ERROR;
} }
return client_send(client, buffer, (size_t)len) == 0 ? 0 : 1; return client_send(client, buffer, (size_t)len) == 0 ? TNT_EXIT_OK
: TNT_EXIT_ERROR;
} }
static int parse_tail_count(const char *args, int *count) { static int parse_tail_count(const char *args, int *count) {
@ -316,7 +319,7 @@ static int exec_command_tail(client_t *client, const char *args) {
if (!snapshot) { if (!snapshot) {
pthread_rwlock_unlock(&g_room->lock); pthread_rwlock_unlock(&g_room->lock);
client_printf(client, "tail: out of memory\n"); client_printf(client, "tail: out of memory\n");
return 1; return TNT_EXIT_ERROR;
} }
memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t)); memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t));
} }
@ -328,7 +331,7 @@ static int exec_command_tail(client_t *client, const char *args) {
if (!output) { if (!output) {
free(snapshot); free(snapshot);
client_printf(client, "tail: out of memory\n"); client_printf(client, "tail: out of memory\n");
return 1; return TNT_EXIT_ERROR;
} }
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
@ -338,7 +341,7 @@ static int exec_command_tail(client_t *client, const char *args) {
timestamp, snapshot[i].username, snapshot[i].content); timestamp, snapshot[i].username, snapshot[i].content);
} }
rc = client_send(client, output, pos) == 0 ? 0 : 1; rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
free(output); free(output);
free(snapshot); free(snapshot);
return rc; return rc;
@ -355,6 +358,12 @@ static int exec_command_post(client_t *client, const char *args) {
return exec_command_usage(client, TNT_EXEC_COMMAND_POST); return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
} }
if (strlen(args) >= sizeof(content)) {
client_printf(client, "%s",
i18n_text(client->ui_lang, I18N_EXEC_POST_TOO_LONG));
return TNT_EXIT_USAGE;
}
strncpy(content, args, sizeof(content) - 1); strncpy(content, args, sizeof(content) - 1);
content[sizeof(content) - 1] = '\0'; content[sizeof(content) - 1] = '\0';
trim_ascii_whitespace(content); trim_ascii_whitespace(content);
@ -362,14 +371,14 @@ static int exec_command_post(client_t *client, const char *args) {
if (content[0] == '\0') { if (content[0] == '\0') {
client_printf(client, "%s", client_printf(client, "%s",
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY)); i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
return 64; return TNT_EXIT_USAGE;
} }
if (!utf8_is_valid_string(content)) { if (!utf8_is_valid_string(content)) {
client_printf(client, "%s", client_printf(client, "%s",
i18n_text(client->ui_lang, i18n_text(client->ui_lang,
I18N_EXEC_POST_INVALID_UTF8)); I18N_EXEC_POST_INVALID_UTF8));
return 1; return TNT_EXIT_ERROR;
} }
resolve_exec_username(client, username, sizeof(username)); resolve_exec_username(client, username, sizeof(username));
@ -388,18 +397,22 @@ static int exec_command_post(client_t *client, const char *args) {
msg.content[sizeof(msg.content) - 1] = '\0'; msg.content[sizeof(msg.content) - 1] = '\0';
} }
room_broadcast(g_room, &msg);
if (client_send(client, "posted\n", 7) != 0) {
return 1;
}
notify_mentions(msg.content, client);
if (message_save(&msg) < 0) { if (message_save(&msg) < 0) {
fprintf(stderr, "post: failed to persist message\n"); fprintf(stderr, "post: failed to persist message\n");
return 1; client_printf(client, "%s",
i18n_text(client->ui_lang,
I18N_EXEC_POST_PERSIST_FAILED));
return TNT_EXIT_ERROR;
} }
return 0; room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
if (client_send(client, "posted\n", 7) != 0) {
return TNT_EXIT_ERROR;
}
return TNT_EXIT_OK;
} }
int exec_dispatch(client_t *client) { int exec_dispatch(client_t *client) {
@ -407,6 +420,13 @@ int exec_dispatch(client_t *client) {
tnt_exec_command_id_t command_id; tnt_exec_command_id_t command_id;
const char *args = NULL; const char *args = NULL;
if (client->exec_command_too_long) {
client_printf(client, "%s",
i18n_text(client->ui_lang,
I18N_EXEC_COMMAND_TOO_LONG));
return TNT_EXIT_USAGE;
}
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1); strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
command_copy[sizeof(command_copy) - 1] = '\0'; command_copy[sizeof(command_copy) - 1] = '\0';
trim_ascii_whitespace(command_copy); trim_ascii_whitespace(command_copy);
@ -434,7 +454,7 @@ int exec_dispatch(client_t *client) {
case TNT_EXEC_COMMAND_POST: case TNT_EXEC_COMMAND_POST:
return exec_command_post(client, args); return exec_command_post(client, args);
case TNT_EXEC_COMMAND_EXIT: case TNT_EXEC_COMMAND_EXIT:
return 0; return TNT_EXIT_OK;
} }
} }
@ -448,5 +468,5 @@ int exec_dispatch(client_t *client) {
i18n_text(client->ui_lang, i18n_text(client->ui_lang,
I18N_EXEC_UNKNOWN_COMMAND_FORMAT), I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
command_copy); command_copy);
return 64; return TNT_EXIT_USAGE;
} }

View file

@ -193,6 +193,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"post: invalid UTF-8 input\n", "post: invalid UTF-8 input\n",
"post: 输入不是有效 UTF-8\n" "post: 输入不是有效 UTF-8\n"
), ),
[I18N_EXEC_POST_TOO_LONG] = I18N_STRING(
"post: message too long\n",
"post: 消息过长\n"
),
[I18N_EXEC_POST_PERSIST_FAILED] = I18N_STRING(
"post: failed to persist message\n",
"post: 消息持久化失败\n"
),
[I18N_EXEC_COMMAND_TOO_LONG] = I18N_STRING(
"exec: command too long\n",
"exec: 命令过长\n"
),
[I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING( [I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
"Unknown command: %s\n", "Unknown command: %s\n",
"未知命令: %s\n" "未知命令: %s\n"

View file

@ -134,9 +134,17 @@ static int read_username(client_t *client) {
void notify_mentions(const char *content, const client_t *sender) { void notify_mentions(const char *content, const client_t *sender) {
pthread_rwlock_rdlock(&g_room->lock); pthread_rwlock_rdlock(&g_room->lock);
int count = g_room->client_count; int count = g_room->client_count;
client_t *targets[MAX_CLIENTS]; client_t **targets = NULL;
int target_count = 0; int target_count = 0;
if (count > 0) {
targets = calloc((size_t)count, sizeof(*targets));
if (!targets) {
pthread_rwlock_unlock(&g_room->lock);
return;
}
}
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
client_t *c = g_room->clients[i]; client_t *c = g_room->clients[i];
if (c == sender) continue; if (c == sender) continue;
@ -150,11 +158,11 @@ void notify_mentions(const char *content, const client_t *sender) {
pthread_rwlock_unlock(&g_room->lock); pthread_rwlock_unlock(&g_room->lock);
for (int i = 0; i < target_count; i++) { for (int i = 0; i < target_count; i++) {
client_send(targets[i], "\a", 1);
targets[i]->unread_mentions++; targets[i]->unread_mentions++;
targets[i]->redraw_pending = true; client_queue_bell(targets[i]);
client_release(targets[i]); client_release(targets[i]);
} }
free(targets);
} }
static int read_channel_exact(client_t *client, char *buf, size_t len, static int read_channel_exact(client_t *client, char *buf, size_t len,
@ -731,7 +739,7 @@ void input_run_session(client_t *client) {
client->last_active = time(NULL); client->last_active = time(NULL);
/* Check for exec command */ /* Check for exec command */
if (client->exec_command[0] != '\0') { if (client->exec_command[0] != '\0' || client->exec_command_too_long) {
int exit_status = exec_dispatch(client); int exit_status = exec_dispatch(client);
ssh_channel_request_send_exit_status(client->channel, exit_status); ssh_channel_request_send_exit_status(client->channel, exit_status);
ssh_channel_send_eof(client->channel); ssh_channel_send_eof(client->channel);
@ -811,6 +819,10 @@ main_loop:
break; break;
} }
if (client_flush_pending_bells(client) != 0) {
break;
}
if (current_update_seq != seen_update_seq) { if (current_update_seq != seen_update_seq) {
seen_update_seq = current_update_seq; seen_update_seq = current_update_seq;
room_updated = true; room_updated = true;

View file

@ -41,7 +41,7 @@ int main(int argc, char **argv) {
if (*end != '\0' || val <= 0 || val > 65535) { if (*end != '\0' || val <= 0 || val > 65535) {
fprintf(stderr, cli_text_invalid_port_format(lang), fprintf(stderr, cli_text_invalid_port_format(lang),
argv[i + 1]); argv[i + 1]);
return 1; return TNT_EXIT_USAGE;
} }
port = (int)val; port = (int)val;
i++; i++;
@ -49,23 +49,23 @@ int main(int argc, char **argv) {
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) { strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) {
if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) { if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) {
perror("setenv TNT_STATE_DIR"); perror("setenv TNT_STATE_DIR");
return 1; return TNT_EXIT_ERROR;
} }
i++; i++;
} else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) { } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
printf("tnt %s\n", TNT_VERSION); printf("tnt %s\n", TNT_VERSION);
return 0; return TNT_EXIT_OK;
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
char output[2048] = {0}; char output[2048] = {0};
size_t pos = 0; size_t pos = 0;
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang); cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
fputs(output, stdout); fputs(output, stdout);
return 0; return TNT_EXIT_OK;
} else { } else {
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]); fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]); fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
return 1; return TNT_EXIT_USAGE;
} }
} }
@ -77,7 +77,7 @@ int main(int argc, char **argv) {
/* Initialize subsystems */ /* Initialize subsystems */
if (tnt_ensure_state_dir() < 0) { if (tnt_ensure_state_dir() < 0) {
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir()); fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
return 1; return TNT_EXIT_ERROR;
} }
message_init(); message_init();
@ -86,14 +86,14 @@ int main(int argc, char **argv) {
g_room = room_create(); g_room = room_create();
if (!g_room) { if (!g_room) {
fprintf(stderr, "Failed to create chat room\n"); fprintf(stderr, "Failed to create chat room\n");
return 1; return TNT_EXIT_ERROR;
} }
/* Initialize server */ /* Initialize server */
if (ssh_server_init(port) < 0) { if (ssh_server_init(port) < 0) {
fprintf(stderr, "Failed to initialize server\n"); fprintf(stderr, "Failed to initialize server\n");
room_destroy(g_room); room_destroy(g_room);
return 1; return TNT_EXIT_ERROR;
} }
/* Start server (blocking) */ /* Start server (blocking) */

296
src/tntctl.c Normal file
View 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;
}

View file

@ -26,6 +26,7 @@ if [ ! -f "$BIN" ]; then
fi fi
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
TNTCTL_OPTS="--host-key-checking no --known-hosts /dev/null"
echo "=== TNT Exec Mode Tests ===" echo "=== TNT Exec Mode Tests ==="
@ -51,14 +52,16 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null || true) HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null)
HEALTH_USAGE_STATUS=$?
printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$' printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$'
if [ $? -eq 0 ]; then if [ $? -eq 0 ] && [ "$HEALTH_USAGE_STATUS" -eq 64 ]; then
echo "✓ no-arg exec usage follows TNT_LANG" echo "✓ no-arg exec usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1)) PASS=$((PASS + 1))
else else
echo "✗ no-arg exec usage output unexpected" echo "✗ no-arg exec usage output unexpected"
printf '%s\n' "$HEALTH_USAGE" printf '%s\n' "$HEALTH_USAGE"
echo "exit status: $HEALTH_USAGE_STATUS"
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
@ -98,36 +101,42 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null || true) UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null)
UNKNOWN_STATUS=$?
printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$' printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$'
if [ $? -eq 0 ]; then if [ $? -eq 0 ] && [ "$UNKNOWN_STATUS" -eq 64 ]; then
echo "✓ unknown command follows TNT_LANG" echo "✓ unknown command follows TNT_LANG and exits 64"
PASS=$((PASS + 1)) PASS=$((PASS + 1))
else else
echo "✗ unknown command output unexpected" echo "✗ unknown command output unexpected"
printf '%s\n' "$UNKNOWN_OUTPUT" printf '%s\n' "$UNKNOWN_OUTPUT"
echo "exit status: $UNKNOWN_STATUS"
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null || true) POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null)
POST_USAGE_STATUS=$?
printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$' printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$'
if [ $? -eq 0 ]; then if [ $? -eq 0 ] && [ "$POST_USAGE_STATUS" -eq 64 ]; then
echo "✓ post usage follows TNT_LANG" echo "✓ post usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1)) PASS=$((PASS + 1))
else else
echo "✗ post usage output unexpected" echo "✗ post usage output unexpected"
printf '%s\n' "$POST_USAGE" printf '%s\n' "$POST_USAGE"
echo "exit status: $POST_USAGE_STATUS"
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null || true) USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null)
USERS_USAGE_STATUS=$?
printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$' printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
if [ $? -eq 0 ]; then if [ $? -eq 0 ] && [ "$USERS_USAGE_STATUS" -eq 64 ]; then
echo "✓ users usage follows TNT_LANG" echo "✓ users usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1)) PASS=$((PASS + 1))
else else
echo "✗ users usage output unexpected" echo "✗ users usage output unexpected"
printf '%s\n' "$USERS_USAGE" printf '%s\n' "$USERS_USAGE"
echo "exit status: $USERS_USAGE_STATUS"
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
@ -152,6 +161,106 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
PERSIST_FAIL_MARKER="persist-fail-marker"
rm -f "$STATE_DIR/messages.log"
mkdir "$STATE_DIR/messages.log"
PERSIST_FAIL_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "$PERSIST_FAIL_MARKER" 2>/dev/null)
PERSIST_FAIL_STATUS=$?
rmdir "$STATE_DIR/messages.log"
printf '%s\n' "$PERSIST_FAIL_OUTPUT" | grep -q 'posted'
PERSIST_FAIL_POSTED=$?
PERSIST_FAIL_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
printf '%s\n' "$PERSIST_FAIL_TAIL" | grep -q "$PERSIST_FAIL_MARKER"
PERSIST_FAIL_VISIBLE=$?
if [ "$PERSIST_FAIL_STATUS" -eq 1 ] &&
[ "$PERSIST_FAIL_POSTED" -ne 0 ] &&
[ "$PERSIST_FAIL_VISIBLE" -ne 0 ]; then
echo "✓ post persistence failure is not broadcast or acknowledged"
PASS=$((PASS + 1))
else
echo "✗ post persistence failure handling unexpected"
printf '%s\n' "$PERSIST_FAIL_OUTPUT"
printf '%s\n' "$PERSIST_FAIL_TAIL"
echo "exit status: $PERSIST_FAIL_STATUS"
FAIL=$((FAIL + 1))
fi
LONG_MARKER="too-long-exec-marker"
LONG_COMMAND=$(printf 'post %s %01020d' "$LONG_MARKER" 0)
LONG_OUTPUT=$(ssh $SSH_OPTS localhost "$LONG_COMMAND" 2>/dev/null)
LONG_STATUS=$?
printf '%s\n' "$LONG_OUTPUT" | grep -q '命令过长'
LONG_ERROR=$?
LONG_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
printf '%s\n' "$LONG_TAIL" | grep -q "$LONG_MARKER"
LONG_VISIBLE=$?
if [ "$LONG_STATUS" -eq 64 ] &&
[ "$LONG_ERROR" -eq 0 ] &&
[ "$LONG_VISIBLE" -ne 0 ]; then
echo "✓ overlong exec command is rejected without truncation"
PASS=$((PASS + 1))
else
echo "✗ overlong exec command handling unexpected"
printf '%s\n' "$LONG_OUTPUT"
printf '%s\n' "$LONG_TAIL"
echo "exit status: $LONG_STATUS"
FAIL=$((FAIL + 1))
fi
TNTCTL_HEALTH=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost health 2>/dev/null || true)
if [ "$TNTCTL_HEALTH" = "ok" ]; then
echo "✓ tntctl health uses exec interface"
PASS=$((PASS + 1))
else
echo "✗ tntctl health failed: $TNTCTL_HEALTH"
FAIL=$((FAIL + 1))
fi
TNTCTL_STATS=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost stats --json 2>/dev/null || true)
printf '%s\n' "$TNTCTL_STATS" | grep -q '"status":"ok"'
if [ $? -eq 0 ]; then
echo "✓ tntctl stats --json returns JSON"
PASS=$((PASS + 1))
else
echo "✗ tntctl stats --json output unexpected"
printf '%s\n' "$TNTCTL_STATS"
FAIL=$((FAIL + 1))
fi
TNTCTL_USERS_USAGE=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost users --xml 2>/dev/null)
TNTCTL_USERS_STATUS=$?
printf '%s\n' "$TNTCTL_USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
if [ $? -eq 0 ] && [ "$TNTCTL_USERS_STATUS" -eq 64 ]; then
echo "✓ tntctl preserves remote usage exit 64"
PASS=$((PASS + 1))
else
echo "✗ tntctl users usage output unexpected"
printf '%s\n' "$TNTCTL_USERS_USAGE"
echo "exit status: $TNTCTL_USERS_STATUS"
FAIL=$((FAIL + 1))
fi
TNTCTL_POST=$("../tntctl" -p "$PORT" $TNTCTL_OPTS -l ctlposter localhost post "hello from tntctl" 2>/dev/null || true)
if [ "$TNTCTL_POST" = "posted" ]; then
echo "✓ tntctl post publishes a message"
PASS=$((PASS + 1))
else
echo "✗ tntctl post failed: $TNTCTL_POST"
FAIL=$((FAIL + 1))
fi
TNTCTL_TAIL=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "tail" "-n" "1" 2>/dev/null || true)
printf '%s\n' "$TNTCTL_TAIL" | grep -q 'ctlposter' &&
printf '%s\n' "$TNTCTL_TAIL" | grep -q 'hello from tntctl'
if [ $? -eq 0 ]; then
echo "✓ tntctl tail returns recent messages"
PASS=$((PASS + 1))
else
echo "✗ tntctl tail output unexpected"
printf '%s\n' "$TNTCTL_TAIL"
FAIL=$((FAIL + 1))
fi
EXPECT_SCRIPT="${STATE_DIR}/watcher.expect" EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
WATCHER_READY="${STATE_DIR}/watcher.ready" WATCHER_READY="${STATE_DIR}/watcher.ready"
cat >"$EXPECT_SCRIPT" <<EOF cat >"$EXPECT_SCRIPT" <<EOF
@ -160,7 +269,7 @@ spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT w
expect "请输入用户名" expect "请输入用户名"
send "watcher\r" send "watcher\r"
exec touch "$WATCHER_READY" exec touch "$WATCHER_READY"
sleep 8 sleep 12
send "\003" send "\003"
expect eof expect eof
EOF EOF
@ -213,6 +322,45 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
MENTION_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "@watcher hello from exec mention" 2>/dev/null || true)
if [ "$MENTION_OUTPUT" = "posted" ]; then
echo "✓ post returns while notifying an interactive mention target"
PASS=$((PASS + 1))
else
echo "✗ mention post failed: $MENTION_OUTPUT"
FAIL=$((FAIL + 1))
fi
MSG_SCRIPT="${STATE_DIR}/private-message.expect"
cat >"$MSG_SCRIPT" <<EOF
set timeout 10
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT sender@localhost
expect "请输入用户名"
send "sender\r"
expect ":help"
send "\033"
expect "NORMAL"
send ":"
expect ":"
send "msg watcher hello from private message\r"
expect "私信已发送给 watcher"
expect "q:关闭"
send "q"
sleep 0.2
send "\003"
expect eof
EOF
if expect "$MSG_SCRIPT" >"${STATE_DIR}/private-message.log" 2>&1; then
echo "✓ :msg returns while queuing recipient notification"
PASS=$((PASS + 1))
else
echo "✗ :msg notification path failed"
sed -n '1,120p' "${STATE_DIR}/private-message.log"
sed -n '1,120p' "${STATE_DIR}/server.log"
FAIL=$((FAIL + 1))
fi
wait "${INTERACTIVE_PID}" 2>/dev/null || true wait "${INTERACTIVE_PID}" 2>/dev/null || true
INTERACTIVE_PID="" INTERACTIVE_PID=""

133
tests/test_tntctl_cli.sh Executable file
View 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"

View file

@ -159,8 +159,9 @@ TEST(room_remove_nonexistent_client) {
TEST(room_add_client_full) { TEST(room_add_client_full) {
chat_room_t *room = room_create(); chat_room_t *room = room_create();
client_t clients[MAX_CLIENTS + 1]; client_t *clients = calloc((size_t)room->client_capacity + 1,
memset(clients, 0, sizeof(clients)); sizeof(*clients));
assert(clients != NULL);
for (int i = 0; i < room->client_capacity; i++) { for (int i = 0; i < room->client_capacity; i++) {
assert(room_add_client(room, &clients[i]) == 0); assert(room_add_client(room, &clients[i]) == 0);
@ -169,6 +170,23 @@ TEST(room_add_client_full) {
assert(room_add_client(room, &clients[room->client_capacity]) == -1); assert(room_add_client(room, &clients[room->client_capacity]) == -1);
assert(room_get_client_count(room) == room->client_capacity); assert(room_get_client_count(room) == room->client_capacity);
free(clients);
room_destroy(room);
}
TEST(room_capacity_follows_tnt_max_connections) {
setenv("TNT_MAX_CONNECTIONS", "3", 1);
chat_room_t *room = room_create();
unsetenv("TNT_MAX_CONNECTIONS");
client_t clients[4];
memset(clients, 0, sizeof(clients));
assert(room->client_capacity == 3);
assert(room_add_client(room, &clients[0]) == 0);
assert(room_add_client(room, &clients[1]) == 0);
assert(room_add_client(room, &clients[2]) == 0);
assert(room_add_client(room, &clients[3]) == -1);
room_destroy(room); room_destroy(room);
} }
@ -201,6 +219,7 @@ int main(void) {
RUN_TEST(room_client_count); RUN_TEST(room_client_count);
RUN_TEST(room_remove_nonexistent_client); RUN_TEST(room_remove_nonexistent_client);
RUN_TEST(room_add_client_full); RUN_TEST(room_add_client_full);
RUN_TEST(room_capacity_follows_tnt_max_connections);
RUN_TEST(room_message_count_threadsafe); RUN_TEST(room_message_count_threadsafe);
printf("\nAll %d tests passed!\n", tests_passed); printf("\nAll %d tests passed!\n", tests_passed);

View file

@ -147,6 +147,10 @@ TEST(text_lookup_matches_language) {
"message cannot be empty") != NULL); "message cannot be empty") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY), assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY),
"消息不能为空") != NULL); "消息不能为空") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_COMMAND_TOO_LONG),
"command too long") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_COMMAND_TOO_LONG),
"命令过长") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT), assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
"Unknown command") != NULL); "Unknown command") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT), assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),

24
tnt.1
View file

@ -144,6 +144,30 @@ ssh host \-p 2222 health
Exit codes follow Exit codes follow
.BR sysexits (3) .BR sysexits (3)
conventions. conventions.
.SH EXIT STATUS
.TP
.B 0
Success.
.TP
.B 1
Runtime error, such as I/O failure, allocation failure, or persistence failure.
.TP
.B 64
Usage error, such as an unknown command, invalid option, or invalid argument
shape.
.TP
.B 69
Reserved for the local
.BR tntctl (1)
wrapper when SSH transport is unavailable.
.TP
.B 78
Reserved for future local
.BR tntctl (1)
configuration errors.
.PP
The SSH exec JSON field contract is documented in
.IR docs/INTERFACE.md .
.SH ENVIRONMENT .SH ENVIRONMENT
.TP .TP
.B PORT .B PORT

119
tntctl.1 Normal file
View 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)