diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..994b387 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0d47ada --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..5936e43 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2cacba..6663d8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + jobs: build-and-test: runs-on: ${{ matrix.os }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0503e4b..c76ddec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,9 @@ on: tags: - 'v*' +permissions: + contents: read + jobs: build: name: Build ${{ matrix.target }} @@ -15,15 +18,19 @@ jobs: - os: ubuntu-24.04 target: linux-amd64 artifact: tnt-linux-amd64 + ctl_artifact: tntctl-linux-amd64 - os: ubuntu-24.04-arm target: linux-arm64 artifact: tnt-linux-arm64 + ctl_artifact: tntctl-linux-arm64 - os: macos-15-intel target: darwin-amd64 artifact: tnt-darwin-amd64 + ctl_artifact: tntctl-darwin-amd64 - os: macos-15 target: darwin-arm64 artifact: tnt-darwin-arm64 + ctl_artifact: tntctl-darwin-arm64 steps: - uses: actions/checkout@v4 @@ -48,29 +55,38 @@ jobs: - name: Verify artifact architecture run: | file tnt + file tntctl case "${{ matrix.target }}" in linux-amd64) file tnt | grep -E 'ELF 64-bit.*x86-64' + file tntctl | grep -E 'ELF 64-bit.*x86-64' ;; linux-arm64) file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)' + file tntctl | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)' ;; darwin-amd64) file tnt | grep -E 'Mach-O 64-bit.*x86_64' + file tntctl | grep -E 'Mach-O 64-bit.*x86_64' ;; darwin-arm64) file tnt | grep -E 'Mach-O 64-bit.*arm64' + file tntctl | grep -E 'Mach-O 64-bit.*arm64' ;; esac - name: Rename binary - run: mv tnt ${{ matrix.artifact }} + run: | + mv tnt ${{ matrix.artifact }} + mv tntctl ${{ matrix.ctl_artifact }} - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} - path: ${{ matrix.artifact }} + path: | + ${{ matrix.artifact }} + ${{ matrix.ctl_artifact }} release: needs: build @@ -90,7 +106,8 @@ jobs: run: | cd artifacts : > checksums.txt - for artifact in */tnt-*; do + for artifact in */tnt-* */tntctl-*; do + [ -f "$artifact" ] || continue sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt done cat checksums.txt @@ -100,6 +117,7 @@ jobs: with: files: | artifacts/*/tnt-* + artifacts/*/tntctl-* artifacts/checksums.txt body: | ## Installation @@ -109,29 +127,41 @@ jobs: **Linux AMD64:** ```bash wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64 + wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-amd64 chmod +x tnt-linux-amd64 + chmod +x tntctl-linux-amd64 sudo mv tnt-linux-amd64 /usr/local/bin/tnt + sudo mv tntctl-linux-amd64 /usr/local/bin/tntctl ``` **Linux ARM64:** ```bash wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64 + wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-arm64 chmod +x tnt-linux-arm64 + chmod +x tntctl-linux-arm64 sudo mv tnt-linux-arm64 /usr/local/bin/tnt + sudo mv tntctl-linux-arm64 /usr/local/bin/tntctl ``` **macOS Intel:** ```bash wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64 + wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-amd64 chmod +x tnt-darwin-amd64 + chmod +x tntctl-darwin-amd64 sudo mv tnt-darwin-amd64 /usr/local/bin/tnt + sudo mv tntctl-darwin-amd64 /usr/local/bin/tntctl ``` **macOS Apple Silicon:** ```bash wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64 + wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-arm64 chmod +x tnt-darwin-arm64 + chmod +x tntctl-darwin-arm64 sudo mv tnt-darwin-arm64 /usr/local/bin/tnt + sudo mv tntctl-darwin-arm64 /usr/local/bin/tntctl ``` **Verify checksums:** diff --git a/.gitignore b/.gitignore index b559a17..3330c0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.o obj/ tnt +tntctl messages.log host_key host_key.pub diff --git a/Makefile b/Makefile index af14d9d..7a010dc 100644 --- a/Makefile +++ b/Makefile @@ -20,10 +20,13 @@ SRC_DIR = src INC_DIR = include OBJ_DIR = obj -SOURCES = $(wildcard $(SRC_DIR)/*.c) +SOURCES = $(filter-out $(SRC_DIR)/tntctl.c,$(wildcard $(SRC_DIR)/*.c)) OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) -DEPS = $(OBJECTS:.o=.d) +DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d) TARGET = tnt +CTL_TARGET = tntctl +CTL_OBJECTS = $(OBJ_DIR)/tntctl.o +TARGETS = $(TARGET) $(CTL_TARGET) PREFIX ?= /usr/local BINDIR ?= $(PREFIX)/bin @@ -33,12 +36,16 @@ CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) .PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info -all: $(TARGET) +all: $(TARGETS) $(TARGET): $(OBJECTS) $(CC) $(OBJECTS) -o $@ $(LDFLAGS) @echo "Build complete: $(TARGET)" +$(CTL_TARGET): $(CTL_OBJECTS) + $(CC) $(CTL_OBJECTS) -o $@ + @echo "Build complete: $(CTL_TARGET)" + $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) $(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@ @@ -46,34 +53,40 @@ $(OBJ_DIR): mkdir -p $(OBJ_DIR) clean: - rm -rf $(OBJ_DIR) $(TARGET) + rm -rf $(OBJ_DIR) $(TARGETS) rm -f tests/*.log tests/host_key* tests/messages.log @echo "Clean complete" -install: $(TARGET) +install: $(TARGETS) install -d $(DESTDIR)$(BINDIR) install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/ + install -m 755 $(CTL_TARGET) $(DESTDIR)$(BINDIR)/ install -d $(DESTDIR)$(MANDIR)/man1 install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/ + install -m 644 tntctl.1 $(DESTDIR)$(MANDIR)/man1/ install-systemd: install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR) - install -m 644 tnt.service $(DESTDIR)$(SYSTEMD_UNIT_DIR)/ + sed 's#^ExecStart=.*#ExecStart=$(BINDIR)/$(TARGET)#' tnt.service > "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service" + chmod 644 "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service" uninstall: rm -f $(DESTDIR)$(BINDIR)/$(TARGET) + rm -f $(DESTDIR)$(BINDIR)/$(CTL_TARGET) rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1 + rm -f $(DESTDIR)$(MANDIR)/man1/tntctl.1 uninstall-systemd: rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service # Development targets debug: CFLAGS += -g -DDEBUG -debug: clean $(TARGET) +debug: clean $(TARGETS) release: CFLAGS += -O3 -DNDEBUG -release: clean $(TARGET) +release: clean $(TARGETS) strip $(TARGET) + strip $(CTL_TARGET) release-check: ./scripts/release_check.sh @@ -83,7 +96,7 @@ release-check-strict: asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer asan: LDFLAGS += -fsanitize=address -asan: clean $(TARGET) +asan: clean $(TARGETS) @echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt" valgrind: debug @@ -112,6 +125,7 @@ integration-test: all @cd tests && PORT=$${PORT:-2222} ./test_basic.sh @cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh @cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh + @cd tests && ./test_tntctl_cli.sh anonymous-access-test: all @echo "Running anonymous access tests..." diff --git a/README.md b/README.md index 8a69f70..893d5dc 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ A minimalist terminal chat server with Vim-style interface over SSH. ```sh curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh ``` -The installer verifies the downloaded release binary against `checksums.txt` -before installing it. +The installer verifies downloaded release binaries against `checksums.txt` +before installing them. Older releases may provide only `tnt`; newer releases +also install `tntctl`. **From source:** ```sh @@ -183,6 +184,18 @@ ssh -p 2222 chat.example.com post "/me deploys v2.0" **`post` identity**: the message is attributed to the SSH login name (the `user@` part of the URL, falling back to `anonymous`). In the default anonymous-access configuration there is no identity check, so any client can post as any name. Set `TNT_ACCESS_TOKEN` if you need authenticated posting. +See [docs/INTERFACE.md](docs/INTERFACE.md) for the stable exec command +contract, exit statuses, and JSON field definitions. + +Source and package-manager installs also include `tntctl`, a thin wrapper +around the same SSH exec interface: + +```sh +tntctl chat.example.com health +tntctl -p 2222 chat.example.com stats --json +tntctl -l operator chat.example.com post "service notice" +``` + ## Development ### Building @@ -254,6 +267,7 @@ TNT/ │ ├── commands.c # COMMAND-mode command dispatch │ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape │ ├── exec.c # SSH exec command dispatch +│ ├── tntctl.c # local wrapper around the SSH exec interface │ ├── ssh_server.c # SSH server implementation │ ├── bootstrap.c # SSH authentication and session bootstrap │ ├── chat_room.c # chat room logic @@ -358,6 +372,7 @@ Delete `motd.txt` to disable the MOTD. - [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual - [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide - [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages +- [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields - [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference - [Contributing](docs/CONTRIBUTING.md) - How to contribute - [Changelog](docs/CHANGELOG.md) - Version history diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3e92987 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6fb4d2b..be83c75 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,7 +2,48 @@ ## Unreleased +### Added +- Documented the stable SSH exec interface contract, including exit statuses + and JSON field shapes for package tests, scripts, and future `tntctl` work. +- Added a public security policy, supported-version guidance, and GitHub issue + templates for bug reports and feature requests. +- Added `tntctl`, a thin local wrapper around the documented SSH exec + interface for health, stats, users, tail, post, help, and exit commands. + ### Changed +- `make install-systemd` now rewrites the installed unit's `ExecStart` to match + the selected `PREFIX`/`BINDIR`, so package builds that install to `/usr` + produce a unit pointing at `/usr/bin/tnt`. +- Release preflight now checks the staged systemd unit path, and strict release + checks also require a clean tree, tag-at-HEAD, changelog release section, and + non-placeholder maintainer metadata. +- CI and release workflows now use explicit least-privilege repository + permissions. +- The release guide now documents SemVer expectations, manual release review, + smoke testing, and rollback steps. +- Package installs now include `tntctl` and its man page alongside `tnt`. +- SSH exec commands longer than the command buffer are now rejected with a + usage error instead of being truncated and executed. +- SSH exec `post` now persists the message before broadcasting or returning + `posted`, so persistence failures are not visible as successful room events. +- Mention and private-message bell notifications are now queued on the target + client and flushed by that client's own session loop, so slow SSH writes do + not block the sender's message path. +- Private-message inbox access now uses its own mutex instead of sharing the + SSH channel write lock, reducing unrelated contention on slow clients. +- Client writes now check the SSH channel's remote window before writing and + mark the client disconnected when the window is closed, avoiding the most + direct slow-reader blocking path. +- Room capacity and mention notification bookkeeping now follow + `TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit. +- Updated the roadmap to reflect completed `tntctl`, stable exec contract, and + monitoring-interface work, leaving the remaining daemon naming and runtime + queue work explicit. +- Strict release preflight now builds and installs from the local `vX.Y.Z` tag + source archive, catching untracked files that would be missing from a GitHub + source release. +- Release documentation now creates the local tag before strict release checks, + matching the strict gate's tag-at-HEAD requirement. - Split UI-language parsing from localized text lookup: `src/i18n.c` now owns locale/code parsing, while `src/i18n_text.c` owns the table-driven text catalog with coverage checks for every message ID. diff --git a/docs/CICD.md b/docs/CICD.md index 427cf1e..96d05c6 100644 --- a/docs/CICD.md +++ b/docs/CICD.md @@ -19,37 +19,87 @@ into production or restart services on push. CREATING RELEASES ----------------- +Release policy: + - Use SemVer-style tags: vMAJOR.MINOR.PATCH. + - Bump PATCH for compatible bug fixes and release hardening. + - Bump MINOR for new commands, new documented flags, JSON field additions, + or visible user-interface behavior changes. + - Bump MAJOR for incompatible command, config, storage, or package behavior. + - Keep GitHub draft release review manual. Do not auto-publish releases. + - Keep production deployment manual. Do not SSH into production from CI. + 1. Update version metadata: - include/common.h - tnt.1 - docs/CHANGELOG.md - packaging/arch/PKGBUILD - packaging/homebrew/tnt-chat.rb + - packaging/debian/debian/changelog + - package checksums and maintainer metadata, when preparing public package + recipes 2. Run the local preflight: make release-check -3. Replace package checksum placeholders and run: +3. Commit the release changes and create a local tag. Do not push the tag + until strict checks pass: + git tag v1.0.1 + +4. Run strict release checks: make release-check-strict -4. Create and push tag: - git tag v1.0.1 + Strict mode requires the local `vX.Y.Z` tag to point at HEAD. It also + builds from the tagged source archive, so it catches files that were left + untracked and would be missing from GitHub's source archive. + +5. Push the tag: git push origin v1.0.1 -5. GitHub Actions automatically: - - Builds binaries (Linux/macOS, AMD64/ARM64) +6. GitHub Actions automatically: + - Builds `tnt` and `tntctl` binaries (Linux/macOS, AMD64/ARM64) - Creates a draft release - Uploads binaries - Generates one `checksums.txt` file - Verifies that artifact architecture matches the asset name -6. Review the draft release, smoke-test downloaded assets, then publish it +7. Review the draft release, smoke-test downloaded assets, then publish it manually from GitHub. -7. Release appears at: +8. Release appears at: https://github.com/m1ngsama/TNT/releases +RELEASE REVIEW CHECKLIST +------------------------ +Before publishing a draft release: + - Confirm `git tag` points at the intended commit. + - Download every release asset from GitHub, not from the local workspace. + - Verify `checksums.txt` with `sha256sum -c checksums.txt`. + - Run downloaded `tnt --version` and `tntctl --version`. + - Start a temporary server and check: + ssh -p 2222 server health + ssh -p 2222 server stats --json + ssh -p 2222 server users --json + ssh -p 2222 operator@server post "release smoke" + ssh -p 2222 server "tail -n 1" + - Check runtime dynamic links (`ldd` on Linux, `otool -L` on macOS) and make + sure `libssh` is documented for the target install path. + - Confirm `make release-check-strict` passed after package checksums were + replaced. + + +ROLLBACK +-------- +Production rollback stays manual: + 1. Keep the previous binary before replacing it. + 2. Stop or restart only the intended `tnt` service. + 3. Restore the previous binary if smoke checks fail. + 4. Re-run `health`, `stats --json`, and one post/tail smoke test. + +Do not overwrite `TNT_STATE_DIR` during rollback. If a future release changes +the message log format, its release notes must include the downgrade behavior. + + DEPLOYING TO SERVERS -------------------- Deployments are operator-driven: diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 3be370a..6632016 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -41,6 +41,7 @@ command_catalog.c → COMMAND-mode command metadata, usage, and argument shape commands.c → COMMAND-mode command dispatch exec_catalog.c → SSH exec command matching, usage, and argument shape exec.c → SSH exec command dispatch +tntctl.c → local wrapper around the SSH exec interface ssh_server.c → SSH listener setup bootstrap.c → SSH authentication/session bootstrap input.c → interactive session loop @@ -69,7 +70,7 @@ utf8.c → UTF-8 string handling ## Known Limits -- Max 64 clients (MAX_CLIENTS) +- Default 64 clients, configurable with `TNT_MAX_CONNECTIONS` - Max 100 messages in memory (MAX_MESSAGES) - Max 1024 bytes per message (MAX_MESSAGE_LEN) - Max 64 bytes username (MAX_USERNAME_LEN) diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md new file mode 100644 index 0000000..94c4e8e --- /dev/null +++ b/docs/INTERFACE.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1866534..fa96614 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,26 +17,33 @@ This roadmap is intentionally strict. Each stage should leave the project easier Goal: make TNT predictable for operators, scripts, and package maintainers. -- split the current surface into `tntd` (daemon) and `tntctl` (control client) -- keep SSH exec support, but treat it as a transport for stable commands rather than the primary API shape -- define stable subcommands and exit codes for: +- ✅ introduce `tntctl` as a thin control client over the stable SSH exec surface +- keep SSH exec support, but treat it as a transport for stable commands rather + than an ad hoc command surface +- ✅ define stable subcommands and exit codes for: - `health` - `stats` - `users` - `tail` - `post` -- support text and JSON output modes where machine use is likely -- normalize command parsing, help text, and error reporting +- ✅ support text and JSON output modes where machine use is likely +- ✅ normalize command parsing, help text, and error reporting +- decide whether the server binary should remain `tnt` or split later into a + separate `tntd` daemon name - add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently -- add a man page for `tntd` and `tntctl` +- ✅ add man pages for `tnt` and `tntctl` ## Stage 2: Runtime Model Goal: make long-running operation boring and reliable. - move client state to a clearer ownership model with one release path -- finish replacing ad hoc cross-thread UI mutation with per-client event delivery -- add bounded outbound queues so slow clients cannot stall other users +- ✅ remove cross-client SSH channel writes from mention and private-message + notifications +- continue replacing ad hoc cross-thread UI mutation with per-client event + delivery +- add bounded outbound queues so slow clients cannot stall their own session + loop indefinitely - separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly - make room/client capacity fully runtime-configurable with no hidden compile-time ceiling - document hard guarantees and soft limits @@ -73,7 +80,7 @@ Goal: make public deployment manageable. - provide clear distinction between concurrent session limits and connection-rate limits - add admin-only controls for read-only mode, mute, and ban -- expose a minimal health and stats surface suitable for monitoring +- ✅ expose a minimal health and stats surface suitable for monitoring - support systemd-friendly readiness and watchdog behavior - document recommended production defaults for public, private, and localhost-only deployments - tighten CI around authentication, limits, and restart behavior @@ -92,8 +99,12 @@ Goal: make regressions harder to introduce. These are the next changes that should happen before new feature work expands the surface area. -1. Introduce `tntctl` and move stable command handling behind it. -2. Define exit codes and JSON schemas for `health`, `stats`, `users`, `tail`, and `post`. -3. Add per-client outbound queues and finish untangling client-state ownership. -4. Remove the remaining hidden runtime limits and make them explicit configuration. -5. Add a long-running soak test that exercises idle sessions, reconnects, and slow consumers. +1. Decide the daemon naming path: keep `tnt` as the server binary for 1.x, or + introduce `tntd` later with a compatibility plan. +2. Add per-client outbound queues and finish untangling client-state ownership. +3. Remove the remaining hidden runtime limits and make them explicit + configuration. +4. Add a long-running soak test that exercises idle sessions, reconnects, and + slow consumers. +5. Replace remaining release placeholders with real maintainer metadata and + source-archive checksums when cutting a public package release. diff --git a/include/client.h b/include/client.h index 968f770..889af68 100644 --- a/include/client.h +++ b/include/client.h @@ -8,6 +8,14 @@ * success, -1 if the channel is gone or a partial write fails. */ int client_send(client_t *client, const char *data, size_t len); +/* Queue an audible bell for the client's own session loop to send. This + * avoids writing to another client's SSH channel from the sender's thread. */ +void client_queue_bell(client_t *client); + +/* Send one queued bell, if present, from the client's own session loop. + * Returns 0 when no bell was pending or it was written successfully. */ +int client_flush_pending_bells(client_t *client); + /* printf-style wrapper around client_send(). The formatted string must * fit in 2048 bytes; truncation or encoding errors return -1. */ int client_printf(client_t *client, const char *fmt, ...); diff --git a/include/commands.h b/include/commands.h index b12a0ed..09f6b47 100644 --- a/include/commands.h +++ b/include/commands.h @@ -15,9 +15,8 @@ * - Toggles client->mute_joins on `:mute-joins` * - May broadcast a system rename message on `:nick` * - * Reads g_room. Caller must already hold the channel I/O serialisation - * established by handle_key() — this function calls back into client_send - * (via tui_render_command_output) which acquires client->io_lock. */ + * Reads g_room. Renders command output through the normal client_send() + * path; callers must not hold client->io_lock before dispatching. */ void commands_dispatch(client_t *client); #endif /* COMMANDS_H */ diff --git a/include/common.h b/include/common.h index 14ba70f..c179d38 100644 --- a/include/common.h +++ b/include/common.h @@ -14,6 +14,14 @@ /* Project Metadata */ #define TNT_VERSION "1.0.1" +/* Public process/exec exit statuses. TNT follows the common sysexits(3) + * convention for usage errors while keeping runtime failures portable. */ +#define TNT_EXIT_OK 0 +#define TNT_EXIT_ERROR 1 +#define TNT_EXIT_USAGE 64 +#define TNT_EXIT_UNAVAILABLE 69 +#define TNT_EXIT_CONFIG 78 + /* Configuration constants */ #define DEFAULT_PORT 2222 #define MAX_MESSAGES 100 @@ -21,7 +29,8 @@ #define MAX_MESSAGE_LEN 1024 #define MAX_EXEC_COMMAND_LEN 1024 #define MAX_COMMAND_OUTPUT_LEN 8192 -#define MAX_CLIENTS 64 +#define DEFAULT_MAX_CLIENTS 64 +#define MAX_CONFIGURED_CLIENTS 1024 #define LOG_FILE "messages.log" #define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */ #define HOST_KEY_FILE "host_key" diff --git a/include/exec.h b/include/exec.h index 341f895..93fe2c6 100644 --- a/include/exec.h +++ b/include/exec.h @@ -6,9 +6,9 @@ /* Dispatch the non-interactive SSH exec command stored in * client->exec_command. Returns the exit status to send back to the * SSH client: - * 0 = success - * 1 = runtime error (I/O, OOM, persistence failure) - * 64 = usage error (unknown command, bad args) + * TNT_EXIT_OK = success + * TNT_EXIT_ERROR = runtime error (I/O, OOM, persistence failure) + * TNT_EXIT_USAGE = usage error (unknown command, bad args) * * Reads g_room and shared client state. Safe to call once per * exec-mode session before the channel is closed. */ diff --git a/include/i18n.h b/include/i18n.h index 10c7900..a5785af 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -58,6 +58,9 @@ typedef enum { I18N_UNKNOWN_GUIDANCE, I18N_EXEC_POST_EMPTY, I18N_EXEC_POST_INVALID_UTF8, + I18N_EXEC_POST_TOO_LONG, + I18N_EXEC_POST_PERSIST_FAILED, + I18N_EXEC_COMMAND_TOO_LONG, I18N_EXEC_UNKNOWN_COMMAND_FORMAT, I18N_TEXT_COUNT } i18n_text_id_t; diff --git a/include/ssh_server.h b/include/ssh_server.h index cab8fa1..7fd2df4 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -44,14 +44,16 @@ typedef struct client { int command_output_scroll; bool show_motd; /* command_output holds MOTD text */ char exec_command[MAX_EXEC_COMMAND_LEN]; + bool exec_command_too_long; char ssh_login[MAX_USERNAME_LEN]; time_t connect_time; time_t last_active; atomic_bool redraw_pending; + _Atomic int pending_bells; /* Bell nudges for this client's loop */ _Atomic int unread_mentions; /* @-mentions received since last reset */ _Atomic int unread_whispers; /* whispers received since last :inbox view */ - /* Per-client whisper inbox. Pushes serialise on io_lock; readers are - * the client's own thread inside :inbox handling. */ + /* Per-client whisper inbox. Protected separately from SSH channel I/O + * so slow writes do not block in-memory private-message delivery. */ whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; int whisper_inbox_count; bool mute_joins; @@ -60,6 +62,7 @@ typedef struct client { int ref_count; /* Reference count for safe cleanup */ pthread_mutex_t ref_lock; /* Lock for ref_count */ pthread_mutex_t io_lock; /* Serialize SSH channel writes */ + pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */ struct ssh_channel_callbacks_struct *channel_cb; } client_t; diff --git a/install.sh b/install.sh index 144b5c7..dacf318 100755 --- a/install.sh +++ b/install.sh @@ -45,7 +45,8 @@ case "$ARCH" in *) fail "Unsupported architecture: $ARCH" ;; esac -BINARY="tnt-${OS}-${ARCH}" +SERVER_BINARY="tnt-${OS}-${ARCH}" +CTL_BINARY="tntctl-${OS}-${ARCH}" echo "=== TNT Installer ===" echo "OS: $OS" @@ -65,51 +66,81 @@ fi echo "Installing version: $VERSION" # Download -URL="https://github.com/$REPO/releases/download/$VERSION/$BINARY" +SERVER_URL="https://github.com/$REPO/releases/download/$VERSION/$SERVER_BINARY" +CTL_URL="https://github.com/$REPO/releases/download/$VERSION/$CTL_BINARY" CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt" -echo "Downloading from: $URL" +echo "Downloading from: $SERVER_URL" -TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX") +SERVER_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX") +CTL_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tntctl.XXXXXX") CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX") +INSTALL_CTL=0 cleanup() { - rm -f "$TMP_FILE" "$CHECKSUM_FILE" + rm -f "$SERVER_TMP_FILE" "$CTL_TMP_FILE" "$CHECKSUM_FILE" } trap cleanup EXIT INT TERM -curl -fsSL -o "$TMP_FILE" "$URL" || fail "Failed to download $BINARY" +curl -fsSL -o "$SERVER_TMP_FILE" "$SERVER_URL" || + fail "Failed to download $SERVER_BINARY" echo "Downloading checksums from: $CHECKSUM_URL" curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" || fail "Failed to download checksums.txt" -EXPECTED_SHA=$(awk -v name="$BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE") -[ -n "$EXPECTED_SHA" ] || fail "No checksum entry found for $BINARY" +EXPECTED_SERVER_SHA=$(awk -v name="$SERVER_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE") +[ -n "$EXPECTED_SERVER_SHA" ] || fail "No checksum entry found for $SERVER_BINARY" +EXPECTED_CTL_SHA=$(awk -v name="$CTL_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE") -ACTUAL_SHA=$(sha256_of "$TMP_FILE") || +ACTUAL_SERVER_SHA=$(sha256_of "$SERVER_TMP_FILE") || fail "sha256sum or shasum is required for checksum verification" -[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || - fail "Checksum mismatch for $BINARY" +[ "$ACTUAL_SERVER_SHA" = "$EXPECTED_SERVER_SHA" ] || + fail "Checksum mismatch for $SERVER_BINARY" -echo "Checksum verified: $ACTUAL_SHA" +echo "Checksum verified: $SERVER_BINARY $ACTUAL_SERVER_SHA" +if [ -n "$EXPECTED_CTL_SHA" ]; then + echo "Downloading from: $CTL_URL" + curl -fsSL -o "$CTL_TMP_FILE" "$CTL_URL" || + fail "Failed to download $CTL_BINARY" + ACTUAL_CTL_SHA=$(sha256_of "$CTL_TMP_FILE") || + fail "sha256sum or shasum is required for checksum verification" + [ "$ACTUAL_CTL_SHA" = "$EXPECTED_CTL_SHA" ] || + fail "Checksum mismatch for $CTL_BINARY" + echo "Checksum verified: $CTL_BINARY $ACTUAL_CTL_SHA" + INSTALL_CTL=1 +else + echo "No checksum entry found for $CTL_BINARY; skipping tntctl for this release" +fi # Install -chmod +x "$TMP_FILE" +chmod +x "$SERVER_TMP_FILE" +[ "$INSTALL_CTL" -eq 0 ] || chmod +x "$CTL_TMP_FILE" if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then - install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt" + install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt" + [ "$INSTALL_CTL" -eq 0 ] || install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl" else echo "Need sudo for installation to $INSTALL_DIR" need_cmd sudo sudo mkdir -p "$INSTALL_DIR" - sudo install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt" + sudo install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt" + [ "$INSTALL_CTL" -eq 0 ] || sudo install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl" fi echo "" -echo "TNT installed successfully to $INSTALL_DIR/tnt" +if [ "$INSTALL_CTL" -eq 1 ]; then + echo "TNT installed successfully to $INSTALL_DIR/tnt and $INSTALL_DIR/tntctl" +else + echo "TNT installed successfully to $INSTALL_DIR/tnt" +fi echo "" echo "Run with:" echo " tnt" echo "" echo "Or specify port:" echo " PORT=3333 tnt" +if [ "$INSTALL_CTL" -eq 1 ]; then + echo "" + echo "Control a server with:" + echo " tntctl localhost health" +fi diff --git a/packaging/README.md b/packaging/README.md index e0d2a01..d980509 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -10,6 +10,9 @@ any public registry. - `homebrew/` - Homebrew tap formula draft and maintainer notes. - `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata. +Package installs include both `tnt` and `tntctl`. `tnt` is the server process; +`tntctl` is a thin wrapper around the documented SSH exec interface. + ## Release checklist 1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match. diff --git a/packaging/debian/README.md b/packaging/debian/README.md index 5fb8fc5..377cc30 100644 --- a/packaging/debian/README.md +++ b/packaging/debian/README.md @@ -44,6 +44,6 @@ debuild -S ## Package shape - Binary package name: `tnt-chat` -- Installed command: `/usr/bin/tnt` +- Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl` - Runtime dependency: `libssh` - Optional systemd unit: `/usr/lib/systemd/system/tnt.service` diff --git a/packaging/homebrew/tnt-chat.rb b/packaging/homebrew/tnt-chat.rb index 0628174..9611df5 100644 --- a/packaging/homebrew/tnt-chat.rb +++ b/packaging/homebrew/tnt-chat.rb @@ -12,10 +12,13 @@ class TntChat < Formula system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}" bin.install "#{buildpath}/stage#{prefix}/bin/tnt" + bin.install "#{buildpath}/stage#{prefix}/bin/tntctl" man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1" + man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tntctl.1" end test do assert_match version.to_s, shell_output("#{bin}/tnt --version") + assert_match version.to_s, shell_output("#{bin}/tntctl --version") end end diff --git a/scripts/release_check.sh b/scripts/release_check.sh index a5ca527..937078f 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -22,7 +22,9 @@ Environment: RUN_INTEGRATION=1 also run full make test PORT=12720 base port for integration tests -Strict checks additionally require real package checksums and a local vX.Y.Z tag. +Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a +matching changelog release section, real package checksums, and non-placeholder +maintainer metadata, then build from the tagged source archive. USAGE } @@ -62,6 +64,8 @@ version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h) step "checking version metadata for $version" grep -q "\"TNT $version\"" tnt.1 || fail "tnt.1 does not mention TNT $version" +grep -q "\"TNT $version\"" tntctl.1 || + fail "tntctl.1 does not mention TNT $version" grep -q "^pkgver=$version$" packaging/arch/PKGBUILD || fail "packaging/arch/PKGBUILD pkgver does not match $version" grep -q "pkgver = $version" packaging/arch/.SRCINFO || @@ -88,11 +92,25 @@ make actual_version=$(./tnt --version) [ "$actual_version" = "tnt $version" ] || fail "binary version mismatch: expected 'tnt $version', got '$actual_version'" +tntctl_version=$(./tntctl --version) +[ "$tntctl_version" = "tntctl $version" ] || + fail "control binary version mismatch: expected 'tntctl $version', got '$tntctl_version'" step "running unit tests" make -C tests/unit clean make -C tests/unit run +step "checking client I/O ownership boundaries" +! grep -R "client_send(target" src include >/dev/null || + fail "cross-client target writes must be queued through client_queue_bell" +! grep -R "client_send(targets" src include >/dev/null || + fail "cross-client target-array writes must be queued through client_queue_bell" +! grep -n "pthread_mutex_lock(&.*->io_lock)" src/commands.c >/dev/null || + fail "commands.c must not use SSH io_lock for in-memory command state" +if grep -R "ssh_channel_write" src include | grep -v "^src/client.c:" >/dev/null; then + fail "raw SSH channel writes must stay inside src/client.c" +fi + if [ "${RUN_INTEGRATION:-0}" = "1" ]; then step "running full integration tests" make test PORT="${PORT:-12720}" @@ -109,9 +127,13 @@ make DESTDIR="$tmpdir" PREFIX=/usr install make DESTDIR="$tmpdir" PREFIX=/usr install-systemd [ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt" +[ -x "$tmpdir/usr/bin/tntctl" ] || fail "missing executable: /usr/bin/tntctl" [ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1" +[ -f "$tmpdir/usr/share/man/man1/tntctl.1" ] || fail "missing manpage: /usr/share/man/man1/tntctl.1" [ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] || fail "missing systemd unit: /usr/lib/systemd/system/tnt.service" +grep -q "^ExecStart=/usr/bin/tnt$" "$tmpdir/usr/lib/systemd/system/tnt.service" || + fail "systemd unit ExecStart does not match PREFIX=/usr install path" step "checking installer syntax" sh -n install.sh @@ -137,14 +159,61 @@ fi if [ "$STRICT" -eq 1 ]; then step "checking strict release gates" + [ -z "$(git status --short)" ] || + fail "working tree must be clean for strict release" + git rev-parse -q --verify "refs/tags/v$version" >/dev/null || + fail "missing local tag v$version" + [ "$(git rev-parse "refs/tags/v$version^{}")" = "$(git rev-parse HEAD)" ] || + fail "local tag v$version does not point at HEAD" + grep -q "^## $version " docs/CHANGELOG.md || + fail "docs/CHANGELOG.md does not contain a release section for $version" ! grep -q "sha256sums=('SKIP')" packaging/arch/PKGBUILD || fail "replace PKGBUILD sha256sums before strict release" ! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO || fail "replace .SRCINFO sha256sums before strict release" ! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb || fail "replace Homebrew sha256 before strict release" - git rev-parse -q --verify "refs/tags/v$version" >/dev/null || - fail "missing local tag v$version" + ! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null || + fail "replace maintainer email placeholders before strict release" + + step "checking tagged source archive" + archive="$tmpdir/tnt-$version-source.tar.gz" + archive_extract="$tmpdir/source" + archive_install="$tmpdir/source-install" + archive_root="$archive_extract/TNT-$version" + + git archive --format=tar.gz --prefix="TNT-$version/" \ + -o "$archive" "refs/tags/v$version" + mkdir -p "$archive_extract" + tar -xzf "$archive" -C "$archive_extract" + + [ -f "$archive_root/src/tntctl.c" ] || + fail "tagged source archive is missing src/tntctl.c" + [ -f "$archive_root/tnt.1" ] || + fail "tagged source archive is missing tnt.1" + [ -f "$archive_root/tntctl.1" ] || + fail "tagged source archive is missing tntctl.1" + [ -f "$archive_root/LICENSE" ] || + fail "tagged source archive is missing LICENSE" + + ( + cd "$archive_root" + make + make DESTDIR="$archive_install" PREFIX=/usr install + make DESTDIR="$archive_install" PREFIX=/usr install-systemd + ) + + [ -x "$archive_install/usr/bin/tnt" ] || + fail "tagged source install is missing /usr/bin/tnt" + [ -x "$archive_install/usr/bin/tntctl" ] || + fail "tagged source install is missing /usr/bin/tntctl" + [ -f "$archive_install/usr/share/man/man1/tnt.1" ] || + fail "tagged source install is missing tnt.1" + [ -f "$archive_install/usr/share/man/man1/tntctl.1" ] || + fail "tagged source install is missing tntctl.1" + grep -q "^ExecStart=/usr/bin/tnt$" \ + "$archive_install/usr/lib/systemd/system/tnt.service" || + fail "tagged source systemd unit ExecStart does not match /usr/bin/tnt" fi step "release preflight passed" diff --git a/src/bootstrap.c b/src/bootstrap.c index 2d77284..b08d84c 100644 --- a/src/bootstrap.c +++ b/src/bootstrap.c @@ -25,6 +25,7 @@ typedef struct { int pty_width; int pty_height; char exec_command[MAX_EXEC_COMMAND_LEN]; + bool exec_command_too_long; bool auth_success; int auth_attempts; bool channel_ready; /* Set when shell/exec request received */ @@ -294,8 +295,13 @@ static int channel_exec_request(ssh_session session, ssh_channel channel, /* Store exec command */ if (command) { - strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1); - ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0'; + if (strlen(command) >= sizeof(ctx->exec_command)) { + ctx->exec_command_too_long = true; + ctx->exec_command[0] = '\0'; + } else { + strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1); + ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0'; + } } /* Mark channel as ready */ @@ -363,6 +369,7 @@ void *bootstrap_run(void *arg) { ctx->pty_width = 80; ctx->pty_height = 24; ctx->exec_command[0] = '\0'; + ctx->exec_command_too_long = false; ctx->requested_user[0] = '\0'; ctx->auth_success = false; ctx->auth_attempts = 0; @@ -451,6 +458,7 @@ void *bootstrap_run(void *arg) { client->ref_count = 1; pthread_mutex_init(&client->ref_lock, NULL); pthread_mutex_init(&client->io_lock, NULL); + pthread_mutex_init(&client->whisper_lock, NULL); if (ctx->requested_user[0] != '\0') { strncpy(client->ssh_login, ctx->requested_user, @@ -466,6 +474,7 @@ void *bootstrap_run(void *arg) { sizeof(client->exec_command) - 1); client->exec_command[sizeof(client->exec_command) - 1] = '\0'; } + client->exec_command_too_long = ctx->exec_command_too_long; /* Add a ref for the channel callbacks (eof/close/window_change) so the * client_t outlives any in-flight callback invocation. */ diff --git a/src/chat_room.c b/src/chat_room.c index ebaea79..ccddeea 100644 --- a/src/chat_room.c +++ b/src/chat_room.c @@ -4,19 +4,8 @@ chat_room_t *g_room = NULL; static int room_capacity_from_env(void) { - const char *env = getenv("TNT_MAX_CONNECTIONS"); - - if (!env || env[0] == '\0') { - return MAX_CLIENTS; - } - - char *end; - long capacity = strtol(env, &end, 10); - if (*end != '\0' || capacity < 1 || capacity > 1024) { - return MAX_CLIENTS; - } - - return (int)capacity; + return env_int("TNT_MAX_CONNECTIONS", DEFAULT_MAX_CLIENTS, 1, + MAX_CONFIGURED_CLIENTS); } /* Initialize chat room */ diff --git a/src/client.c b/src/client.c index c219d0d..830b89c 100644 --- a/src/client.c +++ b/src/client.c @@ -9,6 +9,13 @@ #include #include +static int client_send_fail(client_t *client) { + if (client) { + client->connected = false; + } + return -1; +} + /* Send data to client via SSH channel */ int client_send(client_t *client, const char *data, size_t len) { size_t total = 0; @@ -24,11 +31,21 @@ int client_send(client_t *client, const char *data, size_t len) { while (total < len) { size_t remaining = len - total; + uint32_t window = ssh_channel_window_size(client->channel); + if (window == 0) { + pthread_mutex_unlock(&client->io_lock); + return client_send_fail(client); + } + uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining; + if (chunk > window) { + chunk = window; + } + int sent = ssh_channel_write(client->channel, data + total, chunk); if (sent <= 0) { pthread_mutex_unlock(&client->io_lock); - return -1; + return client_send_fail(client); } total += (size_t)sent; } @@ -41,6 +58,23 @@ int client_send(client_t *client, const char *data, size_t len) { return 0; } +void client_queue_bell(client_t *client) { + if (!client) return; + + atomic_store(&client->pending_bells, 1); + client->redraw_pending = true; +} + +int client_flush_pending_bells(client_t *client) { + if (!client) return 0; + + if (atomic_exchange(&client->pending_bells, 0) <= 0) { + return 0; + } + + return client_send(client, "\a", 1); +} + void client_addref(client_t *client) { if (!client) return; pthread_mutex_lock(&client->ref_lock); @@ -75,6 +109,7 @@ void client_release(client_t *client) { free(client->channel_cb); } pthread_mutex_destroy(&client->io_lock); + pthread_mutex_destroy(&client->whisper_lock); pthread_mutex_destroy(&client->ref_lock); free(client); } diff --git a/src/commands.c b/src/commands.c index 035e518..0ea7527 100644 --- a/src/commands.c +++ b/src/commands.c @@ -199,9 +199,9 @@ void commands_dispatch(client_t *client) { pthread_rwlock_unlock(&g_room->lock); if (target) { - /* Push into recipient's inbox. io_lock serialises so two - * senders to the same recipient don't tear the ring. */ - pthread_mutex_lock(&target->io_lock); + /* Push into recipient's inbox. whisper_lock serialises so + * two senders to the same recipient don't tear the ring. */ + pthread_mutex_lock(&target->whisper_lock); int slot; if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) { slot = target->whisper_inbox_count++; @@ -219,13 +219,12 @@ void commands_dispatch(client_t *client) { snprintf(target->whisper_inbox[slot].content, sizeof(target->whisper_inbox[slot].content), "%s", rest); - pthread_mutex_unlock(&target->io_lock); + pthread_mutex_unlock(&target->whisper_lock); target->unread_whispers++; - target->redraw_pending = true; /* Audible nudge — the title bar ✉ counter (UX-11 style) * carries the persistent signal. */ - client_send(target, "\a", 1); + client_queue_bell(target); client_release(target); } @@ -243,15 +242,15 @@ void commands_dispatch(client_t *client) { } } else if (command_id == TNT_COMMAND_INBOX) { - /* Snapshot the inbox under io_lock so a concurrent sender doesn't + /* Snapshot the inbox under whisper_lock so a concurrent sender doesn't * tear what we're rendering. Counter reset happens after copy. */ whisper_t snapshot[WHISPER_INBOX_SIZE]; int snap_count; - pthread_mutex_lock(&client->io_lock); + pthread_mutex_lock(&client->whisper_lock); snap_count = client->whisper_inbox_count; memcpy(snapshot, client->whisper_inbox, snap_count * sizeof(whisper_t)); - pthread_mutex_unlock(&client->io_lock); + pthread_mutex_unlock(&client->whisper_lock); client->unread_whispers = 0; buffer_appendf(output, sizeof(output), &pos, diff --git a/src/exec.c b/src/exec.c index 0824510..524d70c 100644 --- a/src/exec.c +++ b/src/exec.c @@ -123,7 +123,8 @@ static int exec_command_help(client_t *client) { help_text[0] = '\0'; exec_catalog_append_help(help_text, sizeof(help_text), &pos, client->ui_lang); - return client_send(client, help_text, pos) == 0 ? 0 : 1; + return client_send(client, help_text, pos) == 0 ? TNT_EXIT_OK + : TNT_EXIT_ERROR; } static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) { @@ -134,12 +135,13 @@ static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) { exec_catalog_append_usage(usage, sizeof(usage), &pos, id, client->ui_lang); client_printf(client, "%s", usage); - return 64; + return TNT_EXIT_USAGE; } static int exec_command_health(client_t *client) { static const char ok[] = "ok\n"; - return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1; + return client_send(client, ok, sizeof(ok) - 1) == 0 ? TNT_EXIT_OK + : TNT_EXIT_ERROR; } static int exec_command_users(client_t *client, bool json) { @@ -157,7 +159,7 @@ static int exec_command_users(client_t *client, bool json) { if (!usernames) { pthread_rwlock_unlock(&g_room->lock); client_printf(client, "users: out of memory\n"); - return 1; + return TNT_EXIT_ERROR; } for (int i = 0; i < count; i++) { @@ -177,7 +179,7 @@ static int exec_command_users(client_t *client, bool json) { if (!output) { free(usernames); client_printf(client, "users: out of memory\n"); - return 1; + return TNT_EXIT_ERROR; } if (json) { @@ -195,7 +197,7 @@ static int exec_command_users(client_t *client, bool json) { } } - rc = client_send(client, output, pos) == 0 ? 0 : 1; + rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR; free(output); free(usernames); return rc; @@ -243,10 +245,11 @@ static int exec_command_stats(client_t *client, bool json) { if (len < 0 || len >= (int)sizeof(buffer)) { client_printf(client, "stats: output overflow\n"); - return 1; + return TNT_EXIT_ERROR; } - return client_send(client, buffer, (size_t)len) == 0 ? 0 : 1; + return client_send(client, buffer, (size_t)len) == 0 ? TNT_EXIT_OK + : TNT_EXIT_ERROR; } static int parse_tail_count(const char *args, int *count) { @@ -316,7 +319,7 @@ static int exec_command_tail(client_t *client, const char *args) { if (!snapshot) { pthread_rwlock_unlock(&g_room->lock); client_printf(client, "tail: out of memory\n"); - return 1; + return TNT_EXIT_ERROR; } memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t)); } @@ -328,7 +331,7 @@ static int exec_command_tail(client_t *client, const char *args) { if (!output) { free(snapshot); client_printf(client, "tail: out of memory\n"); - return 1; + return TNT_EXIT_ERROR; } for (int i = 0; i < count; i++) { @@ -338,7 +341,7 @@ static int exec_command_tail(client_t *client, const char *args) { timestamp, snapshot[i].username, snapshot[i].content); } - rc = client_send(client, output, pos) == 0 ? 0 : 1; + rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR; free(output); free(snapshot); return rc; @@ -355,6 +358,12 @@ static int exec_command_post(client_t *client, const char *args) { return exec_command_usage(client, TNT_EXEC_COMMAND_POST); } + if (strlen(args) >= sizeof(content)) { + client_printf(client, "%s", + i18n_text(client->ui_lang, I18N_EXEC_POST_TOO_LONG)); + return TNT_EXIT_USAGE; + } + strncpy(content, args, sizeof(content) - 1); content[sizeof(content) - 1] = '\0'; trim_ascii_whitespace(content); @@ -362,14 +371,14 @@ static int exec_command_post(client_t *client, const char *args) { if (content[0] == '\0') { client_printf(client, "%s", i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY)); - return 64; + return TNT_EXIT_USAGE; } if (!utf8_is_valid_string(content)) { client_printf(client, "%s", i18n_text(client->ui_lang, I18N_EXEC_POST_INVALID_UTF8)); - return 1; + return TNT_EXIT_ERROR; } resolve_exec_username(client, username, sizeof(username)); @@ -388,18 +397,22 @@ static int exec_command_post(client_t *client, const char *args) { msg.content[sizeof(msg.content) - 1] = '\0'; } - room_broadcast(g_room, &msg); - if (client_send(client, "posted\n", 7) != 0) { - return 1; - } - - notify_mentions(msg.content, client); if (message_save(&msg) < 0) { fprintf(stderr, "post: failed to persist message\n"); - return 1; + client_printf(client, "%s", + i18n_text(client->ui_lang, + I18N_EXEC_POST_PERSIST_FAILED)); + return TNT_EXIT_ERROR; } - return 0; + room_broadcast(g_room, &msg); + notify_mentions(msg.content, client); + + if (client_send(client, "posted\n", 7) != 0) { + return TNT_EXIT_ERROR; + } + + return TNT_EXIT_OK; } int exec_dispatch(client_t *client) { @@ -407,6 +420,13 @@ int exec_dispatch(client_t *client) { tnt_exec_command_id_t command_id; const char *args = NULL; + if (client->exec_command_too_long) { + client_printf(client, "%s", + i18n_text(client->ui_lang, + I18N_EXEC_COMMAND_TOO_LONG)); + return TNT_EXIT_USAGE; + } + strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1); command_copy[sizeof(command_copy) - 1] = '\0'; trim_ascii_whitespace(command_copy); @@ -434,7 +454,7 @@ int exec_dispatch(client_t *client) { case TNT_EXEC_COMMAND_POST: return exec_command_post(client, args); case TNT_EXEC_COMMAND_EXIT: - return 0; + return TNT_EXIT_OK; } } @@ -448,5 +468,5 @@ int exec_dispatch(client_t *client) { i18n_text(client->ui_lang, I18N_EXEC_UNKNOWN_COMMAND_FORMAT), command_copy); - return 64; + return TNT_EXIT_USAGE; } diff --git a/src/i18n_text.c b/src/i18n_text.c index a457edb..cfb76f4 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -193,6 +193,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "post: invalid UTF-8 input\n", "post: 输入不是有效 UTF-8\n" ), + [I18N_EXEC_POST_TOO_LONG] = I18N_STRING( + "post: message too long\n", + "post: 消息过长\n" + ), + [I18N_EXEC_POST_PERSIST_FAILED] = I18N_STRING( + "post: failed to persist message\n", + "post: 消息持久化失败\n" + ), + [I18N_EXEC_COMMAND_TOO_LONG] = I18N_STRING( + "exec: command too long\n", + "exec: 命令过长\n" + ), [I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING( "Unknown command: %s\n", "未知命令: %s\n" diff --git a/src/input.c b/src/input.c index 9468053..b4c0ab8 100644 --- a/src/input.c +++ b/src/input.c @@ -134,9 +134,17 @@ static int read_username(client_t *client) { void notify_mentions(const char *content, const client_t *sender) { pthread_rwlock_rdlock(&g_room->lock); int count = g_room->client_count; - client_t *targets[MAX_CLIENTS]; + client_t **targets = NULL; int target_count = 0; + if (count > 0) { + targets = calloc((size_t)count, sizeof(*targets)); + if (!targets) { + pthread_rwlock_unlock(&g_room->lock); + return; + } + } + for (int i = 0; i < count; i++) { client_t *c = g_room->clients[i]; if (c == sender) continue; @@ -150,11 +158,11 @@ void notify_mentions(const char *content, const client_t *sender) { pthread_rwlock_unlock(&g_room->lock); for (int i = 0; i < target_count; i++) { - client_send(targets[i], "\a", 1); targets[i]->unread_mentions++; - targets[i]->redraw_pending = true; + client_queue_bell(targets[i]); client_release(targets[i]); } + free(targets); } static int read_channel_exact(client_t *client, char *buf, size_t len, @@ -731,7 +739,7 @@ void input_run_session(client_t *client) { client->last_active = time(NULL); /* Check for exec command */ - if (client->exec_command[0] != '\0') { + if (client->exec_command[0] != '\0' || client->exec_command_too_long) { int exit_status = exec_dispatch(client); ssh_channel_request_send_exit_status(client->channel, exit_status); ssh_channel_send_eof(client->channel); @@ -811,6 +819,10 @@ main_loop: break; } + if (client_flush_pending_bells(client) != 0) { + break; + } + if (current_update_seq != seen_update_seq) { seen_update_seq = current_update_seq; room_updated = true; diff --git a/src/main.c b/src/main.c index 947b1d2..42ed66e 100644 --- a/src/main.c +++ b/src/main.c @@ -41,7 +41,7 @@ int main(int argc, char **argv) { if (*end != '\0' || val <= 0 || val > 65535) { fprintf(stderr, cli_text_invalid_port_format(lang), argv[i + 1]); - return 1; + return TNT_EXIT_USAGE; } port = (int)val; i++; @@ -49,23 +49,23 @@ int main(int argc, char **argv) { strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) { if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) { perror("setenv TNT_STATE_DIR"); - return 1; + return TNT_EXIT_ERROR; } i++; } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) { printf("tnt %s\n", TNT_VERSION); - return 0; + return TNT_EXIT_OK; } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { char output[2048] = {0}; size_t pos = 0; cli_text_append_help(output, sizeof(output), &pos, argv[0], lang); fputs(output, stdout); - return 0; + return TNT_EXIT_OK; } else { fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]); fprintf(stderr, cli_text_short_usage_format(lang), argv[0]); - return 1; + return TNT_EXIT_USAGE; } } @@ -77,7 +77,7 @@ int main(int argc, char **argv) { /* Initialize subsystems */ if (tnt_ensure_state_dir() < 0) { fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir()); - return 1; + return TNT_EXIT_ERROR; } message_init(); @@ -86,14 +86,14 @@ int main(int argc, char **argv) { g_room = room_create(); if (!g_room) { fprintf(stderr, "Failed to create chat room\n"); - return 1; + return TNT_EXIT_ERROR; } /* Initialize server */ if (ssh_server_init(port) < 0) { fprintf(stderr, "Failed to initialize server\n"); room_destroy(g_room); - return 1; + return TNT_EXIT_ERROR; } /* Start server (blocking) */ diff --git a/src/tntctl.c b/src/tntctl.c new file mode 100644 index 0000000..6e10349 --- /dev/null +++ b/src/tntctl.c @@ -0,0 +1,296 @@ +#include "common.h" + +#include +#include +#include +#include +#include + +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; +} diff --git a/tests/test_exec_mode.sh b/tests/test_exec_mode.sh index 8b0e26a..7ce7109 100755 --- a/tests/test_exec_mode.sh +++ b/tests/test_exec_mode.sh @@ -26,6 +26,7 @@ if [ ! -f "$BIN" ]; then fi SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" +TNTCTL_OPTS="--host-key-checking no --known-hosts /dev/null" echo "=== TNT Exec Mode Tests ===" @@ -51,14 +52,16 @@ else FAIL=$((FAIL + 1)) fi -HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null || true) +HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null) +HEALTH_USAGE_STATUS=$? printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$' -if [ $? -eq 0 ]; then - echo "✓ no-arg exec usage follows TNT_LANG" +if [ $? -eq 0 ] && [ "$HEALTH_USAGE_STATUS" -eq 64 ]; then + echo "✓ no-arg exec usage follows TNT_LANG and exits 64" PASS=$((PASS + 1)) else echo "✗ no-arg exec usage output unexpected" printf '%s\n' "$HEALTH_USAGE" + echo "exit status: $HEALTH_USAGE_STATUS" FAIL=$((FAIL + 1)) fi @@ -98,36 +101,42 @@ else FAIL=$((FAIL + 1)) fi -UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null || true) +UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null) +UNKNOWN_STATUS=$? printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$' -if [ $? -eq 0 ]; then - echo "✓ unknown command follows TNT_LANG" +if [ $? -eq 0 ] && [ "$UNKNOWN_STATUS" -eq 64 ]; then + echo "✓ unknown command follows TNT_LANG and exits 64" PASS=$((PASS + 1)) else echo "✗ unknown command output unexpected" printf '%s\n' "$UNKNOWN_OUTPUT" + echo "exit status: $UNKNOWN_STATUS" FAIL=$((FAIL + 1)) fi -POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null || true) +POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null) +POST_USAGE_STATUS=$? printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$' -if [ $? -eq 0 ]; then - echo "✓ post usage follows TNT_LANG" +if [ $? -eq 0 ] && [ "$POST_USAGE_STATUS" -eq 64 ]; then + echo "✓ post usage follows TNT_LANG and exits 64" PASS=$((PASS + 1)) else echo "✗ post usage output unexpected" printf '%s\n' "$POST_USAGE" + echo "exit status: $POST_USAGE_STATUS" FAIL=$((FAIL + 1)) fi -USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null || true) +USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null) +USERS_USAGE_STATUS=$? printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$' -if [ $? -eq 0 ]; then - echo "✓ users usage follows TNT_LANG" +if [ $? -eq 0 ] && [ "$USERS_USAGE_STATUS" -eq 64 ]; then + echo "✓ users usage follows TNT_LANG and exits 64" PASS=$((PASS + 1)) else echo "✗ users usage output unexpected" printf '%s\n' "$USERS_USAGE" + echo "exit status: $USERS_USAGE_STATUS" FAIL=$((FAIL + 1)) fi @@ -152,6 +161,106 @@ else FAIL=$((FAIL + 1)) fi +PERSIST_FAIL_MARKER="persist-fail-marker" +rm -f "$STATE_DIR/messages.log" +mkdir "$STATE_DIR/messages.log" +PERSIST_FAIL_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "$PERSIST_FAIL_MARKER" 2>/dev/null) +PERSIST_FAIL_STATUS=$? +rmdir "$STATE_DIR/messages.log" +printf '%s\n' "$PERSIST_FAIL_OUTPUT" | grep -q 'posted' +PERSIST_FAIL_POSTED=$? +PERSIST_FAIL_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true) +printf '%s\n' "$PERSIST_FAIL_TAIL" | grep -q "$PERSIST_FAIL_MARKER" +PERSIST_FAIL_VISIBLE=$? +if [ "$PERSIST_FAIL_STATUS" -eq 1 ] && + [ "$PERSIST_FAIL_POSTED" -ne 0 ] && + [ "$PERSIST_FAIL_VISIBLE" -ne 0 ]; then + echo "✓ post persistence failure is not broadcast or acknowledged" + PASS=$((PASS + 1)) +else + echo "✗ post persistence failure handling unexpected" + printf '%s\n' "$PERSIST_FAIL_OUTPUT" + printf '%s\n' "$PERSIST_FAIL_TAIL" + echo "exit status: $PERSIST_FAIL_STATUS" + FAIL=$((FAIL + 1)) +fi + +LONG_MARKER="too-long-exec-marker" +LONG_COMMAND=$(printf 'post %s %01020d' "$LONG_MARKER" 0) +LONG_OUTPUT=$(ssh $SSH_OPTS localhost "$LONG_COMMAND" 2>/dev/null) +LONG_STATUS=$? +printf '%s\n' "$LONG_OUTPUT" | grep -q '命令过长' +LONG_ERROR=$? +LONG_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true) +printf '%s\n' "$LONG_TAIL" | grep -q "$LONG_MARKER" +LONG_VISIBLE=$? +if [ "$LONG_STATUS" -eq 64 ] && + [ "$LONG_ERROR" -eq 0 ] && + [ "$LONG_VISIBLE" -ne 0 ]; then + echo "✓ overlong exec command is rejected without truncation" + PASS=$((PASS + 1)) +else + echo "✗ overlong exec command handling unexpected" + printf '%s\n' "$LONG_OUTPUT" + printf '%s\n' "$LONG_TAIL" + echo "exit status: $LONG_STATUS" + FAIL=$((FAIL + 1)) +fi + +TNTCTL_HEALTH=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost health 2>/dev/null || true) +if [ "$TNTCTL_HEALTH" = "ok" ]; then + echo "✓ tntctl health uses exec interface" + PASS=$((PASS + 1)) +else + echo "✗ tntctl health failed: $TNTCTL_HEALTH" + FAIL=$((FAIL + 1)) +fi + +TNTCTL_STATS=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost stats --json 2>/dev/null || true) +printf '%s\n' "$TNTCTL_STATS" | grep -q '"status":"ok"' +if [ $? -eq 0 ]; then + echo "✓ tntctl stats --json returns JSON" + PASS=$((PASS + 1)) +else + echo "✗ tntctl stats --json output unexpected" + printf '%s\n' "$TNTCTL_STATS" + FAIL=$((FAIL + 1)) +fi + +TNTCTL_USERS_USAGE=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost users --xml 2>/dev/null) +TNTCTL_USERS_STATUS=$? +printf '%s\n' "$TNTCTL_USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$' +if [ $? -eq 0 ] && [ "$TNTCTL_USERS_STATUS" -eq 64 ]; then + echo "✓ tntctl preserves remote usage exit 64" + PASS=$((PASS + 1)) +else + echo "✗ tntctl users usage output unexpected" + printf '%s\n' "$TNTCTL_USERS_USAGE" + echo "exit status: $TNTCTL_USERS_STATUS" + FAIL=$((FAIL + 1)) +fi + +TNTCTL_POST=$("../tntctl" -p "$PORT" $TNTCTL_OPTS -l ctlposter localhost post "hello from tntctl" 2>/dev/null || true) +if [ "$TNTCTL_POST" = "posted" ]; then + echo "✓ tntctl post publishes a message" + PASS=$((PASS + 1)) +else + echo "✗ tntctl post failed: $TNTCTL_POST" + FAIL=$((FAIL + 1)) +fi + +TNTCTL_TAIL=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "tail" "-n" "1" 2>/dev/null || true) +printf '%s\n' "$TNTCTL_TAIL" | grep -q 'ctlposter' && +printf '%s\n' "$TNTCTL_TAIL" | grep -q 'hello from tntctl' +if [ $? -eq 0 ]; then + echo "✓ tntctl tail returns recent messages" + PASS=$((PASS + 1)) +else + echo "✗ tntctl tail output unexpected" + printf '%s\n' "$TNTCTL_TAIL" + FAIL=$((FAIL + 1)) +fi + EXPECT_SCRIPT="${STATE_DIR}/watcher.expect" WATCHER_READY="${STATE_DIR}/watcher.ready" cat >"$EXPECT_SCRIPT" </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" <"${STATE_DIR}/private-message.log" 2>&1; then + echo "✓ :msg returns while queuing recipient notification" + PASS=$((PASS + 1)) +else + echo "✗ :msg notification path failed" + sed -n '1,120p' "${STATE_DIR}/private-message.log" + sed -n '1,120p' "${STATE_DIR}/server.log" + FAIL=$((FAIL + 1)) +fi + wait "${INTERACTIVE_PID}" 2>/dev/null || true INTERACTIVE_PID="" diff --git a/tests/test_tntctl_cli.sh b/tests/test_tntctl_cli.sh new file mode 100755 index 0000000..3163f8d --- /dev/null +++ b/tests/test_tntctl_cli.sh @@ -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" diff --git a/tests/unit/test_chat_room.c b/tests/unit/test_chat_room.c index 15187c5..c52377b 100644 --- a/tests/unit/test_chat_room.c +++ b/tests/unit/test_chat_room.c @@ -159,8 +159,9 @@ TEST(room_remove_nonexistent_client) { TEST(room_add_client_full) { chat_room_t *room = room_create(); - client_t clients[MAX_CLIENTS + 1]; - memset(clients, 0, sizeof(clients)); + client_t *clients = calloc((size_t)room->client_capacity + 1, + sizeof(*clients)); + assert(clients != NULL); for (int i = 0; i < room->client_capacity; i++) { assert(room_add_client(room, &clients[i]) == 0); @@ -169,6 +170,23 @@ TEST(room_add_client_full) { assert(room_add_client(room, &clients[room->client_capacity]) == -1); assert(room_get_client_count(room) == room->client_capacity); + free(clients); + room_destroy(room); +} + +TEST(room_capacity_follows_tnt_max_connections) { + setenv("TNT_MAX_CONNECTIONS", "3", 1); + chat_room_t *room = room_create(); + unsetenv("TNT_MAX_CONNECTIONS"); + client_t clients[4]; + memset(clients, 0, sizeof(clients)); + + assert(room->client_capacity == 3); + assert(room_add_client(room, &clients[0]) == 0); + assert(room_add_client(room, &clients[1]) == 0); + assert(room_add_client(room, &clients[2]) == 0); + assert(room_add_client(room, &clients[3]) == -1); + room_destroy(room); } @@ -201,6 +219,7 @@ int main(void) { RUN_TEST(room_client_count); RUN_TEST(room_remove_nonexistent_client); RUN_TEST(room_add_client_full); + RUN_TEST(room_capacity_follows_tnt_max_connections); RUN_TEST(room_message_count_threadsafe); printf("\nAll %d tests passed!\n", tests_passed); diff --git a/tests/unit/test_i18n.c b/tests/unit/test_i18n.c index 67113c8..1279f06 100644 --- a/tests/unit/test_i18n.c +++ b/tests/unit/test_i18n.c @@ -147,6 +147,10 @@ TEST(text_lookup_matches_language) { "message cannot be empty") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY), "消息不能为空") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_COMMAND_TOO_LONG), + "command too long") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_COMMAND_TOO_LONG), + "命令过长") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT), "Unknown command") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT), diff --git a/tnt.1 b/tnt.1 index a030474..357ef2f 100644 --- a/tnt.1 +++ b/tnt.1 @@ -144,6 +144,30 @@ ssh host \-p 2222 health Exit codes follow .BR sysexits (3) conventions. +.SH EXIT STATUS +.TP +.B 0 +Success. +.TP +.B 1 +Runtime error, such as I/O failure, allocation failure, or persistence failure. +.TP +.B 64 +Usage error, such as an unknown command, invalid option, or invalid argument +shape. +.TP +.B 69 +Reserved for the local +.BR tntctl (1) +wrapper when SSH transport is unavailable. +.TP +.B 78 +Reserved for future local +.BR tntctl (1) +configuration errors. +.PP +The SSH exec JSON field contract is documented in +.IR docs/INTERFACE.md . .SH ENVIRONMENT .TP .B PORT diff --git a/tntctl.1 b/tntctl.1 new file mode 100644 index 0000000..97a3c63 --- /dev/null +++ b/tntctl.1 @@ -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)