From 33e2dc4f13987215de4bd33b2bfb957bc1cf5a84 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 26 May 2026 09:42:14 +0800 Subject: [PATCH 01/31] Build public release readiness foundation --- .github/ISSUE_TEMPLATE/bug_report.yml | 64 +++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 37 +++ .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 36 ++- .gitignore | 1 + Makefile | 32 ++- README.md | 19 +- SECURITY.md | 61 +++++ docs/CHANGELOG.md | 41 +++ docs/CICD.md | 64 ++++- docs/CONTRIBUTING.md | 3 +- docs/INTERFACE.md | 150 +++++++++++ docs/ROADMAP.md | 39 ++- include/client.h | 8 + include/commands.h | 5 +- include/common.h | 11 +- include/exec.h | 6 +- include/i18n.h | 3 + include/ssh_server.h | 7 +- install.sh | 63 +++-- packaging/README.md | 3 + packaging/debian/README.md | 2 +- packaging/homebrew/tnt-chat.rb | 3 + scripts/release_check.sh | 75 +++++- src/bootstrap.c | 13 +- src/chat_room.c | 15 +- src/client.c | 37 ++- src/commands.c | 17 +- src/exec.c | 66 +++-- src/i18n_text.c | 12 + src/input.c | 20 +- src/main.c | 16 +- src/tntctl.c | 296 +++++++++++++++++++++ tests/test_exec_mode.sh | 174 +++++++++++- tests/test_tntctl_cli.sh | 133 +++++++++ tests/unit/test_chat_room.c | 23 +- tests/unit/test_i18n.c | 4 + tnt.1 | 24 ++ tntctl.1 | 119 +++++++++ 40 files changed, 1570 insertions(+), 140 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 SECURITY.md create mode 100644 docs/INTERFACE.md create mode 100644 src/tntctl.c create mode 100755 tests/test_tntctl_cli.sh create mode 100644 tntctl.1 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) From d3002dbfdeb85e2c5bf258a3a58bc62ab45390f0 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 26 May 2026 11:15:55 +0800 Subject: [PATCH 02/31] Deepen TUI lifecycle and runtime readiness --- .gitignore | 3 + Makefile | 11 +- README.md | 19 +++ demos/tnt-lifecycle.tape | 59 ++++++++ docs/CHANGELOG.md | 13 ++ docs/ROADMAP.md | 22 +-- docs/USER_LIFECYCLE.md | 49 +++++++ include/cli_text.h | 1 + include/client.h | 15 +- include/common.h | 2 + include/ssh_server.h | 4 + scripts/release_check.sh | 7 + src/cli_text.c | 42 ++++-- src/client.c | 177 +++++++++++++++++++--- src/input.c | 8 + src/main.c | 139 +++++++++++++++++- tests/test_soak.sh | 226 ++++++++++++++++++++++++++++ tests/test_user_lifecycle.sh | 275 +++++++++++++++++++++++++++++++++++ tests/unit/test_cli_text.c | 12 +- tnt.1 | 80 ++++++++++ 20 files changed, 1108 insertions(+), 56 deletions(-) create mode 100644 demos/tnt-lifecycle.tape create mode 100644 docs/USER_LIFECYCLE.md create mode 100755 tests/test_soak.sh create mode 100755 tests/test_user_lifecycle.sh diff --git a/.gitignore b/.gitignore index 3330c0f..e4ea543 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ host_key.pub .DS_Store test.log *.dSYM/ +demos/*.gif +demos/*.mp4 +demos/*.webm tests/unit/test_utf8 tests/unit/test_message tests/unit/test_chat_room diff --git a/Makefile b/Makefile index 7a010dc..52f4a88 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ MANDIR ?= $(PREFIX)/share/man SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) -.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info +.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test user-lifecycle-test info all: $(TARGETS) @@ -125,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 && PORT=$$(($${PORT:-2222} + 3)) ./test_user_lifecycle.sh @cd tests && ./test_tntctl_cli.sh anonymous-access-test: all @@ -143,6 +144,14 @@ stress-test: all @echo "Running stress tests..." @cd tests && PORT=$${PORT:-2222} ./test_stress.sh $${CLIENTS:-10} $${DURATION:-30} +soak-test: all + @echo "Running soak tests..." + @cd tests && PORT=$${PORT:-2222} ./test_soak.sh $${DURATION:-8} $${RECONNECTS:-5} + +user-lifecycle-test: all + @echo "Running user lifecycle tests..." + @cd tests && PORT=$${PORT:-2222} ./test_user_lifecycle.sh + ci-test: @$(MAKE) test PORT=$(CI_TEST_PORT) @$(MAKE) anonymous-access-test PORT=$$(($(CI_TEST_PORT) + 5)) diff --git a/README.md b/README.md index 893d5dc..f31b8aa 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,21 @@ TNT_PUBLIC_HOST=chat.example.com tnt TNT_LANG=zh tnt ``` +The same operational settings can be passed explicitly, which is often +clearer in package scripts and one-off test deployments: + +```sh +tnt \ + --bind 127.0.0.1 \ + --public-host chat.example.com \ + --max-connections 100 \ + --max-conn-per-ip 10 \ + --max-conn-rate-per-ip 30 \ + --idle-timeout 3600 \ + -p 2222 \ + -d /var/lib/tnt +``` + **Rate limiting:** ```sh # Max total connections (default 64) @@ -218,6 +233,8 @@ make anonymous-access-test # verify default anonymous login behavior make connection-limit-test # verify per-IP concurrency and rate limits make security-test # run security feature checks make stress-test # run configurable concurrent-client stress test +make soak-test # run idle/reconnect/control-plane soak test +make user-lifecycle-test # run a two-user TUI lifecycle test make ci-test # run the same checks as GitHub Actions # Individual tests @@ -227,6 +244,8 @@ cd tests ./test_anonymous_access.sh # anonymous access ./test_connection_limits.sh # per-IP concurrency and rate limits ./test_stress.sh # stress test +./test_soak.sh # soak test +./test_user_lifecycle.sh # two-user TUI lifecycle ``` **Test coverage:** diff --git a/demos/tnt-lifecycle.tape b/demos/tnt-lifecycle.tape new file mode 100644 index 0000000..042f90e --- /dev/null +++ b/demos/tnt-lifecycle.tape @@ -0,0 +1,59 @@ +# TNT lifecycle demo. +# +# Run from the repository root after building: +# +# make +# vhs demos/tnt-lifecycle.tape +# +# The generated GIF is intentionally ignored by git; commit the tape, not the +# rendered artifact. + +Output demos/tnt-lifecycle.gif + +Require ssh + +Set Shell "bash" +Set FontSize 28 +Set Width 1200 +Set Height 720 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 35ms +Set Padding 16 +Set WindowBar Colorful + +Hide +Type "STATE_DIR=$(mktemp -d /tmp/tnt-vhs.XXXXXX); PORT=22333; TNT_LANG=en ./tnt --bind 127.0.0.1 --public-host demo.local --rate-limit 0 --idle-timeout 0 -p $PORT -d $STATE_DIR >/tmp/tnt-vhs.log 2>&1 & TNT_PID=$!; sleep 1; clear" Enter +Show + +Type "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT demo@127.0.0.1" Enter +Sleep 1s +Type "demo" Enter +Sleep 1s +Type "hello from TNT" Enter +Sleep 800ms +Escape +Sleep 500ms +Type ":help" Enter +Sleep 2s +Type "q" +Sleep 600ms +Type ":last 5" Enter +Sleep 2s +Type "q" +Sleep 600ms +Type ":search TNT" Enter +Sleep 2s +Type "q" +Sleep 600ms +Type "i" +Sleep 300ms +Type "/me ships terminal chat over SSH" Enter +Sleep 2s +Ctrl+C +Sleep 300ms +Ctrl+C +Sleep 1s + +Hide +Type "kill $TNT_PID >/dev/null 2>&1; rm -rf $STATE_DIR; clear" Enter +Show diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index be83c75..d8adcf5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,14 @@ templates for bug reports and feature requests. - Added `tntctl`, a thin local wrapper around the documented SSH exec interface for health, stats, users, tail, post, help, and exit commands. +- Added explicit server configuration flags for bind address, public host, + connection limits, rate limiting, idle timeout, and SSH log verbosity. +- Added a configurable soak test that keeps an interactive session open while + repeatedly checking health, stats, users, reconnects, and post/tail behavior. +- Added a two-user TUI lifecycle regression test and user-lifecycle notes for + the main onboarding, chat, help, history, search, private-message, nickname, + action-message, and exit paths. +- Added a VHS tape draft for recording the core TNT terminal-chat experience. ### Changed - `make install-systemd` now rewrites the installed unit's `ExecStart` to match @@ -29,11 +37,16 @@ - Mention and private-message bell notifications are now queued on the target client and flushed by that client's own session loop, so slow SSH writes do not block the sender's message path. +- Interactive client writes now pass through a bounded per-client outbox and + flush against the remote SSH window from that client's session loop. Exec + sessions still write synchronously to preserve script output ordering. - Private-message inbox access now uses its own mutex instead of sharing the SSH channel write lock, reducing unrelated contention on slow clients. - Client writes now check the SSH channel's remote window before writing and mark the client disconnected when the window is closed, avoiding the most direct slow-reader blocking path. +- `make release-check` can now run the soak test with `RUN_SOAK=1`, keeping + longer runtime checks opt-in for local release validation. - 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fa96614..dcc7fd9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,7 +30,8 @@ Goal: make TNT predictable for operators, scripts, and package maintainers. - ✅ 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-connections`, and related long options consistently - ✅ add man pages for `tnt` and `tntctl` ## Stage 2: Runtime Model @@ -42,8 +43,8 @@ Goal: make long-running operation boring and reliable. 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 +- ✅ add bounded outbound queues so closed SSH windows cannot immediately stall + interactive output writes - separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly - make room/client capacity fully runtime-configurable with no hidden compile-time ceiling - document hard guarantees and soft limits @@ -91,7 +92,10 @@ Goal: make regressions harder to introduce. - expand CI coverage across Linux and macOS for build and smoke tests - add sanitizer jobs and targeted fuzzing for UTF-8, log parsing, and command parsing -- add soak tests for long-lived sessions and slow-client behavior +- ✅ add a configurable soak test for idle sessions, reconnects, and control + interface availability +- add deeper slow-client soak coverage with a deliberately backpressured SSH + client - keep deployment and test docs aligned with actual runtime behavior - require every user-visible interface change to update docs and tests in the same change set @@ -101,10 +105,8 @@ These are the next changes that should happen before new feature work expands th 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 +2. Finish untangling client-state ownership into a clearer release path. +3. Add deeper slow-client soak coverage with a deliberately backpressured SSH + client. +4. Replace remaining release placeholders with real maintainer metadata and source-archive checksums when cutting a public package release. diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md new file mode 100644 index 0000000..878bb15 --- /dev/null +++ b/docs/USER_LIFECYCLE.md @@ -0,0 +1,49 @@ +# User Lifecycle + +TNT solves one narrow problem: create a keyboard-first chat room that anyone +with an SSH client can join without installing a custom client. + +The product path should stay short: + +1. Operator installs `tnt`, chooses a state directory, and starts the server. +2. User connects with `ssh -p 2222 host`. +3. User picks a display name or presses Enter for `anonymous`. +4. User lands in INSERT mode at the live tail and can type immediately. +5. User presses Esc to browse history with Vim-style movement. +6. User uses `:help` for the concise manual or `?` for the full key reference. +7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`, + `:search`, `:nick`, `:mute-joins`, and `:q`. +8. Scripts and operators use `tntctl` or SSH exec commands for `health`, + `stats`, `users`, `tail`, and `post`. + +## TUI Experience Notes + +- The first screen should make the product legible without reading external + docs: this is an SSH chat room, not a shell. +- INSERT mode is the default because most users arrive to send a message. +- NORMAL mode opens at the latest messages, not the oldest history. Users can + move upward for older context and use `G` or End to return to live chat. +- `:help` is a compact manual, while `?` is a full key reference. Do not add + parallel support commands for the same task. +- Command syntax stays ASCII even in localized UI text. Translations explain; + they do not change the command language. +- Private messages are visible only in the recipient inbox and are not written + to `messages.log`. +- Long command output uses a small pager so `:last` and `:search` are readable + on small terminals. + +## Regression Coverage + +`make user-lifecycle-test` runs a two-user SSH TUI journey: + +- second user joins and is visible through `users --json` +- first user opens `?`, checks `:users`, sends a public message, scrolls, uses + `:last` and `:search` +- first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends + `/me`, and exits +- second user reads `:inbox` +- exec `tail` sees public messages +- `messages.log` contains public history and excludes private-message content + +This test is intentionally closer to a user story than a unit regression. Keep +it focused on lifecycle guarantees, not every keybinding. diff --git a/include/cli_text.h b/include/cli_text.h index 5fb6420..bbf0edc 100644 --- a/include/cli_text.h +++ b/include/cli_text.h @@ -6,6 +6,7 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, const char *program_name, ui_lang_t lang); const char *cli_text_invalid_port_format(ui_lang_t lang); +const char *cli_text_invalid_value_format(ui_lang_t lang); const char *cli_text_unknown_option_format(ui_lang_t lang); const char *cli_text_short_usage_format(ui_lang_t lang); diff --git a/include/client.h b/include/client.h index 889af68..af06833 100644 --- a/include/client.h +++ b/include/client.h @@ -3,11 +3,20 @@ #include "ssh_server.h" /* for client_t */ -/* Send `len` bytes to the client over its SSH channel. Serialised on - * client->io_lock so concurrent senders don't interleave. Returns 0 on - * success, -1 if the channel is gone or a partial write fails. */ +/* Send `len` bytes to the client over its SSH channel. + * + * Exec sessions write synchronously so command output and exit status remain + * ordered. Interactive sessions enqueue into a bounded per-client outbox and + * flush opportunistically from the same client's session loop, so a closed SSH + * window cannot block unrelated room activity. Returns -1 if the channel is + * gone, a write fails, or the bounded outbox is full. */ int client_send(client_t *client, const char *data, size_t len); +/* Flush queued interactive output for this client. Returns 0 when all + * possible progress was made; queued bytes may remain if the remote SSH window + * is currently closed. */ +int client_flush_output(client_t *client); + /* Queue an audible bell for the client's own session loop to send. This * avoids writing to another client's SSH channel from the sender's thread. */ void client_queue_bell(client_t *client); diff --git a/include/common.h b/include/common.h index c179d38..93b5b55 100644 --- a/include/common.h +++ b/include/common.h @@ -29,6 +29,8 @@ #define MAX_MESSAGE_LEN 1024 #define MAX_EXEC_COMMAND_LEN 1024 #define MAX_COMMAND_OUTPUT_LEN 8192 +#define CLIENT_OUTBOX_CAPACITY (128 * 1024) +#define CLIENT_OUTBOX_FLUSH_BUDGET 32768 #define DEFAULT_MAX_CLIENTS 64 #define MAX_CONFIGURED_CLIENTS 1024 #define LOG_FILE "messages.log" diff --git a/include/ssh_server.h b/include/ssh_server.h index 7fd2df4..3e35c37 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -52,6 +52,10 @@ typedef struct client { _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 */ + char *outbox; /* Bounded queued output for interactive writes */ + size_t outbox_len; + size_t outbox_pos; + size_t outbox_capacity; /* Per-client whisper inbox. Protected separately from SSH channel I/O * so slow writes do not block in-memory private-message delivery. */ whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; diff --git a/scripts/release_check.sh b/scripts/release_check.sh index 937078f..a35d7fe 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -20,6 +20,7 @@ Default checks: Environment: RUN_INTEGRATION=1 also run full make test + RUN_SOAK=1 also run the configurable soak test PORT=12720 base port for integration tests Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a @@ -116,6 +117,12 @@ if [ "${RUN_INTEGRATION:-0}" = "1" ]; then make test PORT="${PORT:-12720}" fi +if [ "${RUN_SOAK:-0}" = "1" ]; then + step "running soak test" + make soak-test PORT="$((${PORT:-12720} + 30))" \ + DURATION="${SOAK_DURATION:-8}" RECONNECTS="${SOAK_RECONNECTS:-5}" +fi + tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX") cleanup() { rm -rf "$tmpdir" diff --git a/src/cli_text.c b/src/cli_text.c index 41be3c2..6b3f5b1 100644 --- a/src/cli_text.c +++ b/src/cli_text.c @@ -8,10 +8,18 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, "tnt %s - anonymous SSH chat server\n\n" "Usage: %s [options]\n\n" "Options:\n" - " -p, --port PORT Listen on PORT (default: %d)\n" - " -d, --state-dir DIR Store host key and logs in DIR\n" - " -V, --version Show version\n" - " -h, --help Show this help\n" + " -p, --port PORT Listen on PORT (default: %d)\n" + " -d, --state-dir DIR Store host key and logs in DIR\n" + " --bind ADDR Bind to ADDR (default: 0.0.0.0)\n" + " --public-host HOST Show HOST in startup connection hints\n" + " --max-connections N Global connection limit (default: 64)\n" + " --max-conn-per-ip N Per-IP concurrent session limit\n" + " --max-conn-rate-per-ip N Per-IP connection-rate limit\n" + " --rate-limit 0|1 Disable/enable rate-based blocking\n" + " --idle-timeout SECONDS Idle disconnect timeout\n" + " --ssh-log-level LEVEL libssh log level 0..4\n" + " -V, --version Show version\n" + " -h, --help Show this help\n" "\n" "Environment:\n" " PORT Default listening port\n" @@ -24,10 +32,18 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, "tnt %s - 匿名 SSH 聊天服务器\n\n" "用法: %s [options]\n\n" "选项:\n" - " -p, --port PORT 监听 PORT (默认: %d)\n" - " -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n" - " -V, --version 显示版本\n" - " -h, --help 显示此帮助\n" + " -p, --port PORT 监听 PORT (默认: %d)\n" + " -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n" + " --bind ADDR 绑定到 ADDR (默认: 0.0.0.0)\n" + " --public-host HOST 在启动提示中显示 HOST\n" + " --max-connections N 全局连接数限制 (默认: 64)\n" + " --max-conn-per-ip N 单 IP 并发会话限制\n" + " --max-conn-rate-per-ip N 单 IP 连接速率限制\n" + " --rate-limit 0|1 禁用/启用速率封禁\n" + " --idle-timeout SECONDS 空闲断开时间\n" + " --ssh-log-level LEVEL libssh 日志级别 0..4\n" + " -V, --version 显示版本\n" + " -h, --help 显示此帮助\n" "\n" "环境变量:\n" " PORT 默认监听端口\n" @@ -52,6 +68,12 @@ const char *cli_text_invalid_port_format(ui_lang_t lang) { return i18n_string(text, lang); } +const char *cli_text_invalid_value_format(ui_lang_t lang) { + static const i18n_string_t text = + I18N_STRING("Invalid %s: %s\n", "%s 无效: %s\n"); + return i18n_string(text, lang); +} + const char *cli_text_unknown_option_format(ui_lang_t lang) { static const i18n_string_t text = I18N_STRING("Unknown option: %s\n", "未知选项: %s\n"); @@ -60,7 +82,7 @@ const char *cli_text_unknown_option_format(ui_lang_t lang) { const char *cli_text_short_usage_format(ui_lang_t lang) { static const i18n_string_t text = - I18N_STRING("Usage: %s [-p PORT] [-d DIR] [-h]\n", - "用法: %s [-p PORT] [-d DIR] [-h]\n"); + I18N_STRING("Usage: %s [options]\n", + "用法: %s [options]\n"); return i18n_string(text, lang); } diff --git a/src/client.c b/src/client.c index 830b89c..d5a5c8d 100644 --- a/src/client.c +++ b/src/client.c @@ -16,11 +16,132 @@ static int client_send_fail(client_t *client) { return -1; } -/* Send data to client via SSH channel */ -int client_send(client_t *client, const char *data, size_t len) { +static bool client_is_exec(const client_t *client) { + return client && (client->exec_command[0] != '\0' || + client->exec_command_too_long); +} + +static int client_write_direct_locked(client_t *client, const char *data, + size_t len, size_t budget, + bool fail_on_closed_window) { size_t total = 0; + while (total < len) { + size_t remaining = len - total; + uint32_t window = ssh_channel_window_size(client->channel); + + if (window == 0) { + if (!fail_on_closed_window) { + break; + } + return client_send_fail(client); + } + + uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining; + if (chunk > window) { + chunk = window; + } + if (budget > 0 && chunk > budget) { + chunk = (uint32_t)budget; + } + + int sent = ssh_channel_write(client->channel, data + total, chunk); + if (sent <= 0) { + return client_send_fail(client); + } + total += (size_t)sent; + + if (budget > 0) { + budget -= (size_t)sent; + if (budget == 0) { + break; + } + } + } + + return (int)total; +} + +static int client_flush_output_locked(client_t *client, size_t budget) { + size_t pending; + int sent; + + if (!client->outbox || client->outbox_pos >= client->outbox_len) { + if (client->outbox) { + client->outbox_pos = 0; + client->outbox_len = 0; + } + return 0; + } + + pending = client->outbox_len - client->outbox_pos; + sent = client_write_direct_locked(client, client->outbox + client->outbox_pos, + pending, budget, false); + if (sent < 0) { + return -1; + } + + client->outbox_pos += (size_t)sent; + if (client->outbox_pos >= client->outbox_len) { + client->outbox_pos = 0; + client->outbox_len = 0; + } + + return 0; +} + +static int client_compact_outbox(client_t *client) { + if (!client->outbox || client->outbox_pos == 0) { + return 0; + } + + if (client->outbox_pos < client->outbox_len) { + memmove(client->outbox, client->outbox + client->outbox_pos, + client->outbox_len - client->outbox_pos); + client->outbox_len -= client->outbox_pos; + } else { + client->outbox_len = 0; + } + client->outbox_pos = 0; + return 0; +} + +static int client_enqueue_output_locked(client_t *client, const char *data, + size_t len) { + if (len == 0) { + return 0; + } + + if (len > CLIENT_OUTBOX_CAPACITY) { + return client_send_fail(client); + } + + if (!client->outbox) { + client->outbox = malloc(CLIENT_OUTBOX_CAPACITY); + if (!client->outbox) { + return client_send_fail(client); + } + client->outbox_capacity = CLIENT_OUTBOX_CAPACITY; + client->outbox_len = 0; + client->outbox_pos = 0; + } + + client_compact_outbox(client); + if (client->outbox_len + len > client->outbox_capacity) { + return client_send_fail(client); + } + + memcpy(client->outbox + client->outbox_len, data, len); + client->outbox_len += len; + return 0; +} + +/* Send data to client via SSH channel */ +int client_send(client_t *client, const char *data, size_t len) { + int rc = 0; + if (!client || !data) return -1; + if (len == 0) return 0; pthread_mutex_lock(&client->io_lock); @@ -29,33 +150,40 @@ int client_send(client_t *client, const char *data, size_t len) { return -1; } - 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); + if (client_is_exec(client)) { + rc = client_write_direct_locked(client, data, len, 0, true); + if (rc >= 0 && (size_t)rc == len) { + rc = 0; + } else if (rc >= 0) { + rc = client_send_fail(client); } - - 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 client_send_fail(client); - } - total += (size_t)sent; - } - - if (client->exec_command[0] != '\0') { ssh_blocking_flush(client->session, 1000); + } else { + rc = client_enqueue_output_locked(client, data, len); + if (rc == 0) { + rc = client_flush_output_locked(client, CLIENT_OUTBOX_FLUSH_BUDGET); + } } pthread_mutex_unlock(&client->io_lock); - return 0; + return rc; +} + +int client_flush_output(client_t *client) { + int rc; + + if (!client) return 0; + + pthread_mutex_lock(&client->io_lock); + + if (!client->connected || !client->channel) { + pthread_mutex_unlock(&client->io_lock); + return -1; + } + + rc = client_flush_output_locked(client, CLIENT_OUTBOX_FLUSH_BUDGET); + pthread_mutex_unlock(&client->io_lock); + return rc; } void client_queue_bell(client_t *client) { @@ -108,6 +236,7 @@ void client_release(client_t *client) { if (client->channel_cb) { free(client->channel_cb); } + free(client->outbox); pthread_mutex_destroy(&client->io_lock); pthread_mutex_destroy(&client->whisper_lock); pthread_mutex_destroy(&client->ref_lock); diff --git a/src/input.c b/src/input.c index b4c0ab8..c122c0a 100644 --- a/src/input.c +++ b/src/input.c @@ -805,6 +805,10 @@ main_loop: /* Main input loop */ while (client->connected && ssh_channel_is_open(client->channel)) { + if (client_flush_output(client) != 0) { + break; + } + int ready = ssh_channel_poll_timeout(client->channel, 1000, 0); if (ready == SSH_ERROR) { @@ -819,6 +823,10 @@ main_loop: break; } + if (client_flush_output(client) != 0) { + break; + } + if (client_flush_pending_bells(client) != 0) { break; } diff --git a/src/main.c b/src/main.c index 42ed66e..036df2c 100644 --- a/src/main.c +++ b/src/main.c @@ -18,6 +18,62 @@ static void signal_handler(int sig) { _exit(0); } +static bool parse_int_arg(const char *value, int min_val, int max_val, + int *out) { + char *end = NULL; + long val; + + if (!value || value[0] == '\0' || !out) { + return false; + } + + val = strtol(value, &end, 10); + if (!end || *end != '\0' || val < min_val || val > max_val) { + return false; + } + + *out = (int)val; + return true; +} + +static bool is_config_token(const char *value) { + const unsigned char *p = (const unsigned char *)value; + + if (!value || value[0] == '\0') { + return false; + } + while (*p) { + if (*p <= 32 || *p == 127) { + return false; + } + p++; + } + return true; +} + +static int set_env_option(const char *name, const char *value) { + if (setenv(name, value, 1) != 0) { + perror(name); + return -1; + } + return 0; +} + +static int set_numeric_env_option(const char *env_name, const char *opt_name, + const char *value, int min_val, + int max_val, ui_lang_t lang) { + int parsed; + + if (!parse_int_arg(value, min_val, max_val, &parsed)) { + fprintf(stderr, cli_text_invalid_value_format(lang), opt_name, value); + return TNT_EXIT_USAGE; + } + if (set_env_option(env_name, value) != 0) { + return TNT_EXIT_ERROR; + } + return TNT_EXIT_OK; +} + int main(int argc, char **argv) { int port = DEFAULT_PORT; ui_lang_t lang = i18n_default_ui_lang(); @@ -36,22 +92,93 @@ int main(int argc, char **argv) { for (int i = 1; i < argc; i++) { if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) && i + 1 < argc) { - char *end; - long val = strtol(argv[i + 1], &end, 10); - if (*end != '\0' || val <= 0 || val > 65535) { + int val; + if (!parse_int_arg(argv[i + 1], 1, 65535, &val)) { fprintf(stderr, cli_text_invalid_port_format(lang), argv[i + 1]); return TNT_EXIT_USAGE; } - port = (int)val; + port = val; i++; } else if ((strcmp(argv[i], "-d") == 0 || strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) { - if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) { - perror("setenv TNT_STATE_DIR"); + if (argv[i + 1][0] == '\0') { + fprintf(stderr, cli_text_invalid_value_format(lang), + argv[i], argv[i + 1]); + return TNT_EXIT_USAGE; + } + if (set_env_option("TNT_STATE_DIR", argv[i + 1]) != 0) { return TNT_EXIT_ERROR; } i++; + } else if (strcmp(argv[i], "--bind") == 0 && i + 1 < argc) { + if (!is_config_token(argv[i + 1])) { + fprintf(stderr, cli_text_invalid_value_format(lang), + argv[i], argv[i + 1]); + return TNT_EXIT_USAGE; + } + if (set_env_option("TNT_BIND_ADDR", argv[i + 1]) != 0) { + return TNT_EXIT_ERROR; + } + i++; + } else if (strcmp(argv[i], "--public-host") == 0 && i + 1 < argc) { + if (!is_config_token(argv[i + 1])) { + fprintf(stderr, cli_text_invalid_value_format(lang), + argv[i], argv[i + 1]); + return TNT_EXIT_USAGE; + } + if (set_env_option("TNT_PUBLIC_HOST", argv[i + 1]) != 0) { + return TNT_EXIT_ERROR; + } + i++; + } else if (strcmp(argv[i], "--max-connections") == 0 && + i + 1 < argc) { + int rc = set_numeric_env_option("TNT_MAX_CONNECTIONS", argv[i], + argv[i + 1], 1, + MAX_CONFIGURED_CLIENTS, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--max-conn-per-ip") == 0 && + i + 1 < argc) { + int rc = set_numeric_env_option("TNT_MAX_CONN_PER_IP", argv[i], + argv[i + 1], 1, + MAX_CONFIGURED_CLIENTS, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--max-conn-rate-per-ip") == 0 && + i + 1 < argc) { + int rc = set_numeric_env_option("TNT_MAX_CONN_RATE_PER_IP", + argv[i], argv[i + 1], 1, + MAX_CONFIGURED_CLIENTS, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--rate-limit") == 0 && i + 1 < argc) { + int rc = set_numeric_env_option("TNT_RATE_LIMIT", argv[i], + argv[i + 1], 0, 1, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--idle-timeout") == 0 && i + 1 < argc) { + int rc = set_numeric_env_option("TNT_IDLE_TIMEOUT", argv[i], + argv[i + 1], 0, 86400, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--ssh-log-level") == 0 && i + 1 < argc) { + int rc = set_numeric_env_option("TNT_SSH_LOG_LEVEL", argv[i], + argv[i + 1], 0, 4, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) { printf("tnt %s\n", TNT_VERSION); return TNT_EXIT_OK; diff --git a/tests/test_soak.sh b/tests/test_soak.sh new file mode 100755 index 0000000..e44dd05 --- /dev/null +++ b/tests/test_soak.sh @@ -0,0 +1,226 @@ +#!/bin/sh +# Lightweight soak test for TNT. +# Usage: ./test_soak.sh [duration_seconds] [reconnect_count] + +PORT=${PORT:-2222} +DURATION=${1:-8} +RECONNECTS=${2:-5} +BIN="../tnt" +PASS=0 +FAIL=0 +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-soak-test.XXXXXX") +SERVER_PID="" +IDLE_PID="" + +cleanup() { + if [ -n "$IDLE_PID" ]; then + kill "$IDLE_PID" 2>/dev/null || true + wait "$IDLE_PID" 2>/dev/null || true + fi + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +case "$DURATION" in + ''|*[!0-9]*) + echo "Error: duration_seconds must be a positive integer" + exit 2 + ;; +esac + +case "$RECONNECTS" in + ''|*[!0-9]*) + echo "Error: reconnect_count must be a positive integer" + exit 2 + ;; +esac + +if [ "$DURATION" -lt 1 ] || [ "$RECONNECTS" -lt 1 ]; then + echo "Error: duration_seconds and reconnect_count must be positive" + exit 2 +fi + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +if ! command -v expect >/dev/null 2>&1; then + echo "expect not installed; skipping soak test" + exit 0 +fi + +SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" +SSH_TTY_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" + +wait_for_health() { + out="" + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + return 1 + fi + out=$(ssh $SSH_OPTS localhost health 2>/dev/null || true) + [ "$out" = "ok" ] && return 0 + sleep 1 + done + return 1 +} + +echo "=== TNT Soak Test ===" +echo "duration=${DURATION}s reconnects=$RECONNECTS port=$PORT" + +TNT_LANG=zh "$BIN" \ + --bind 127.0.0.1 \ + --public-host soak.local \ + --max-connections 32 \ + --max-conn-per-ip 32 \ + --max-conn-rate-per-ip 64 \ + --rate-limit 0 \ + --idle-timeout 0 \ + --ssh-log-level 1 \ + -p "$PORT" \ + -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +if wait_for_health; then + echo "✓ server started" + PASS=$((PASS + 1)) +else + echo "✗ server failed to start" + sed -n '1,160p' "$STATE_DIR/server.log" + exit 1 +fi + +if grep -q 'ssh -p '"$PORT"' soak.local' "$STATE_DIR/server.log"; then + echo "✓ explicit public host appears in startup hint" + PASS=$((PASS + 1)) +else + echo "✗ explicit public host missing from startup hint" + sed -n '1,80p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +IDLE_READY="$STATE_DIR/idle.ready" +cat >"$STATE_DIR/idle.expect" <"$STATE_DIR/idle.log" 2>&1 & +IDLE_PID=$! + +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -f "$IDLE_READY" ] && break + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done + +if [ -f "$IDLE_READY" ]; then + echo "✓ idle interactive session reached chat" + PASS=$((PASS + 1)) +else + echo "✗ idle interactive session did not reach chat" + sed -n '1,160p' "$STATE_DIR/idle.log" + FAIL=$((FAIL + 1)) +fi + +control_failed=0 +for i in $(seq 1 "$DURATION"); do + HEALTH=$(ssh $SSH_OPTS localhost health 2>/dev/null || true) + STATS=$(ssh $SSH_OPTS localhost stats --json 2>/dev/null || true) + USERS=$(ssh $SSH_OPTS localhost users --json 2>/dev/null || true) + + if [ "$HEALTH" != "ok" ] || + ! printf '%s\n' "$STATS" | grep -q '"status":"ok"' || + ! printf '%s\n' "$USERS" | grep -q 'soakidle'; then + echo "✗ control interface failed during idle soak at ${i}s" + printf 'health=%s\nstats=%s\nusers=%s\n' "$HEALTH" "$STATS" "$USERS" + FAIL=$((FAIL + 1)) + control_failed=1 + break + fi + sleep 1 +done + +if [ "$control_failed" -eq 0 ]; then + echo "✓ control interface stayed available during idle soak" + PASS=$((PASS + 1)) +fi + +reconnected=0 +for i in $(seq 1 "$RECONNECTS"); do + cat >"$STATE_DIR/reconnect-$i.expect" <"$STATE_DIR/reconnect-$i.log" 2>&1; then + reconnected=$((reconnected + 1)) + else + sed -n '1,120p' "$STATE_DIR/reconnect-$i.log" + break + fi +done + +if [ "$reconnected" -eq "$RECONNECTS" ]; then + echo "✓ repeated reconnects completed" + PASS=$((PASS + 1)) +else + echo "✗ repeated reconnects stopped at $reconnected/$RECONNECTS" + FAIL=$((FAIL + 1)) +fi + +LAST_MESSAGE="soak message $RECONNECTS" +POST=$(ssh $SSH_OPTS soakbot@localhost post "$LAST_MESSAGE" 2>/dev/null || true) +TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true) +if [ "$POST" = "posted" ] && + printf '%s\n' "$TAIL" | grep -q "$LAST_MESSAGE"; then + echo "✓ post/tail path stayed available after reconnect churn" + PASS=$((PASS + 1)) +else + echo "✗ post/tail path failed after reconnect churn" + printf '%s\n' "$POST" + printf '%s\n' "$TAIL" + FAIL=$((FAIL + 1)) +fi + +wait "$IDLE_PID" 2>/dev/null || FAIL=$((FAIL + 1)) +IDLE_PID="" + +if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "✓ server survived soak test" + PASS=$((PASS + 1)) +else + echo "✗ server exited during soak test" + sed -n '1,160p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/test_user_lifecycle.sh b/tests/test_user_lifecycle.sh new file mode 100755 index 0000000..e007ab7 --- /dev/null +++ b/tests/test_user_lifecycle.sh @@ -0,0 +1,275 @@ +#!/bin/sh +# End-to-end user lifecycle test for TNT's interactive TUI. + +PORT=${PORT:-2222} +BIN="../tnt" +PASS=0 +FAIL=0 +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-lifecycle-test.XXXXXX") +SERVER_PID="" +BOB_PID="" + +cleanup() { + if [ -n "$BOB_PID" ]; then + kill "$BOB_PID" 2>/dev/null || true + wait "$BOB_PID" 2>/dev/null || true + fi + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +if ! command -v expect >/dev/null 2>&1; then + echo "expect not installed; skipping user lifecycle test" + exit 0 +fi + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" +SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" +BOB_READY="$STATE_DIR/bob.ready" +ALICE_DONE="$STATE_DIR/alice.done" + +wait_for_health() { + out="" + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + return 1 + fi + out=$(ssh $SSH_EXEC_OPTS localhost health 2>/dev/null || true) + [ "$out" = "ok" ] && return 0 + sleep 1 + done + return 1 +} + +echo "=== TNT User Lifecycle Test ===" + +TNT_LANG=zh "$BIN" \ + --bind 127.0.0.1 \ + --public-host lifecycle.local \ + --max-connections 32 \ + --max-conn-per-ip 32 \ + --max-conn-rate-per-ip 64 \ + --rate-limit 0 \ + --idle-timeout 0 \ + -p "$PORT" \ + -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +if wait_for_health; then + echo "✓ server started" + PASS=$((PASS + 1)) +else + echo "✗ server failed to start" + sed -n '1,160p' "$STATE_DIR/server.log" + exit 1 +fi + +cat >"$STATE_DIR/bob.expect" <"$STATE_DIR/bob.log" 2>&1 & +BOB_PID=$! + +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -f "$BOB_READY" ] && break + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done + +if [ -f "$BOB_READY" ]; then + echo "✓ second user reached chat" + PASS=$((PASS + 1)) +else + echo "✗ second user did not reach chat" + sed -n '1,180p' "$STATE_DIR/bob.log" + FAIL=$((FAIL + 1)) +fi + +USERS_JSON="" +for _ in 1 2 3 4 5; do + USERS_JSON=$(ssh $SSH_EXEC_OPTS localhost users --json 2>/dev/null || true) + printf '%s\n' "$USERS_JSON" | grep -q '"bob"' && break + sleep 1 +done +if printf '%s\n' "$USERS_JSON" | grep -q '"bob"'; then + echo "✓ exec users sees active TUI user" + PASS=$((PASS + 1)) +else + echo "✗ exec users did not see active TUI user" + printf '%s\n' "$USERS_JSON" + FAIL=$((FAIL + 1)) +fi + +cat >"$STATE_DIR/alice.expect" < alice2" +expect "q:关闭" +send -- "q" +expect "NORMAL" +send -- "i" +expect ":help" +send -- "/me ships lifecycle\r" +sleep 1 +exec touch "$ALICE_DONE" +send -- "\003" +sleep 0.2 +send -- "\003" +expect eof +EOF + +if expect "$STATE_DIR/alice.expect" >"$STATE_DIR/alice.log" 2>&1; then + echo "✓ primary user lifecycle completed" + PASS=$((PASS + 1)) +else + echo "✗ primary user lifecycle failed" + sed -n '1,240p' "$STATE_DIR/alice.log" + FAIL=$((FAIL + 1)) + touch "$ALICE_DONE" +fi + +if wait "$BOB_PID" 2>/dev/null; then + echo "✓ recipient read private-message inbox" + PASS=$((PASS + 1)) +else + echo "✗ recipient inbox journey failed" + sed -n '1,240p' "$STATE_DIR/bob.log" + FAIL=$((FAIL + 1)) +fi +BOB_PID="" + +TAIL_OUTPUT=$(ssh $SSH_EXEC_OPTS localhost "tail -n 10" 2>/dev/null || true) +printf '%s\n' "$TAIL_OUTPUT" | grep -q 'hello lifecycle alpha' && +printf '%s\n' "$TAIL_OUTPUT" | grep -q 'alice2 ships lifecycle' +if [ $? -eq 0 ]; then + echo "✓ exec tail sees public lifecycle messages" + PASS=$((PASS + 1)) +else + echo "✗ exec tail missing lifecycle messages" + printf '%s\n' "$TAIL_OUTPUT" + FAIL=$((FAIL + 1)) +fi + +if grep -q 'alice|hello lifecycle alpha' "$STATE_DIR/messages.log" && + grep -q '系统|alice 更名为 alice2' "$STATE_DIR/messages.log" && + grep -q '*|alice2 ships lifecycle' "$STATE_DIR/messages.log" && + ! grep -q 'private lifecycle ping' "$STATE_DIR/messages.log"; then + echo "✓ persisted history matches public/private boundary" + PASS=$((PASS + 1)) +else + echo "✗ persisted history boundary unexpected" + cat "$STATE_DIR/messages.log" 2>/dev/null || true + FAIL=$((FAIL + 1)) +fi + +if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "✓ server survived user lifecycle" + PASS=$((PASS + 1)) +else + echo "✗ server exited during user lifecycle" + sed -n '1,160p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +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_cli_text.c b/tests/unit/test_cli_text.c index 2cb708f..66b5967 100644 --- a/tests/unit/test_cli_text.c +++ b/tests/unit/test_cli_text.c @@ -22,6 +22,8 @@ TEST(help_matches_language) { cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_EN); assert(strstr(output, "anonymous SSH chat server") != NULL); assert(strstr(output, "Usage: tnt [options]") != NULL); + assert(strstr(output, "--bind ADDR") != NULL); + assert(strstr(output, "--max-connections N") != NULL); assert(strstr(output, "TNT_LANG") != NULL); memset(output, 0, sizeof(output)); @@ -35,6 +37,8 @@ TEST(help_matches_language) { assert(strstr(output, "匿名 SSH 聊天服务器") != NULL); assert(strstr(output, "用法: tnt [options]") != NULL); assert(strstr(output, "[选项]") == NULL); + assert(strstr(output, "--public-host HOST") != NULL); + assert(strstr(output, "--idle-timeout SECONDS") != NULL); assert(strstr(output, "TNT_LANG") != NULL); } @@ -43,14 +47,18 @@ TEST(error_formats_match_language) { "Invalid port: %s\n") == 0); assert(strcmp(cli_text_invalid_port_format(UI_LANG_ZH), "端口无效: %s\n") == 0); + assert(strcmp(cli_text_invalid_value_format(UI_LANG_EN), + "Invalid %s: %s\n") == 0); + assert(strcmp(cli_text_invalid_value_format(UI_LANG_ZH), + "%s 无效: %s\n") == 0); assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN), "Unknown option: %s\n") == 0); assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH), "未知选项: %s\n") == 0); assert(strcmp(cli_text_short_usage_format(UI_LANG_EN), - "Usage: %s [-p PORT] [-d DIR] [-h]\n") == 0); + "Usage: %s [options]\n") == 0); assert(strcmp(cli_text_short_usage_format(UI_LANG_ZH), - "用法: %s [-p PORT] [-d DIR] [-h]\n") == 0); + "用法: %s [options]\n") == 0); assert(strcmp(cli_text_invalid_port_format((ui_lang_t)99), "Invalid port: %s\n") == 0); } diff --git a/tnt.1 b/tnt.1 index 357ef2f..4e207c1 100644 --- a/tnt.1 +++ b/tnt.1 @@ -8,6 +8,22 @@ tnt \- anonymous SSH chat server with Vim\-style TUI .IR port ] .RB [ \-d | \-\-state\-dir .IR dir ] +.RB [ \-\-bind +.IR addr ] +.RB [ \-\-public\-host +.IR host ] +.RB [ \-\-max\-connections +.IR n ] +.RB [ \-\-max\-conn\-per\-ip +.IR n ] +.RB [ \-\-max\-conn\-rate\-per\-ip +.IR n ] +.RB [ \-\-rate\-limit +.IR 0|1 ] +.RB [ \-\-idle\-timeout +.IR seconds ] +.RB [ \-\-ssh\-log\-level +.IR level ] .RB [ \-V | \-\-version ] .RB [ \-h | \-\-help ] .SH DESCRIPTION @@ -39,6 +55,61 @@ Overrides the environment variable. Defaults to the current working directory. .TP +.BR \-\-bind " " \fIaddr\fR +Bind the SSH listener to +.IR addr . +Overrides the +.B TNT_BIND_ADDR +environment variable. +The default is 0.0.0.0. +.TP +.BR \-\-public\-host " " \fIhost\fR +Show +.I host +in the startup connection hint. +Overrides the +.B TNT_PUBLIC_HOST +environment variable. +.TP +.BR \-\-max\-connections " " \fIn\fR +Set the global connection limit. +Overrides the +.B TNT_MAX_CONNECTIONS +environment variable. +.TP +.BR \-\-max\-conn\-per\-ip " " \fIn\fR +Set the concurrent session limit per source IP. +Overrides the +.B TNT_MAX_CONN_PER_IP +environment variable. +.TP +.BR \-\-max\-conn\-rate\-per\-ip " " \fIn\fR +Set the connection-rate limit per source IP per 60-second window. +Overrides the +.B TNT_MAX_CONN_RATE_PER_IP +environment variable. +.TP +.BR \-\-rate\-limit " " \fI0|1\fR +Disable or enable rate-based blocking and auth-failure IP blocking. +Explicit capacity limits still apply. +Overrides the +.B TNT_RATE_LIMIT +environment variable. +.TP +.BR \-\-idle\-timeout " " \fIseconds\fR +Disconnect inactive interactive sessions after +.I seconds +seconds. Use 0 to disable. +Overrides the +.B TNT_IDLE_TIMEOUT +environment variable. +.TP +.BR \-\-ssh\-log\-level " " \fIlevel\fR +Set libssh log verbosity from 0 to 4. +Overrides the +.B TNT_SSH_LOG_LEVEL +environment variable. +.TP .BR \-V ", " \-\-version Print version and exit. .TP @@ -176,6 +247,12 @@ Default listening port (default: 2222). .B TNT_STATE_DIR Directory for host key and message log (default: current directory). .TP +.B TNT_BIND_ADDR +Address to bind (default: 0.0.0.0). +.TP +.B TNT_PUBLIC_HOST +Host name shown in startup connection hints (default: localhost). +.TP .B TNT_ACCESS_TOKEN If set, clients must supply this string as their SSH password. Compared in constant time. @@ -204,6 +281,9 @@ Explicit capacity limits still apply (default: 1). .B TNT_IDLE_TIMEOUT Disconnect clients after this many seconds of inactivity. Set to 0 to disable (default: 1800, i.e. 30 minutes). +.TP +.B TNT_SSH_LOG_LEVEL +libssh log verbosity from 0 to 4 (default: 1). .SH FILES .TP .I messages.log From f3e2762f30fdc205df896e00a9d4ee05b30f43de Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 26 May 2026 11:26:09 +0800 Subject: [PATCH 03/31] Stabilize short soak lifecycle window --- tests/test_soak.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_soak.sh b/tests/test_soak.sh index e44dd05..4becc0c 100755 --- a/tests/test_soak.sh +++ b/tests/test_soak.sh @@ -106,14 +106,15 @@ else fi IDLE_READY="$STATE_DIR/idle.ready" +IDLE_HOLD=$((DURATION + 2)) cat >"$STATE_DIR/idle.expect" < Date: Tue, 26 May 2026 12:22:33 +0800 Subject: [PATCH 04/31] Polish live inbox command output --- README.md | 4 ++ docs/CHANGELOG.md | 4 ++ docs/USER_LIFECYCLE.md | 6 ++- include/commands.h | 5 ++ include/i18n.h | 1 + include/ssh_server.h | 7 +++ src/commands.c | 82 +++++++++++++++++++++------------ src/help_text.c | 2 + src/i18n_text.c | 4 ++ src/input.c | 13 ++++++ src/tui.c | 5 +- tests/test_interactive_input.sh | 3 ++ tests/test_user_lifecycle.sh | 15 +++--- tests/unit/test_help_text.c | 2 + tests/unit/test_i18n.c | 6 +++ tnt.1 | 8 ++++ 16 files changed, 129 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f31b8aa..e6a1cc3 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ Up/Down - Browse command history ESC - Return to NORMAL mode ``` +Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox` +is live: press `r` to refresh it manually, and it refreshes when a new private +message arrives while the inbox is open. + **Special messages (INSERT mode)** ``` /me - Send action (e.g. /me waves) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d8adcf5..586430b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,8 @@ the main onboarding, chat, help, history, search, private-message, nickname, action-message, and exit paths. - Added a VHS tape draft for recording the core TNT terminal-chat experience. +- Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and + an open inbox refreshes when a new private message arrives. ### Changed - `make install-systemd` now rewrites the installed unit's `ExecStart` to match @@ -40,6 +42,8 @@ - Interactive client writes now pass through a bounded per-client outbox and flush against the remote SSH window from that client's session loop. Exec sessions still write synchronously to preserve script output ordering. +- The two-user lifecycle test now covers opening `:inbox` before a private + message arrives, matching the way users often leave an inbox page open. - 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 diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md index 878bb15..43c9bd9 100644 --- a/docs/USER_LIFECYCLE.md +++ b/docs/USER_LIFECYCLE.md @@ -29,6 +29,9 @@ The product path should stay short: they do not change the command language. - Private messages are visible only in the recipient inbox and are not written to `messages.log`. +- `:inbox` is live enough for normal chat use: it can be refreshed with `r` + and refreshes automatically when a new private message arrives while the + inbox is open. - Long command output uses a small pager so `:last` and `:search` are readable on small terminals. @@ -41,7 +44,8 @@ The product path should stay short: `:last` and `:search` - first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends `/me`, and exits -- second user reads `:inbox` +- second user opens `:inbox` before the private message arrives and sees it + auto-refresh after delivery - exec `tail` sees public messages - `messages.log` contains public history and excludes private-message content diff --git a/include/commands.h b/include/commands.h index 09f6b47..8d60eca 100644 --- a/include/commands.h +++ b/include/commands.h @@ -19,4 +19,9 @@ * path; callers must not hold client->io_lock before dispatching. */ void commands_dispatch(client_t *client); +/* Rebuild the currently visible command output when it is backed by live + * client state, such as :inbox. Returns true if output changed and the caller + * should render it again. */ +bool commands_refresh_active_output(client_t *client); + #endif /* COMMANDS_H */ diff --git a/include/i18n.h b/include/i18n.h index a5785af..219965b 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -25,6 +25,7 @@ typedef enum { I18N_HELP_STATUS_FORMAT, I18N_COMMAND_OUTPUT_TITLE, I18N_COMMAND_OUTPUT_STATUS_FORMAT, + I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT, I18N_MOTD_TITLE, I18N_MOTD_CONTINUE_HINT, I18N_TITLE_ONLINE_FORMAT, diff --git a/include/ssh_server.h b/include/ssh_server.h index 3e35c37..f584567 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -17,6 +17,12 @@ typedef struct { char content[MAX_MESSAGE_LEN]; } whisper_t; +typedef enum { + TNT_COMMAND_OUTPUT_NONE, + TNT_COMMAND_OUTPUT_GENERIC, + TNT_COMMAND_OUTPUT_INBOX +} tnt_command_output_kind_t; + /* Client connection structure */ typedef struct client { ssh_session session; /* SSH session */ @@ -42,6 +48,7 @@ typedef struct client { int insert_history_pos; char command_output[MAX_COMMAND_OUTPUT_LEN]; int command_output_scroll; + tnt_command_output_kind_t command_output_kind; bool show_motd; /* command_output holds MOTD text */ char exec_command[MAX_EXEC_COMMAND_LEN]; bool exec_command_too_long; diff --git a/src/commands.c b/src/commands.c index 0ea7527..318c720 100644 --- a/src/commands.c +++ b/src/commands.c @@ -52,12 +52,60 @@ static void append_command_usage(char *output, size_t buf_size, size_t *pos, command_catalog_append_usage(output, buf_size, pos, id, lang); } +static void append_inbox_output(client_t *client, char *output, + size_t buf_size, size_t *pos) { + whisper_t snapshot[WHISPER_INBOX_SIZE]; + int snap_count; + + pthread_mutex_lock(&client->whisper_lock); + snap_count = client->whisper_inbox_count; + memcpy(snapshot, client->whisper_inbox, + snap_count * sizeof(whisper_t)); + client->unread_whispers = 0; + pthread_mutex_unlock(&client->whisper_lock); + + buffer_appendf(output, buf_size, pos, + "\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n", + i18n_text(client->ui_lang, I18N_INBOX_TITLE), + snap_count); + if (snap_count == 0) { + buffer_appendf(output, buf_size, pos, + " \033[2;37m%s\033[0m\n", + i18n_text(client->ui_lang, I18N_INBOX_EMPTY)); + } + for (int i = 0; i < snap_count; i++) { + char ts[20]; + struct tm tmi; + localtime_r(&snapshot[i].timestamp, &tmi); + strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); + buffer_appendf(output, buf_size, pos, + " \033[90m%s\033[0m \033[35m%s\033[0m: %s\n", + ts, snapshot[i].from, snapshot[i].content); + } +} + +bool commands_refresh_active_output(client_t *client) { + char output[MAX_COMMAND_OUTPUT_LEN] = {0}; + size_t pos = 0; + + if (!client || client->command_output_kind != TNT_COMMAND_OUTPUT_INBOX) { + return false; + } + + append_inbox_output(client, output, sizeof(output), &pos); + snprintf(client->command_output, sizeof(client->command_output), "%s", + output); + client->command_output_scroll = 0; + return true; +} + void commands_dispatch(client_t *client) { char cmd_buf[256]; strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1); cmd_buf[sizeof(cmd_buf) - 1] = '\0'; char *cmd = cmd_buf; char output[MAX_COMMAND_OUTPUT_LEN] = {0}; + tnt_command_output_kind_t output_kind = TNT_COMMAND_OUTPUT_GENERIC; size_t pos = 0; /* Trim whitespace */ @@ -219,9 +267,9 @@ void commands_dispatch(client_t *client) { snprintf(target->whisper_inbox[slot].content, sizeof(target->whisper_inbox[slot].content), "%s", rest); + target->unread_whispers++; pthread_mutex_unlock(&target->whisper_lock); - target->unread_whispers++; /* Audible nudge — the title bar ✉ counter (UX-11 style) * carries the persistent signal. */ client_queue_bell(target); @@ -242,35 +290,8 @@ void commands_dispatch(client_t *client) { } } else if (command_id == TNT_COMMAND_INBOX) { - /* 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->whisper_lock); - snap_count = client->whisper_inbox_count; - memcpy(snapshot, client->whisper_inbox, - snap_count * sizeof(whisper_t)); - pthread_mutex_unlock(&client->whisper_lock); - client->unread_whispers = 0; - - buffer_appendf(output, sizeof(output), &pos, - "\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n", - i18n_text(client->ui_lang, I18N_INBOX_TITLE), - snap_count); - if (snap_count == 0) { - buffer_appendf(output, sizeof(output), &pos, - " \033[2;37m%s\033[0m\n", - i18n_text(client->ui_lang, I18N_INBOX_EMPTY)); - } - for (int i = 0; i < snap_count; i++) { - char ts[20]; - struct tm tmi; - localtime_r(&snapshot[i].timestamp, &tmi); - strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); - buffer_appendf(output, sizeof(output), &pos, - " \033[90m%s\033[0m \033[35m%s\033[0m: %s\n", - ts, snapshot[i].from, snapshot[i].content); - } + output_kind = TNT_COMMAND_OUTPUT_INBOX; + append_inbox_output(client, output, sizeof(output), &pos); } else if (command_id == TNT_COMMAND_NICK) { const char *new_name = arg; @@ -414,6 +435,7 @@ void commands_dispatch(client_t *client) { cmd_done: snprintf(client->command_output, sizeof(client->command_output), "%s", output); client->command_output_scroll = 0; + client->command_output_kind = output_kind; client->command_input[0] = '\0'; tui_render_command_output(client); } diff --git a/src/help_text.c b/src/help_text.c index e1e221e..0a8c919 100644 --- a/src/help_text.c +++ b/src/help_text.c @@ -75,6 +75,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " Ctrl+D/U - Scroll half page down/up\n" " Ctrl+F/B - Scroll full page down/up\n" " g/G - Jump to top/bottom\n" + " r - Refresh live output (:inbox)\n" "\n" "SPECIAL MESSAGES:\n" " /me - Send action (e.g. /me waves)\n" @@ -94,6 +95,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+F/B - 向下/上滚动整页\n" " g/G - 跳到顶部/底部\n" + " r - 刷新动态输出 (:inbox)\n" "\n" "特殊消息:\n" " /me - 发送动作 (如 /me waves)\n" diff --git a/src/i18n_text.c b/src/i18n_text.c index cfb76f4..5fe3f60 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -57,6 +57,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom q:close", "-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 q:关闭" ), + [I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT] = I18N_STRING( + "-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom r:refresh q:close", + "-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 r:刷新 q:关闭" + ), [I18N_MOTD_TITLE] = I18N_STRING( " NOTICE ", " 公告 " diff --git a/src/input.c b/src/input.c index c122c0a..8cd2573 100644 --- a/src/input.c +++ b/src/input.c @@ -221,6 +221,7 @@ static void dismiss_command_output(client_t *client) { was_motd = client->show_motd; client->command_output[0] = '\0'; client->command_output_scroll = 0; + client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->show_motd = false; client->mode = MODE_NORMAL; if (was_motd) { @@ -349,6 +350,9 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { } else if (key == 'G') { client->command_output_scroll = 999; tui_render_command_output(client); + } else if ((key == 'r' || key == 'R') && + commands_refresh_active_output(client)) { + tui_render_command_output(client); } return true; /* Key consumed */ } @@ -735,6 +739,7 @@ void input_run_session(client_t *client) { client->command_history_count = 0; client->command_history_pos = 0; client->command_output_scroll = 0; + client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->connect_time = time(NULL); client->last_active = time(NULL); @@ -788,6 +793,7 @@ void input_run_session(client_t *client) { sizeof(client->command_output), "%s", motd_buf); client->command_output_scroll = 0; + client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->show_motd = true; tui_render_motd(client); seen_update_seq = room_get_update_seq(g_room); @@ -836,6 +842,13 @@ main_loop: room_updated = true; } + if (client->command_output_kind == TNT_COMMAND_OUTPUT_INBOX && + client->command_output[0] != '\0' && + client->unread_whispers > 0) { + commands_refresh_active_output(client); + client->redraw_pending = true; + } + if (client->redraw_pending || (room_updated && !client->show_help && client->command_output[0] == '\0')) { diff --git a/src/tui.c b/src/tui.c index 1330ac7..be2f185 100644 --- a/src/tui.c +++ b/src/tui.c @@ -677,7 +677,10 @@ void tui_render_command_output(client_t *client) { buffer_appendf(buffer, sizeof(buffer), &pos, i18n_text(client->ui_lang, - I18N_COMMAND_OUTPUT_STATUS_FORMAT), + client->command_output_kind == + TNT_COMMAND_OUTPUT_INBOX + ? I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT + : I18N_COMMAND_OUTPUT_STATUS_FORMAT), start + 1, max_scroll + 1); client_send(client, buffer, pos); diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index cc457e4..26322d6 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -304,6 +304,9 @@ expect ":" send -- "inbox\r" expect "Private messages" expect "(empty)" +expect "r:refresh" +send -- "r" +expect "Private messages" expect "q:close" send -- "q" expect "NORMAL" diff --git a/tests/test_user_lifecycle.sh b/tests/test_user_lifecycle.sh index e007ab7..e6a4e8f 100755 --- a/tests/test_user_lifecycle.sh +++ b/tests/test_user_lifecycle.sh @@ -36,7 +36,7 @@ fi SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" BOB_READY="$STATE_DIR/bob.ready" -ALICE_DONE="$STATE_DIR/alice.done" +PRIVATE_SENT="$STATE_DIR/private.sent" wait_for_health() { out="" @@ -80,14 +80,17 @@ spawn ssh $SSH_OPTS bob@127.0.0.1 sleep 1 send -- "bob\r" expect ":help" -exec touch "$BOB_READY" -exec sh -c "while \[ ! -f '$ALICE_DONE' \]; do sleep 1; done" send -- "\033" expect "NORMAL" send -- ":" expect ":" send -- "inbox\r" expect "私信" +expect "(空)" +expect "r:刷新" +exec touch "$BOB_READY" +exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done" +expect "私信" expect "alice" expect "private lifecycle ping" expect "q:关闭" @@ -194,6 +197,7 @@ send -- ":" expect ":" send -- "msg bob private lifecycle ping\r" expect "私信已发送给 bob" +exec touch "$PRIVATE_SENT" expect "q:关闭" send -- "q" expect "NORMAL" @@ -208,7 +212,6 @@ send -- "i" expect ":help" send -- "/me ships lifecycle\r" sleep 1 -exec touch "$ALICE_DONE" send -- "\003" sleep 0.2 send -- "\003" @@ -222,11 +225,11 @@ else echo "✗ primary user lifecycle failed" sed -n '1,240p' "$STATE_DIR/alice.log" FAIL=$((FAIL + 1)) - touch "$ALICE_DONE" + touch "$PRIVATE_SENT" fi if wait "$BOB_PID" 2>/dev/null; then - echo "✓ recipient read private-message inbox" + echo "✓ recipient inbox auto-refreshed after private message" PASS=$((PASS + 1)) else echo "✗ recipient inbox journey failed" diff --git a/tests/unit/test_help_text.c b/tests/unit/test_help_text.c index d7ca9aa..09b888c 100644 --- a/tests/unit/test_help_text.c +++ b/tests/unit/test_help_text.c @@ -29,6 +29,7 @@ TEST(full_help_matches_language) { assert(strstr(en, "AVAILABLE COMMANDS") != NULL); assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL); assert(strstr(en, ":inbox") != NULL); + assert(strstr(en, "Refresh live output") != NULL); assert(strstr(en, ":support") == NULL); assert(strstr(en, ":commands") == NULL); assert(strstr(en, "Cycle UI language") != NULL); @@ -38,6 +39,7 @@ TEST(full_help_matches_language) { assert(strstr(zh, "可用命令") != NULL); assert(strstr(zh, "命令输出按键") != NULL); assert(strstr(zh, ":inbox") != NULL); + assert(strstr(zh, "刷新动态输出") != NULL); assert(strstr(zh, "/me ") != NULL); assert(strstr(zh, "@username") != NULL); assert(strstr(zh, "<动作>") == NULL); diff --git a/tests/unit/test_i18n.c b/tests/unit/test_i18n.c index 1279f06..73675ed 100644 --- a/tests/unit/test_i18n.c +++ b/tests/unit/test_i18n.c @@ -111,6 +111,12 @@ TEST(text_lookup_matches_language) { "q:close") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT), "q:关闭") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, + I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT), + "r:refresh") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, + I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT), + "r:刷新") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_MOTD_CONTINUE_HINT), "Press any key") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT), diff --git a/tnt.1 b/tnt.1 index 4e207c1..cecd23f 100644 --- a/tnt.1 +++ b/tnt.1 @@ -199,6 +199,14 @@ l l. Up/Down Browse command history ESC Cancel and return to NORMAL .TE +.PP +Command output pages use j/k, Ctrl+D/Ctrl+U, and g/G for paging. +The +.B :inbox +page can also be refreshed with +.B r +and refreshes automatically when a new private message arrives while it is +open. .SH EXEC INTERFACE Commands can be run non\-interactively for scripting: .PP From 13b671cc9fef832b2010bfc641d38030ee2b723b Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 26 May 2026 14:20:07 +0800 Subject: [PATCH 05/31] Add slow-client backpressure regression --- Makefile | 6 +- README.md | 10 ++ docs/CHANGELOG.md | 5 + docs/CICD.md | 3 + docs/QUICKREF.md | 3 + docs/ROADMAP.md | 6 +- scripts/release_check.sh | 8 ++ tests/test_slow_client.sh | 223 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 259 insertions(+), 5 deletions(-) create mode 100755 tests/test_slow_client.sh diff --git a/Makefile b/Makefile index 52f4a88..db978bc 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ MANDIR ?= $(PREFIX)/share/man SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) -.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test user-lifecycle-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 soak-test slow-client-test user-lifecycle-test info all: $(TARGETS) @@ -148,6 +148,10 @@ soak-test: all @echo "Running soak tests..." @cd tests && PORT=$${PORT:-2222} ./test_soak.sh $${DURATION:-8} $${RECONNECTS:-5} +slow-client-test: all + @echo "Running slow-client tests..." + @cd tests && PORT=$${PORT:-2222} ./test_slow_client.sh $${DURATION:-8} $${BURST_CHARS:-1600} + user-lifecycle-test: all @echo "Running user lifecycle tests..." @cd tests && PORT=$${PORT:-2222} ./test_user_lifecycle.sh diff --git a/README.md b/README.md index e6a1cc3..8779303 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ make connection-limit-test # verify per-IP concurrency and rate limits make security-test # run security feature checks make stress-test # run configurable concurrent-client stress test make soak-test # run idle/reconnect/control-plane soak test +make slow-client-test # run slow interactive-client backpressure test make user-lifecycle-test # run a two-user TUI lifecycle test make ci-test # run the same checks as GitHub Actions @@ -249,6 +250,7 @@ cd tests ./test_connection_limits.sh # per-IP concurrency and rate limits ./test_stress.sh # stress test ./test_soak.sh # soak test +./test_slow_client.sh # slow-client backpressure ./test_user_lifecycle.sh # two-user TUI lifecycle ``` @@ -257,6 +259,8 @@ cd tests - Anonymous access: 2 tests - Security features: 12 tests - Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`) +- Slow-client test: an unread interactive SSH client cannot block health, + stats, post, tail, or server survival checks ### Dependencies @@ -361,6 +365,12 @@ Before preparing a release locally: make release-check ``` +Longer local preflight can opt into runtime soak and slow-client coverage: + +```sh +RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check +``` + Before publishing package recipes, replace placeholder checksums and run: ```sh diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 586430b..5033f81 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,9 @@ - Added a VHS tape draft for recording the core TNT terminal-chat experience. - Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and an open inbox refreshes when a new private message arrives. +- Added `make slow-client-test`, an opt-in regression for an unread + interactive SSH client under backpressure while health, stats, post, tail, + and server survival stay responsive. ### Changed - `make install-systemd` now rewrites the installed unit's `ExecStart` to match @@ -51,6 +54,8 @@ direct slow-reader blocking path. - `make release-check` can now run the soak test with `RUN_SOAK=1`, keeping longer runtime checks opt-in for local release validation. +- `make release-check` can also run the slow-client backpressure test with + `RUN_SLOW_CLIENT=1`. - Room capacity and mention notification bookkeeping now follow `TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit. - Updated the roadmap to reflect completed `tntctl`, stable exec contract, and diff --git a/docs/CICD.md b/docs/CICD.md index 96d05c6..fa62eab 100644 --- a/docs/CICD.md +++ b/docs/CICD.md @@ -41,6 +41,9 @@ Release policy: 2. Run the local preflight: make release-check + For a longer local runtime gate before publishing or production rollout: + RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check + 3. Commit the release changes and create a local tag. Do not push the tag until strict checks pass: git tag v1.0.1 diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 0f19bd0..efcb65d 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -15,6 +15,9 @@ TEST make connection-limit-test per-IP concurrency/rate-limit checks make security-test security feature checks make stress-test concurrent-client stress test + make soak-test idle/reconnect/control-plane soak test + make slow-client-test slow interactive-client backpressure test + make user-lifecycle-test two-user TUI lifecycle test make ci-test same checks as GitHub Actions DEBUG diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dcc7fd9..2c85a04 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -94,7 +94,7 @@ Goal: make regressions harder to introduce. - add sanitizer jobs and targeted fuzzing for UTF-8, log parsing, and command parsing - ✅ add a configurable soak test for idle sessions, reconnects, and control interface availability -- add deeper slow-client soak coverage with a deliberately backpressured SSH +- ✅ add deeper slow-client coverage with a deliberately backpressured SSH client - keep deployment and test docs aligned with actual runtime behavior - require every user-visible interface change to update docs and tests in the same change set @@ -106,7 +106,5 @@ These are the next changes that should happen before new feature work expands th 1. Decide the daemon naming path: keep `tnt` as the server binary for 1.x, or introduce `tntd` later with a compatibility plan. 2. Finish untangling client-state ownership into a clearer release path. -3. Add deeper slow-client soak coverage with a deliberately backpressured SSH - client. -4. Replace remaining release placeholders with real maintainer metadata and +3. Replace remaining release placeholders with real maintainer metadata and source-archive checksums when cutting a public package release. diff --git a/scripts/release_check.sh b/scripts/release_check.sh index a35d7fe..a80c1ad 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -21,6 +21,7 @@ Default checks: Environment: RUN_INTEGRATION=1 also run full make test RUN_SOAK=1 also run the configurable soak test + RUN_SLOW_CLIENT=1 also run the slow-client backpressure test PORT=12720 base port for integration tests Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a @@ -123,6 +124,13 @@ if [ "${RUN_SOAK:-0}" = "1" ]; then DURATION="${SOAK_DURATION:-8}" RECONNECTS="${SOAK_RECONNECTS:-5}" fi +if [ "${RUN_SLOW_CLIENT:-0}" = "1" ]; then + step "running slow-client test" + make slow-client-test PORT="$((${PORT:-12720} + 40))" \ + DURATION="${SLOW_CLIENT_DURATION:-8}" \ + BURST_CHARS="${SLOW_CLIENT_BURST_CHARS:-1600}" +fi + tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX") cleanup() { rm -rf "$tmpdir" diff --git a/tests/test_slow_client.sh b/tests/test_slow_client.sh new file mode 100755 index 0000000..7cce7e4 --- /dev/null +++ b/tests/test_slow_client.sh @@ -0,0 +1,223 @@ +#!/bin/sh +# Slow interactive-client regression test for TNT. +# Usage: ./test_slow_client.sh [hold_seconds] [burst_chars] + +PORT=${PORT:-2222} +HOLD_SECONDS=${1:-8} +BURST_CHARS=${2:-1600} +BIN="../tnt" +PASS=0 +FAIL=0 +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-slow-client-test.XXXXXX") +SERVER_PID="" +SLOW_PID="" + +cleanup() { + if [ -n "$SLOW_PID" ]; then + kill "$SLOW_PID" 2>/dev/null || true + wait "$SLOW_PID" 2>/dev/null || true + fi + exec 3>&- 2>/dev/null || true + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +case "$HOLD_SECONDS" in + ''|*[!0-9]*) + echo "Error: hold_seconds must be a positive integer" + exit 2 + ;; +esac + +case "$BURST_CHARS" in + ''|*[!0-9]*) + echo "Error: burst_chars must be a positive integer" + exit 2 + ;; +esac + +if [ "$HOLD_SECONDS" -lt 1 ] || [ "$BURST_CHARS" -lt 1 ]; then + echo "Error: hold_seconds and burst_chars must be positive" + exit 2 +fi + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=5 -p $PORT" +SSH_TTY_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 -p $PORT" + +run_ssh_timeout() { + seconds=$1 + outfile=$2 + shift 2 + + ssh $SSH_EXEC_OPTS "$@" >"$outfile" 2>&1 & + cmd_pid=$! + elapsed=0 + + while [ "$elapsed" -lt "$seconds" ]; do + if ! kill -0 "$cmd_pid" 2>/dev/null; then + wait "$cmd_pid" + return $? + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + + if kill -0 "$cmd_pid" 2>/dev/null; then + kill "$cmd_pid" 2>/dev/null || true + wait "$cmd_pid" 2>/dev/null || true + fi + return 124 +} + +wait_for_health() { + out="" + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + return 1 + fi + out=$(ssh $SSH_EXEC_OPTS localhost health 2>/dev/null || true) + [ "$out" = "ok" ] && return 0 + sleep 1 + done + return 1 +} + +wait_for_slow_user() { + out="" + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + return 1 + fi + out=$(ssh $SSH_EXEC_OPTS localhost users --json 2>/dev/null || true) + printf '%s\n' "$out" | grep -q '"slow"' && return 0 + sleep 1 + done + return 1 +} + +echo "=== TNT Slow Client Test ===" +echo "hold=${HOLD_SECONDS}s burst_chars=$BURST_CHARS port=$PORT" + +TNT_LANG=en "$BIN" \ + --bind 127.0.0.1 \ + --public-host slow.local \ + --max-connections 32 \ + --max-conn-per-ip 32 \ + --max-conn-rate-per-ip 64 \ + --rate-limit 0 \ + --idle-timeout 0 \ + --ssh-log-level 1 \ + -p "$PORT" \ + -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +if wait_for_health; then + echo "✓ server started" + PASS=$((PASS + 1)) +else + echo "✗ server failed to start" + sed -n '1,160p' "$STATE_DIR/server.log" + exit 1 +fi + +SLOW_FIFO="$STATE_DIR/slow.out" +mkfifo "$SLOW_FIFO" +exec 3<>"$SLOW_FIFO" + +( + printf 'slow\n' + sleep 2 + i=0 + while [ "$i" -lt "$BURST_CHARS" ]; do + printf 'x' + i=$((i + 1)) + done + sleep "$HOLD_SECONDS" +) | ssh $SSH_TTY_OPTS slow@127.0.0.1 >"$SLOW_FIFO" 2>"$STATE_DIR/slow.err" & +SLOW_PID=$! + +if wait_for_slow_user; then + echo "✓ deliberately unread interactive client reached chat" + PASS=$((PASS + 1)) +else + echo "✗ slow client did not reach chat" + sed -n '1,120p' "$STATE_DIR/slow.err" + FAIL=$((FAIL + 1)) +fi + +sleep 3 + +if run_ssh_timeout 5 "$STATE_DIR/health.out" localhost health && + grep -qx 'ok' "$STATE_DIR/health.out"; then + echo "✓ health stayed responsive while slow client was pressured" + PASS=$((PASS + 1)) +else + echo "✗ health blocked or returned unexpected output" + cat "$STATE_DIR/health.out" 2>/dev/null || true + FAIL=$((FAIL + 1)) +fi + +if run_ssh_timeout 5 "$STATE_DIR/stats.out" localhost stats --json && + grep -q '"status":"ok"' "$STATE_DIR/stats.out"; then + echo "✓ stats stayed responsive while slow client was pressured" + PASS=$((PASS + 1)) +else + echo "✗ stats blocked or returned unexpected output" + cat "$STATE_DIR/stats.out" 2>/dev/null || true + FAIL=$((FAIL + 1)) +fi + +FLOOD_FAIL=0 +i=1 +while [ "$i" -le 8 ]; do + msg=$(printf 'slow-client responsive post %02d %0900d' "$i" 0) + if ! run_ssh_timeout 5 "$STATE_DIR/post-$i.out" probe@localhost post "$msg" || + ! grep -qx 'posted' "$STATE_DIR/post-$i.out"; then + echo "✗ post blocked or failed during slow-client pressure at $i/8" + cat "$STATE_DIR/post-$i.out" 2>/dev/null || true + FAIL=$((FAIL + 1)) + FLOOD_FAIL=1 + break + fi + i=$((i + 1)) +done + +if [ "$FLOOD_FAIL" -eq 0 ]; then + echo "✓ post path stayed responsive during slow-client pressure" + PASS=$((PASS + 1)) +fi + +if run_ssh_timeout 5 "$STATE_DIR/tail.out" localhost "tail -n 5" && + grep -q 'slow-client responsive post 08' "$STATE_DIR/tail.out"; then + echo "✓ tail sees messages posted during slow-client pressure" + PASS=$((PASS + 1)) +else + echo "✗ tail missing slow-client pressure messages" + cat "$STATE_DIR/tail.out" 2>/dev/null || true + FAIL=$((FAIL + 1)) +fi + +if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "✓ server survived slow-client pressure" + PASS=$((PASS + 1)) +else + echo "✗ server exited during slow-client pressure" + sed -n '1,160p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" From cbaf02c7699c7cd66d3a3986e1606dddbf3fa8cb Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 26 May 2026 20:16:36 +0800 Subject: [PATCH 06/31] Document stable 1.x binary naming --- docs/CHANGELOG.md | 3 +++ docs/INTERFACE.md | 13 ++++++++++--- docs/ROADMAP.md | 10 ++++------ tnt.1 | 7 +++++++ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5033f81..49577d7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -35,6 +35,9 @@ - The release guide now documents SemVer expectations, manual release review, smoke testing, and rollback steps. - Package installs now include `tntctl` and its man page alongside `tnt`. +- The binary naming policy is now explicit: `tnt` remains the stable 1.x + server process name, and any future `tntd` split requires a major-version + compatibility plan. - SSH exec commands longer than the command buffer are now rejected with a usage error instead of being truncated and executed. - SSH exec `post` now persists the message before broadcasting or returning diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index 94c4e8e..ffe13af 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -3,14 +3,21 @@ 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. +For 1.x, the public binary names are stable: + +- `tnt` is the server process and daemon entrypoint. +- `tntctl` is a thin local wrapper around the SSH exec interface. + +TNT will not introduce a separate `tntd` binary during 1.x. If the project +ever splits the server into `tntd`, that change must ship with a major-version +compatibility plan, package migration notes, and a transition period for the +`tnt` command. ## Stability Scope Stable: +- public binary names for 1.x: `tnt` and `tntctl` - documented command-line flags in `tnt(1)` - documented environment variables in `tnt(1)` - SSH exec command names and argument shapes listed below diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2c85a04..086fb21 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,8 +28,8 @@ Goal: make TNT predictable for operators, scripts, and package maintainers. - `post` - ✅ 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 +- ✅ keep `tnt` as the 1.x server binary; reserve any future `tntd` split for a + major-version compatibility plan - ✅ add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-connections`, and related long options consistently - ✅ add man pages for `tnt` and `tntctl` @@ -103,8 +103,6 @@ Goal: make regressions harder to introduce. These are the next changes that should happen before new feature work expands the surface area. -1. Decide the daemon naming path: keep `tnt` as the server binary for 1.x, or - introduce `tntd` later with a compatibility plan. -2. Finish untangling client-state ownership into a clearer release path. -3. Replace remaining release placeholders with real maintainer metadata and +1. Finish untangling client-state ownership into a clearer release path. +2. Replace remaining release placeholders with real maintainer metadata and source-archive checksums when cutting a public package release. diff --git a/tnt.1 b/tnt.1 index cecd23f..c0d9496 100644 --- a/tnt.1 +++ b/tnt.1 @@ -34,6 +34,13 @@ COMMAND modes. Users connect with any standard SSH client; no account or registration is needed. .PP +In the 1.x series, +.B tnt +is the stable server process name. +Use +.BR tntctl (1) +for local control commands against a running server. +.PP Messages are persisted to a log file and restored on server restart. The server supports CJK and emoji input, rate limiting, access tokens, and a non\-interactive exec interface for scripting. From 2b43ce6a3e9c92ccdee1368307c17402a51d0213 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 26 May 2026 20:19:43 +0800 Subject: [PATCH 07/31] Refresh client ownership developer docs --- docs/CHANGELOG.md | 3 ++ docs/CONTRIBUTING.md | 6 +++- docs/Development-Guide.md | 65 ++++++++++++++++++++++++--------------- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 49577d7..ddfa6eb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -83,6 +83,9 @@ - Refreshed contributor and development guidance so new commands are added through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale `ssh_server.c` / inline-`strcmp` instructions. +- Refreshed developer ownership guidance to match the current update-sequence + model: room broadcasts update shared state only, while each interactive + client renders and flushes its own SSH channel. - `exec_catalog` now owns SSH exec command matching as well as help metadata, reducing duplicate command knowledge in `src/exec.c`. - Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 6632016..872d6d4 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -16,6 +16,9 @@ make release-check # release preflight make test # unit + integration tests make ci-test # local CI-equivalent checks make stress-test # concurrent-client stress test +make soak-test # idle/reconnect/control-plane soak +make slow-client-test # slow interactive-client backpressure +make user-lifecycle-test # two-user TUI lifecycle ``` ## Debug @@ -78,7 +81,8 @@ utf8.c → UTF-8 string handling ## Common Bugs to Avoid 1. Don't use `strtok()` on client data - use `strtok_r()` or copy first -2. Always increment ref_count before using client outside lock +2. Always use `client_addref()` / `client_release()` before using a client + outside `g_room->lock`; never modify `ref_count` directly 3. Check SSH API return values (can be SSH_ERROR, SSH_AGAIN, or negative) 4. UTF-8 chars are multi-byte - use utf8_* functions diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index b7c8b51..900531d 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -55,10 +55,13 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th ### Key Design Principles -1. **Fixed-size buffers** - No dynamic allocation in hot paths -2. **Reader-writer locks** - Multiple readers, single writer -3. **Reference counting** - Prevent use-after-free -4. **Ring buffer** - Fixed-size message history (last 100 messages) +1. **Fixed-size buffers** - Keep message, command, and UI buffers bounded +2. **Reader-writer locks** - Multiple readers, single writer for room state +3. **Per-client output ownership** - Each interactive session writes only to + its own SSH channel +4. **Reference counting** - Keep client objects alive across callbacks and + cross-thread lookups +5. **Ring buffer** - Fixed-size in-memory message history (last 100 messages) --- @@ -76,7 +79,7 @@ src/ ├── command_catalog.c - COMMAND-mode names, aliases, and help summaries ├── exec_catalog.c - SSH exec command matching and help metadata ├── exec.c - SSH exec command dispatch -├── chat_room.c - Chat room logic and message broadcasting +├── chat_room.c - Chat room state, message ring, and update sequence ├── message.c - Message persistence (RFC3339 format) ├── history_view.c - NORMAL-mode scroll window rules ├── tui.c - Terminal UI rendering (ANSI escape codes) @@ -119,12 +122,15 @@ typedef struct client { ssh_session session; ssh_channel channel; char username[MAX_USERNAME_LEN]; - int width, height; // Terminal dimensions + _Atomic int width, height; // Terminal dimensions client_mode_t mode; // INSERT/NORMAL/COMMAND int scroll_pos; - bool connected; + atomic_bool connected; + char *outbox; // Bounded queued interactive output + size_t outbox_len, outbox_pos; int ref_count; // Reference counting pthread_mutex_t ref_lock; + pthread_mutex_t io_lock; // Own SSH channel writes only } client_t; ``` @@ -134,6 +140,7 @@ typedef struct { pthread_rwlock_t lock; // Reader-writer lock struct client **clients; // Dynamic array int client_count; + uint64_t update_seq; // Bumped when message history changes message_t *messages; // Ring buffer int message_count; } chat_room_t; @@ -189,6 +196,9 @@ make anonymous-access-test # Verify default anonymous login behavior make connection-limit-test # Verify per-IP concurrency and rate limits make security-test # Run security feature checks make stress-test # Run configurable concurrent-client stress test +make soak-test # Run idle/reconnect/control-plane soak test +make slow-client-test # Run slow interactive-client backpressure test +make user-lifecycle-test # Run a two-user TUI lifecycle test make ci-test # Run the same checks as GitHub Actions # Individual tests @@ -197,6 +207,9 @@ cd tests ./test_security_features.sh # Security checks ./test_anonymous_access.sh # Anonymous access ./test_stress.sh # Concurrent connections +./test_soak.sh # Idle/reconnect soak +./test_slow_client.sh # Slow-client backpressure +./test_user_lifecycle.sh # Two-user TUI lifecycle ``` ### Test Coverage @@ -205,6 +218,10 @@ cd tests - **Security**: RSA keys, env vars, UTF-8 validation, buffer overflow protection - **Anonymous**: Passwordless access, any username - **Stress**: 10 concurrent clients for 30 seconds +- **Soak**: idle session, reconnect churn, health/stats/users/post/tail +- **Slow client**: unread interactive SSH client cannot block control paths +- **Lifecycle**: two-user TUI story covering help, history, search, private + messages, nickname, action messages, and persistence boundaries --- @@ -244,33 +261,29 @@ while ((!ctx->auth_success || ctx->channel == NULL || !ctx->channel_ready) && !t ### 2. Chat Room (chat_room.c) -**Thread-safe broadcasting:** +**Thread-safe message publication:** ```c void room_broadcast(chat_room_t *room, const message_t *msg) { pthread_rwlock_wrlock(&room->lock); - /* Copy client list with ref counting */ - client_t **clients_copy = calloc(...); - for (int i = 0; i < count; i++) { - clients_copy[i]->ref_count++; - } + room_add_message(room, msg); + room->update_seq++; - pthread_rwlock_unlock(&room->lock); // Release lock early - - /* Render outside lock (avoid deadlock) */ - for (int i = 0; i < count; i++) { - tui_render_screen(clients_copy[i]); - client_release(clients_copy[i]); - } + pthread_rwlock_unlock(&room->lock); } ``` **Why this works:** -- Copy client list while holding write lock -- Increment reference counts -- Release lock BEFORE rendering -- Render to all clients outside lock -- Decrement reference counts (may free clients) +- Broadcast updates shared room state only; it does not render or write to + any SSH channel. +- Each interactive session tracks `room_get_update_seq()` in its own + `input_run_session()` loop. +- When the sequence changes, the client renders and flushes its own output. +- This keeps slow SSH windows local to that client and prevents one recipient + from blocking a sender or the whole room. +- Cross-client lookups, such as mentions and private messages, must call + `client_addref()` before using a client pointer outside `g_room->lock`, then + `client_release()` when done. Do not increment `ref_count` directly. ### 3. Message Persistence (message.c) @@ -380,6 +393,8 @@ void utf8_remove_last_word(char *str) { ```sh tests/test_exec_mode.sh # exec command behavior tests/test_interactive_input.sh # COMMAND-mode/TUI behavior +tests/test_user_lifecycle.sh # end-to-end two-user TUI behavior +tests/test_slow_client.sh # slow SSH reader/backpressure behavior tests/unit/test_i18n.c # localized shared text tests/unit/test_command_catalog.c # interactive command metadata tests/unit/test_exec_catalog.c # exec command help metadata From ec507965b2dd590f5564f5d1d4dc3e47f6a528f8 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 09:11:07 +0800 Subject: [PATCH 08/31] Centralize client session ownership release --- docs/CHANGELOG.md | 3 +++ docs/Development-Guide.md | 4 ++++ docs/ROADMAP.md | 6 +++--- include/client.h | 20 +++++++++----------- include/ssh_server.h | 1 + scripts/release_check.sh | 4 ++++ src/bootstrap.c | 7 +------ src/client.c | 26 ++++++++++++++++++++++++++ src/input.c | 12 +----------- 9 files changed, 52 insertions(+), 31 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ddfa6eb..13a7045 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -48,6 +48,9 @@ - Interactive client writes now pass through a bounded per-client outbox and flush against the remote SSH window from that client's session loop. Exec sessions still write synchronously to preserve script output ordering. +- Session callback refs are now owned and released through `client.c`, so + bootstrap and interactive cleanup no longer need to manually mirror the + main-ref / callback-ref release sequence. - The two-user lifecycle test now covers opening `:inbox` before a private message arrives, matching the way users often leave an inbox page open. - Private-message inbox access now uses its own mutex instead of sharing the diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index 900531d..5541bfe 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -131,6 +131,7 @@ typedef struct client { int ref_count; // Reference counting pthread_mutex_t ref_lock; pthread_mutex_t io_lock; // Own SSH channel writes only + bool channel_callback_ref; // Ref held while callbacks are installed } client_t; ``` @@ -284,6 +285,9 @@ void room_broadcast(chat_room_t *room, const message_t *msg) { - Cross-client lookups, such as mentions and private messages, must call `client_addref()` before using a client pointer outside `g_room->lock`, then `client_release()` when done. Do not increment `ref_count` directly. +- Session callback lifetime is owned by `client.c`: `client_install_channel_callbacks()` + takes the callback ref, and `client_release_session()` removes callbacks and + releases both the callback ref and the session main ref. ### 3. Message Persistence (message.c) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 086fb21..6bd64f9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -38,7 +38,8 @@ Goal: make TNT predictable for operators, scripts, and package maintainers. Goal: make long-running operation boring and reliable. -- move client state to a clearer ownership model with one release path +- ✅ move session callback ownership into `client.c` and release sessions + through one `client_release_session()` path - ✅ remove cross-client SSH channel writes from mention and private-message notifications - continue replacing ad hoc cross-thread UI mutation with per-client event @@ -103,6 +104,5 @@ Goal: make regressions harder to introduce. These are the next changes that should happen before new feature work expands the surface area. -1. Finish untangling client-state ownership into a clearer release path. -2. Replace remaining release placeholders with real maintainer metadata and +1. 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 af06833..16cc4a4 100644 --- a/include/client.h +++ b/include/client.h @@ -32,20 +32,18 @@ int client_printf(client_t *client, const char *fmt, ...); /* Reference counting for safe cross-thread cleanup. * * Lifecycle: bootstrap_run() creates the client_t with ref_count = 1 - * (the "main" ref), then adds a second ref before installing the channel - * callbacks (the "callback" ref) so the client outlives any in-flight - * eof / close / window-change callback invocation. The interactive - * session releases both refs in its cleanup path; the final release - * frees the SSH session, channel, callback struct, and the client_t. */ + * (the "main" ref). client_install_channel_callbacks() takes a second + * ref owned by client.c while channel callbacks are installed, so the + * client outlives in-flight eof / close / window-change callbacks. + * input_run_session() ends ownership with client_release_session(). */ void client_addref(client_t *client); void client_release(client_t *client); +void client_release_session(client_t *client); -/* Install the post-bootstrap channel callbacks (window-change, eof, close) - * that target this client_t. Caller MUST have already added one - * client_addref() to keep the client alive across in-flight callback - * invocations; the matching client_release() happens during cleanup in - * input_run_session(). Returns 0 on success, -1 on failure (in which - * case the caller still owns both refs and must release them). */ +/* Install the post-bootstrap channel callbacks (window-change, eof, close). + * On success this function takes the callback reference described above. + * On failure no callback reference remains and the caller still owns only + * its original main reference. */ int client_install_channel_callbacks(client_t *client); #endif /* CLIENT_H */ diff --git a/include/ssh_server.h b/include/ssh_server.h index f584567..2bcd0e7 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -74,6 +74,7 @@ typedef struct client { pthread_mutex_t ref_lock; /* Lock for ref_count */ pthread_mutex_t io_lock; /* Serialize SSH channel writes */ pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */ + bool channel_callback_ref; /* client.c owns one ref while callbacks are installed */ struct ssh_channel_callbacks_struct *channel_cb; } client_t; diff --git a/scripts/release_check.sh b/scripts/release_check.sh index a80c1ad..27dedc6 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -109,6 +109,10 @@ step "checking client I/O ownership boundaries" fail "cross-client target-array writes must be queued through client_queue_bell" ! grep -n "pthread_mutex_lock(&.*->io_lock)" src/commands.c >/dev/null || fail "commands.c must not use SSH io_lock for in-memory command state" +! grep -n "client_addref(client)" src/bootstrap.c >/dev/null || + fail "bootstrap.c must let client_install_channel_callbacks own callback refs" +grep -q "client_release_session(client)" src/input.c || + fail "input.c must release session ownership through client_release_session" if grep -R "ssh_channel_write" src include | grep -v "^src/client.c:" >/dev/null; then fail "raw SSH channel writes must stay inside src/client.c" fi diff --git a/src/bootstrap.c b/src/bootstrap.c index b08d84c..5c91e42 100644 --- a/src/bootstrap.c +++ b/src/bootstrap.c @@ -476,17 +476,12 @@ void *bootstrap_run(void *arg) { } 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. */ - client_addref(client); - if (client_install_channel_callbacks(client) < 0) { /* Nullify session/channel ownership so client_release won't * double-free what cleanup_failed_session is about to free. */ client->session = NULL; client->channel = NULL; - client_release(client); /* drop the callback ref (2 → 1) */ - client_release(client); /* drop the main ref (1 → 0, frees client) */ + client_release(client); cleanup_failed_session(session, ctx); return NULL; } diff --git a/src/client.c b/src/client.c index d5a5c8d..aff4a9e 100644 --- a/src/client.c +++ b/src/client.c @@ -244,6 +244,25 @@ void client_release(client_t *client) { } } +void client_release_session(client_t *client) { + if (!client) return; + + if (client->channel && client->channel_cb) { + ssh_remove_channel_callbacks(client->channel, client->channel_cb); + } + if (client->channel_cb) { + free(client->channel_cb); + client->channel_cb = NULL; + } + + if (client->channel_callback_ref) { + client->channel_callback_ref = false; + client_release(client); + } + + client_release(client); +} + /* Send formatted string to client */ int client_printf(client_t *client, const char *fmt, ...) { char buffer[2048]; @@ -315,8 +334,13 @@ int client_install_channel_callbacks(client_t *client) { return -1; } + client_addref(client); + client->channel_callback_ref = true; + client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct)); if (!client->channel_cb) { + client->channel_callback_ref = false; + client_release(client); return -1; } @@ -330,6 +354,8 @@ int client_install_channel_callbacks(client_t *client) { if (ssh_set_channel_callbacks(client->channel, client->channel_cb) != SSH_OK) { free(client->channel_cb); client->channel_cb = NULL; + client->channel_callback_ref = false; + client_release(client); return -1; } diff --git a/src/input.c b/src/input.c index 8cd2573..cf54e22 100644 --- a/src/input.c +++ b/src/input.c @@ -995,17 +995,7 @@ cleanup: ratelimit_release_ip(client->client_ip); - /* Remove channel callbacks before releasing refs to prevent use-after-free - * if a callback fires between the two releases. */ - if (client->channel && client->channel_cb) { - ssh_remove_channel_callbacks(client->channel, client->channel_cb); - } - - /* Release the callback reference (paired with addref before client_install_channel_callbacks) */ - client_release(client); - - /* Release the main reference - client will be freed when all refs are gone */ - client_release(client); + client_release_session(client); /* Decrement connection count */ ratelimit_decrement_total(); From ceffe59234265d0cd5f264fa62e27dc37ae94610 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 09:18:23 +0800 Subject: [PATCH 09/31] Harden message log replay parsing --- docs/CHANGELOG.md | 3 + docs/Development-Guide.md | 5 ++ docs/ROADMAP.md | 4 +- src/message.c | 161 +++++++++++++++++++++----------------- tests/unit/test_message.c | 89 +++++++++++++++++++++ 5 files changed, 187 insertions(+), 75 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 13a7045..1b0c4cc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -51,6 +51,9 @@ - Session callback refs are now owned and released through `client.c`, so bootstrap and interactive cleanup no longer need to manually mirror the main-ref / callback-ref release sequence. +- Message-log replay and search now share one strict record parser and skip + malformed, invalid UTF-8, extra-separator, oversized, or unterminated + records instead of accepting partial replay data. - The two-user lifecycle test now covers opening `:inbox` before a private message arrives, matching the way users often leave an inbox page open. - Private-message inbox access now uses its own mutex instead of sharing the diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index 5541bfe..362c6ce 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -296,6 +296,11 @@ void room_broadcast(chat_room_t *room, const message_t *msg) { 2024-01-13T10:30:45Z|username|message content ``` +Log replay and search use the same strict parser. A record is accepted only +when it has exactly three fields, a strict UTC RFC3339 timestamp, valid UTF-8 +username/content, bounded field lengths, and a trailing newline. Unterminated +last lines are treated as partial writes and skipped. + **Optimized loading** (backward scan): ```c /* Scan backwards from file end */ diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6bd64f9..7b70a1e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -56,10 +56,10 @@ Goal: make stored history durable, inspectable, and recoverable. - formalize the message log format and version it - keep timestamps in a timezone-safe format throughout write and replay -- validate persisted UTF-8 and record structure before replay +- ✅ validate persisted UTF-8 and record structure before replay/search - add log rotation and compaction tooling - provide an offline inspection/export command -- define recovery behavior for truncated or partially corrupted logs +- define broader recovery behavior for truncated or partially corrupted logs ## Stage 4: Interactive UX diff --git a/src/message.c b/src/message.c index e48b5cd..667795c 100644 --- a/src/message.c +++ b/src/message.c @@ -26,6 +26,82 @@ static time_t parse_rfc3339_utc(const char *timestamp_str) { return timegm(&tm); } +static void discard_line_remainder(FILE *fp) { + int c; + + while ((c = fgetc(fp)) != '\n' && c != EOF) { + } +} + +static bool parse_log_record(const char *line, message_t *out, + time_t now) { + char line_copy[2048]; + char *first_sep; + char *second_sep; + char *timestamp_str; + char *username; + char *content; + time_t msg_time; + size_t line_len; + + if (!line || !out) { + return false; + } + + line_len = strlen(line); + if (line_len == 0 || line[line_len - 1] != '\n') { + return false; + } + if (line_len >= sizeof(line_copy)) { + return false; + } + + memcpy(line_copy, line, line_len + 1); + line_copy[line_len - 1] = '\0'; + + first_sep = strchr(line_copy, '|'); + if (!first_sep) { + return false; + } + second_sep = strchr(first_sep + 1, '|'); + if (!second_sep || strchr(second_sep + 1, '|')) { + return false; + } + + *first_sep = '\0'; + *second_sep = '\0'; + timestamp_str = line_copy; + username = first_sep + 1; + content = second_sep + 1; + + if (timestamp_str[0] == '\0' || username[0] == '\0' || + content[0] == '\0') { + return false; + } + if (strlen(username) >= MAX_USERNAME_LEN || + strlen(content) >= MAX_MESSAGE_LEN) { + return false; + } + if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) { + return false; + } + + msg_time = parse_rfc3339_utc(timestamp_str); + if (msg_time == (time_t)-1) { + return false; + } + if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) { + return false; + } + + out->timestamp = msg_time; + strncpy(out->username, username, MAX_USERNAME_LEN - 1); + out->username[MAX_USERNAME_LEN - 1] = '\0'; + strncpy(out->content, content, MAX_MESSAGE_LEN - 1); + out->content[MAX_MESSAGE_LEN - 1] = '\0'; + return true; +} + /* Initialize message subsystem */ void message_init(void) { /* Nothing to initialize for now */ @@ -120,65 +196,23 @@ int message_load(message_t **messages, int max_messages) { read_messages:; char line[2048]; int count = 0; + time_t now = time(NULL); /* Now read forward */ while (fgets(line, sizeof(line), fp) && count < max_messages) { /* Check for oversized lines */ size_t line_len = strlen(line); - if (line_len >= sizeof(line) - 1) { - /* Skip remainder of line */ - int c; - while ((c = fgetc(fp)) != '\n' && c != EOF); + if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') { + discard_line_remainder(fp); continue; } - /* Format: RFC3339_timestamp|username|content */ - char line_copy[2048]; - strncpy(line_copy, line, sizeof(line_copy) - 1); - line_copy[sizeof(line_copy) - 1] = '\0'; - - char *timestamp_str = strtok(line_copy, "|"); - char *username = strtok(NULL, "|"); - char *content = strtok(NULL, "\n"); - - /* Validate all fields exist and are non-empty */ - if (!timestamp_str || !username || !content) { - continue; - } - if (username[0] == '\0') { + message_t parsed; + if (!parse_log_record(line, &parsed, now)) { continue; } - /* Validate field lengths */ - if (strlen(username) >= MAX_USERNAME_LEN) { - continue; - } - if (strlen(content) >= MAX_MESSAGE_LEN) { - continue; - } - - if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) { - continue; - } - - /* Parse strict UTC RFC3339 timestamp */ - time_t msg_time = parse_rfc3339_utc(timestamp_str); - if (msg_time == (time_t)-1) { - continue; - } - - /* Validate timestamp is reasonable (not in far future or past) */ - time_t now = time(NULL); - if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) { - continue; - } - - msg_array[count].timestamp = msg_time; - strncpy(msg_array[count].username, username, MAX_USERNAME_LEN - 1); - msg_array[count].username[MAX_USERNAME_LEN - 1] = '\0'; - strncpy(msg_array[count].content, content, MAX_MESSAGE_LEN - 1); - msg_array[count].content[MAX_MESSAGE_LEN - 1] = '\0'; - count++; + msg_array[count++] = parsed; } fclose(fp); @@ -276,38 +310,19 @@ int message_search(const char *query, message_t **results, int max_results) { char line[2048]; int count = 0; + time_t now = time(NULL); while (fgets(line, sizeof(line), fp)) { size_t line_len = strlen(line); - if (line_len >= sizeof(line) - 1) { - int c; - while ((c = fgetc(fp)) != '\n' && c != EOF); + if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') { + discard_line_remainder(fp); continue; } - char line_copy[2048]; - strncpy(line_copy, line, sizeof(line_copy) - 1); - line_copy[sizeof(line_copy) - 1] = '\0'; - - char *timestamp_str = strtok(line_copy, "|"); - char *username = strtok(NULL, "|"); - char *content = strtok(NULL, "\n"); - - if (!timestamp_str || !username || !content || username[0] == '\0') continue; - if (strlen(username) >= MAX_USERNAME_LEN || strlen(content) >= MAX_MESSAGE_LEN) continue; - if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) continue; - - if (strcasestr(username, query) == NULL && strcasestr(content, query) == NULL) continue; - - time_t msg_time = parse_rfc3339_utc(timestamp_str); - if (msg_time == (time_t)-1) continue; - message_t m; - m.timestamp = msg_time; - strncpy(m.username, username, MAX_USERNAME_LEN - 1); - m.username[MAX_USERNAME_LEN - 1] = '\0'; - strncpy(m.content, content, MAX_MESSAGE_LEN - 1); - m.content[MAX_MESSAGE_LEN - 1] = '\0'; + if (!parse_log_record(line, &m, now)) continue; + if (strcasestr(m.username, query) == NULL && + strcasestr(m.content, query) == NULL) continue; if (count < max_results) { res[count++] = m; diff --git a/tests/unit/test_message.c b/tests/unit/test_message.c index 0eb9012..060fa0d 100644 --- a/tests/unit/test_message.c +++ b/tests/unit/test_message.c @@ -3,8 +3,10 @@ #include #include #include +#include #include #include +#include #define TEST(name) static void test_##name() #define RUN_TEST(name) do { \ @@ -16,12 +18,45 @@ static int tests_passed = 0; static const char *test_log = "test_messages.log"; +static char test_state_dir[PATH_MAX]; /* Helper: Clean up test log file */ static void cleanup_test_log(void) { unlink(test_log); } +static void cleanup_state_dir(void) { + if (test_state_dir[0] != '\0') { + char log_path[PATH_MAX]; + snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir); + unlink(log_path); + rmdir(test_state_dir); + test_state_dir[0] = '\0'; + } + unsetenv("TNT_STATE_DIR"); +} + +static void setup_state_dir(void) { + const char *tmp = getenv("TMPDIR"); + + cleanup_state_dir(); + if (!tmp || tmp[0] == '\0') { + tmp = "/tmp"; + } + snprintf(test_state_dir, sizeof(test_state_dir), + "%s/tnt-message-test.XXXXXX", tmp); + assert(mkdtemp(test_state_dir) != NULL); + assert(setenv("TNT_STATE_DIR", test_state_dir, 1) == 0); +} + +static void format_rfc3339_now(char *buffer, size_t buf_size) { + time_t now = time(NULL); + struct tm tm_info; + + gmtime_r(&now, &tm_info); + strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info); +} + /* Test message initialization */ TEST(message_init) { message_init(); @@ -122,6 +157,57 @@ TEST(message_save_basic) { cleanup_test_log(); } +TEST(message_load_skips_malformed_records) { + char ts[64]; + char log_path[PATH_MAX]; + message_t *messages = NULL; + + setup_state_dir(); + format_rfc3339_now(ts, sizeof(ts)); + snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir); + + FILE *fp = fopen(log_path, "wb"); + assert(fp != NULL); + fprintf(fp, "%s|alice|valid one\n", ts); + fprintf(fp, "not-a-date|bob|bad date\n"); + fprintf(fp, "%s||empty user\n", ts); + fprintf(fp, "%s|mallory|extra|pipe\n", ts); + fprintf(fp, "%s|badutf|bad \xC3\x28\n", ts); + fprintf(fp, "%s|partial|truncated record", ts); + fclose(fp); + + int count = message_load(&messages, 10); + assert(count == 1); + assert(strcmp(messages[0].username, "alice") == 0); + assert(strcmp(messages[0].content, "valid one") == 0); + free(messages); + cleanup_state_dir(); +} + +TEST(message_search_skips_malformed_records) { + char ts[64]; + char log_path[PATH_MAX]; + message_t *results = NULL; + + setup_state_dir(); + format_rfc3339_now(ts, sizeof(ts)); + snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir); + + FILE *fp = fopen(log_path, "wb"); + assert(fp != NULL); + fprintf(fp, "%s|alice|needle valid\n", ts); + fprintf(fp, "%s|mallory|needle extra|pipe\n", ts); + fprintf(fp, "%s|partial|needle truncated", ts); + fclose(fp); + + int count = message_search("needle", &results, 10); + assert(count == 1); + assert(strcmp(results[0].username, "alice") == 0); + assert(strcmp(results[0].content, "needle valid") == 0); + free(results); + cleanup_state_dir(); +} + /* Test edge cases */ TEST(message_edge_cases) { message_t msg; @@ -215,12 +301,15 @@ int main(void) { RUN_TEST(message_format_unicode); RUN_TEST(message_format_width_limits); RUN_TEST(message_save_basic); + RUN_TEST(message_load_skips_malformed_records); + RUN_TEST(message_search_skips_malformed_records); RUN_TEST(message_edge_cases); RUN_TEST(message_special_characters); RUN_TEST(message_buffer_safety); RUN_TEST(message_timestamp_formats); cleanup_test_log(); + cleanup_state_dir(); printf("\n✓ All %d tests passed!\n", tests_passed); return 0; From 7b5a6835570d6d39ef35906583e1e8ad9c979790 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 09:21:59 +0800 Subject: [PATCH 10/31] Document message log v1 contract --- README.md | 3 ++ docs/CHANGELOG.md | 2 ++ docs/Development-Guide.md | 3 ++ docs/INTERFACE.md | 4 ++- docs/MESSAGE_LOG.md | 60 +++++++++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 6 ++-- tnt.1 | 6 +++- 7 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 docs/MESSAGE_LOG.md diff --git a/README.md b/README.md index 8779303..7d5f5d4 100644 --- a/README.md +++ b/README.md @@ -386,6 +386,9 @@ motd.txt - Message of the Day (optional, shown to users on connect) tnt.service - systemd service unit ``` +The persisted chat-history format is documented in +[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md). + ### MOTD (Message of the Day) Place a `motd.txt` file in the state directory to show a welcome message to every user on connect. Users see the MOTD before entering the chat and press any key to continue. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1b0c4cc..da8e83b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - Documented the stable SSH exec interface contract, including exit statuses and JSON field shapes for package tests, scripts, and future `tntctl` work. +- Documented `messages.log` v1 as the stable TNT 1.x persisted history format, + including parser, sanitization, and partial-record recovery rules. - Added 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 diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index 362c6ce..a52eceb 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -291,6 +291,9 @@ void room_broadcast(chat_room_t *room, const message_t *msg) { ### 3. Message Persistence (message.c) +See [MESSAGE_LOG.md](MESSAGE_LOG.md) for the stable TNT 1.x on-disk record +contract. + **Log format:** ``` 2024-01-13T10:30:45Z|username|message content diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index ffe13af..de9b65a 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -23,12 +23,14 @@ Stable: - SSH exec command names and argument shapes listed below - SSH exec exit statuses - JSON field names and value types for documented `--json` commands +- `messages.log` v1 record format documented in + [MESSAGE_LOG.md](MESSAGE_LOG.md) Not yet stable: - exact human-readable diagnostic wording - interactive TUI layout -- on-disk message log format +- future storage migration tooling - internal module names and helper functions ## Exit Status diff --git a/docs/MESSAGE_LOG.md b/docs/MESSAGE_LOG.md new file mode 100644 index 0000000..4b7517f --- /dev/null +++ b/docs/MESSAGE_LOG.md @@ -0,0 +1,60 @@ +# Message Log + +This document defines the persisted chat-history format used by TNT 1.x. + +## Format: `messages.log` v1 + +Each record is one UTF-8 line: + +```text +RFC3339_UTC|username|content\n +``` + +Example: + +```text +2026-05-27T12:34:56Z|alice|hello +``` + +Rules: + +- Timestamp is strict UTC RFC3339: `YYYY-MM-DDTHH:MM:SSZ`. +- The separator is literal `|`. +- A valid record has exactly three fields and exactly two separators. +- `username` and `content` must be non-empty valid UTF-8. +- `username` must fit `MAX_USERNAME_LEN`; `content` must fit + `MAX_MESSAGE_LEN`. +- Every complete record ends with `\n`. + +The file has no header. The version is defined by this record contract so +existing append-only logs remain readable. + +## Write Behavior + +`message_save()` sanitizes fields before appending: + +- `|`, `\n`, and `\r` in usernames become `_`. +- `|`, `\n`, and `\r` in content become spaces. +- Timestamps are written in UTC. + +Private messages are not written to `messages.log`. + +## Replay And Search + +Replay and search use the same strict parser. TNT skips records that are: + +- malformed or missing fields +- invalid UTF-8 +- too long +- outside the accepted timestamp window +- terminated without a trailing newline +- written with extra separators + +Skipping a bad record is intentional recovery behavior. A truncated final +line is treated as a partial append and ignored rather than replayed. + +## Compatibility + +The v1 record format is stable for TNT 1.x. Future incompatible storage +changes must document downgrade behavior in release notes and provide an +operator-visible migration or export path. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7b70a1e..2b03a25 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,12 +54,12 @@ Goal: make long-running operation boring and reliable. Goal: make stored history durable, inspectable, and recoverable. -- formalize the message log format and version it -- keep timestamps in a timezone-safe format throughout write and replay +- ✅ formalize the message log v1 format +- ✅ keep persisted timestamps in UTC throughout write and replay - ✅ validate persisted UTF-8 and record structure before replay/search - add log rotation and compaction tooling - provide an offline inspection/export command -- define broader recovery behavior for truncated or partially corrupted logs +- define broader recovery tooling for truncated or partially corrupted logs ## Stage 4: Interactive UX diff --git a/tnt.1 b/tnt.1 index c0d9496..85f879d 100644 --- a/tnt.1 +++ b/tnt.1 @@ -302,9 +302,13 @@ libssh log verbosity from 0 to 4 (default: 1). .SH FILES .TP .I messages.log -Chat history in RFC\ 3339 pipe\-delimited format +Chat history in the TNT message log v1 format: +RFC\ 3339 UTC pipe\-delimited records .RI ( timestamp | username | content ). Stored in the state directory. +See +.I docs/MESSAGE_LOG.md +in the source distribution for parser and recovery rules. .TP .I host_key RSA 4096\-bit host key, auto\-generated on first run. From 8b55a3d9abe5ead83a7eacbdb8a904cc8c1f7829 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 09:37:51 +0800 Subject: [PATCH 11/31] Add persisted message dump command --- README.md | 2 + docs/CHANGELOG.md | 2 + docs/INTERFACE.md | 18 ++++ docs/MESSAGE_LOG.md | 7 ++ docs/QUICKREF.md | 8 ++ docs/ROADMAP.md | 2 +- docs/USER_LIFECYCLE.md | 2 +- include/exec_catalog.h | 1 + include/message.h | 5 + src/exec.c | 62 +++++++++++++ src/exec_catalog.c | 8 ++ src/message.c | 165 +++++++++++++++++++++++++++++++++ src/tntctl.c | 3 +- tests/test_exec_mode.sh | 35 +++++++ tests/test_tntctl_cli.sh | 11 +++ tests/unit/test_exec_catalog.c | 13 ++- tests/unit/test_message.c | 48 ++++++++++ tnt.1 | 1 + tntctl.1 | 7 ++ 19 files changed, 395 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7d5f5d4..bd48bd6 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ ssh -p 2222 chat.example.com health ssh -p 2222 chat.example.com stats --json ssh -p 2222 chat.example.com users ssh -p 2222 chat.example.com "tail -n 20" +ssh -p 2222 chat.example.com "dump -n 100" ssh -p 2222 operator@chat.example.com post "service notice" ssh -p 2222 chat.example.com post "/me deploys v2.0" ``` @@ -212,6 +213,7 @@ around the same SSH exec interface: ```sh tntctl chat.example.com health tntctl -p 2222 chat.example.com stats --json +tntctl -p 2222 chat.example.com dump -n 100 tntctl -l operator chat.example.com post "service notice" ``` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index da8e83b..d010817 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ and JSON field shapes for package tests, scripts, and future `tntctl` work. - Documented `messages.log` v1 as the stable TNT 1.x persisted history format, including parser, sanitization, and partial-record recovery rules. +- Added `dump [N]` / `dump -n N` to the SSH exec interface and `tntctl` for + exporting valid persisted `messages.log` v1 records. - Added 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 diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index de9b65a..6351a86 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -56,6 +56,7 @@ ssh -p 2222 chat.example.com health ssh -p 2222 chat.example.com stats --json ssh -p 2222 chat.example.com users --json ssh -p 2222 chat.example.com "tail -n 20" +ssh -p 2222 chat.example.com "dump -n 100" ssh -p 2222 operator@chat.example.com post "service notice" ``` @@ -64,6 +65,7 @@ The same commands can be run through `tntctl`: ```sh tntctl chat.example.com health tntctl -p 2222 chat.example.com stats --json +tntctl -p 2222 chat.example.com dump -n 100 tntctl -l operator chat.example.com post "service notice" tntctl --host-key-checking accept-new chat.example.com users ``` @@ -128,6 +130,22 @@ Prints recent in-memory messages as tab-separated lines: The current upper bound is `MAX_MESSAGES`. This command reads the live in-memory room buffer, not the full persisted log. +### `dump [N]` / `dump -n N` + +Exports valid persisted `messages.log` v1 records in chronological order: + +```text +2026-05-25T12:00:00Z|alice|hello +``` + +Without `N`, `dump` exports all valid persisted records. With `N`, it exports +the last `N` valid persisted records. Malformed, invalid UTF-8, oversized, or +truncated records are skipped by the same strict parser used for replay and +search. + +This command reads the on-disk log, not the live in-memory room buffer. A +missing log produces empty output and exit status `0`. + ### `post MESSAGE` Posts a message as the SSH login name and prints: diff --git a/docs/MESSAGE_LOG.md b/docs/MESSAGE_LOG.md index 4b7517f..b06fa9e 100644 --- a/docs/MESSAGE_LOG.md +++ b/docs/MESSAGE_LOG.md @@ -53,6 +53,13 @@ Replay and search use the same strict parser. TNT skips records that are: Skipping a bad record is intentional recovery behavior. A truncated final line is treated as a partial append and ignored rather than replayed. +## Export + +`dump [N]` and `dump -n N` export valid persisted records through the SSH exec +interface and `tntctl`. The output format is exactly the v1 record format +above. Without `N`, `dump` exports all valid records; with `N`, it exports the +last `N` valid records. + ## Compatibility The v1 record format is stable for TNT 1.x. Future incompatible storage diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index efcb65d..7bef386 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -46,6 +46,14 @@ INSERT MODE limit 1023 bytes/message; over-limit input rings bell normal opens/follows latest; k/PgUp older, j/PgDn newer +EXEC COMMANDS + health print service health + stats [--json] print room statistics + users [--json] list online users + tail [N] / tail -n N recent in-memory room messages + dump [N] / dump -n N persisted messages.log v1 records + post post as the SSH login name + STRUCTURE src/main.c entry, signals src/cli_text.c startup CLI text diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2b03a25..91b0dac 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -57,8 +57,8 @@ Goal: make stored history durable, inspectable, and recoverable. - ✅ formalize the message log v1 format - ✅ keep persisted timestamps in UTC throughout write and replay - ✅ validate persisted UTF-8 and record structure before replay/search +- ✅ provide an inspection/export command for persisted records - add log rotation and compaction tooling -- provide an offline inspection/export command - define broader recovery tooling for truncated or partially corrupted logs ## Stage 4: Interactive UX diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md index 43c9bd9..1f03184 100644 --- a/docs/USER_LIFECYCLE.md +++ b/docs/USER_LIFECYCLE.md @@ -14,7 +14,7 @@ The product path should stay short: 7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`, and `:q`. 8. Scripts and operators use `tntctl` or SSH exec commands for `health`, - `stats`, `users`, `tail`, and `post`. + `stats`, `users`, `tail`, `dump`, and `post`. ## TUI Experience Notes diff --git a/include/exec_catalog.h b/include/exec_catalog.h index 69ae5ac..cc7d573 100644 --- a/include/exec_catalog.h +++ b/include/exec_catalog.h @@ -9,6 +9,7 @@ typedef enum { TNT_EXEC_COMMAND_USERS, TNT_EXEC_COMMAND_STATS, TNT_EXEC_COMMAND_TAIL, + TNT_EXEC_COMMAND_DUMP, TNT_EXEC_COMMAND_POST, TNT_EXEC_COMMAND_EXIT } tnt_exec_command_id_t; diff --git a/include/message.h b/include/message.h index 3da4800..0bf3803 100644 --- a/include/message.h +++ b/include/message.h @@ -26,4 +26,9 @@ void message_format(const message_t *msg, char *buffer, size_t buf_size, int wid * Returns the last max_results matches in chronological order; caller must free *results. */ int message_search(const char *query, message_t **results, int max_results); +/* Export valid persisted log records in messages.log v1 format. max_records + * 0 exports all valid records; positive values export the last max_records + * valid records. Caller must free *output. */ +int message_dump_text(char **output, size_t *output_len, int max_records); + #endif /* MESSAGE_H */ diff --git a/src/exec.c b/src/exec.c index 524d70c..a1cccf7 100644 --- a/src/exec.c +++ b/src/exec.c @@ -291,6 +291,45 @@ static int parse_tail_count(const char *args, int *count) { return 0; } +static int parse_dump_count(const char *args, int *count) { + char *end = NULL; + long value; + + if (!count) { + return -1; + } + + *count = 0; + if (!args || args[0] == '\0') { + return 0; + } + + if (strncmp(args, "-n", 2) == 0) { + args += 2; + while (*args && isspace((unsigned char)*args)) { + args++; + } + } + + value = strtol(args, &end, 10); + if (end == args) { + return -1; + } + while (*end) { + if (!isspace((unsigned char)*end)) { + return -1; + } + end++; + } + + if (value < 1 || value > 10000) { + return -1; + } + + *count = (int)value; + return 0; +} + static int exec_command_tail(client_t *client, const char *args) { int requested = 20; int total_messages; @@ -347,6 +386,27 @@ static int exec_command_tail(client_t *client, const char *args) { return rc; } +static int exec_command_dump(client_t *client, const char *args) { + int requested = 0; + char *output = NULL; + size_t output_len = 0; + int rc; + + if (parse_dump_count(args, &requested) < 0) { + return exec_command_usage(client, TNT_EXEC_COMMAND_DUMP); + } + + if (message_dump_text(&output, &output_len, requested) < 0) { + client_printf(client, "dump: failed to read message log\n"); + return TNT_EXIT_ERROR; + } + + rc = client_send(client, output, output_len) == 0 ? TNT_EXIT_OK + : TNT_EXIT_ERROR; + free(output); + return rc; +} + static int exec_command_post(client_t *client, const char *args) { char content[MAX_MESSAGE_LEN]; char username[MAX_USERNAME_LEN]; @@ -451,6 +511,8 @@ int exec_dispatch(client_t *client) { return exec_command_stats(client, args != NULL); case TNT_EXEC_COMMAND_TAIL: return exec_command_tail(client, args); + case TNT_EXEC_COMMAND_DUMP: + return exec_command_dump(client, args); case TNT_EXEC_COMMAND_POST: return exec_command_post(client, args); case TNT_EXEC_COMMAND_EXIT: diff --git a/src/exec_catalog.c b/src/exec_catalog.c index 20bd420..d06e594 100644 --- a/src/exec_catalog.c +++ b/src/exec_catalog.c @@ -38,6 +38,14 @@ static const exec_catalog_entry_t entries[] = { "tail -n N", "tail [N] | tail -n N", I18N_STRING("Print recent messages", "输出最近消息"), false, false, false}, + {TNT_EXEC_COMMAND_DUMP, "dump", NULL, + "dump [N]", "dump [N] | dump -n N", + I18N_STRING("Export persisted messages", "导出持久化消息"), + false, false, false}, + {TNT_EXEC_COMMAND_DUMP, "dump", NULL, + "dump -n N", "dump [N] | dump -n N", + I18N_STRING("Export persisted messages", "导出持久化消息"), + false, false, false}, {TNT_EXEC_COMMAND_POST, "post", NULL, "post MESSAGE", "post MESSAGE", I18N_STRING("Post a message non-interactively", "非交互发送消息"), diff --git a/src/message.c b/src/message.c index 667795c..c71e6c1 100644 --- a/src/message.c +++ b/src/message.c @@ -6,6 +6,7 @@ #endif #include "message.h" #include "utf8.h" +#include #include #include @@ -26,6 +27,17 @@ static time_t parse_rfc3339_utc(const char *timestamp_str) { return timegm(&tm); } +static void format_rfc3339_utc(time_t ts, char *buffer, size_t buf_size) { + struct tm tm_info; + + if (!buffer || buf_size == 0) { + return; + } + + gmtime_r(&ts, &tm_info); + strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info); +} + static void discard_line_remainder(FILE *fp) { int c; @@ -102,6 +114,47 @@ static bool parse_log_record(const char *line, message_t *out, return true; } +static int append_dump_record(char **output, size_t *capacity, + size_t *len, const message_t *msg) { + char timestamp[64]; + int needed; + size_t available; + + if (!output || !capacity || !len || !msg) { + return -1; + } + + format_rfc3339_utc(msg->timestamp, timestamp, sizeof(timestamp)); + needed = snprintf(NULL, 0, "%s|%s|%s\n", timestamp, msg->username, + msg->content); + if (needed < 0) { + return -1; + } + + available = *capacity > *len ? *capacity - *len : 0; + if ((size_t)needed + 1 > available) { + size_t new_capacity = *capacity ? *capacity : 1024; + while ((size_t)needed + 1 > new_capacity - *len) { + if (new_capacity > SIZE_MAX / 2) { + return -1; + } + new_capacity *= 2; + } + + char *grown = realloc(*output, new_capacity); + if (!grown) { + return -1; + } + *output = grown; + *capacity = new_capacity; + } + + snprintf(*output + *len, *capacity - *len, "%s|%s|%s\n", timestamp, + msg->username, msg->content); + *len += (size_t)needed; + return 0; +} + /* Initialize message subsystem */ void message_init(void) { /* Nothing to initialize for now */ @@ -339,6 +392,118 @@ int message_search(const char *query, message_t **results, int max_results) { return (count < max_results) ? count : max_results; } +int message_dump_text(char **output, size_t *output_len, int max_records) { + char log_path[PATH_MAX]; + char *buf = NULL; + size_t capacity = 0; + size_t len = 0; + message_t *ring = NULL; + int seen = 0; + int rc = 0; + + if (!output || !output_len || max_records < 0) { + return -1; + } + + *output = calloc(1, 1); + if (!*output) { + return -1; + } + *output_len = 0; + + if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) { + free(*output); + *output = NULL; + return -1; + } + + if (max_records > 0) { + ring = calloc((size_t)max_records, sizeof(*ring)); + if (!ring) { + free(*output); + *output = NULL; + return -1; + } + } + + pthread_mutex_lock(&g_message_file_lock); + FILE *fp = fopen(log_path, "r"); + if (!fp) { + int saved_errno = errno; + pthread_mutex_unlock(&g_message_file_lock); + free(ring); + if (saved_errno != ENOENT) { + free(*output); + *output = NULL; + return -1; + } + return 0; + } + + char line[2048]; + time_t now = time(NULL); + while (fgets(line, sizeof(line), fp)) { + size_t line_len = strlen(line); + if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') { + discard_line_remainder(fp); + continue; + } + + message_t parsed; + if (!parse_log_record(line, &parsed, now)) { + continue; + } + + if (max_records > 0) { + ring[seen % max_records] = parsed; + seen++; + } else if (append_dump_record(output, &capacity, output_len, + &parsed) < 0) { + rc = -1; + break; + } + } + + fclose(fp); + pthread_mutex_unlock(&g_message_file_lock); + + if (rc == 0 && max_records > 0 && seen > 0) { + int count = seen < max_records ? seen : max_records; + int start = seen < max_records ? 0 : seen % max_records; + + free(*output); + *output = NULL; + *output_len = 0; + + for (int i = 0; i < count; i++) { + message_t *msg = &ring[(start + i) % max_records]; + if (append_dump_record(&buf, &capacity, &len, msg) < 0) { + rc = -1; + break; + } + } + + if (rc == 0) { + *output = buf ? buf : calloc(1, 1); + *output_len = len; + if (!*output) { + rc = -1; + } + } else { + free(buf); + } + } + + free(ring); + if (rc < 0) { + free(*output); + *output = NULL; + *output_len = 0; + return -1; + } + return 0; +} + /* Format a message for display */ void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) { struct tm tm_info; diff --git a/src/tntctl.c b/src/tntctl.c index 6e10349..9d21850 100644 --- a/src/tntctl.c +++ b/src/tntctl.c @@ -20,7 +20,7 @@ static void print_usage(FILE *stream) { " -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"); + "tail, dump, post, help, and exit.\n"); } static bool is_valid_port(const char *value) { @@ -78,6 +78,7 @@ static bool is_known_exec_command(const char *command) { strcmp(command, "stats") == 0 || strcmp(command, "users") == 0 || strcmp(command, "tail") == 0 || + strcmp(command, "dump") == 0 || strcmp(command, "post") == 0 || strcmp(command, "help") == 0 || strcmp(command, "exit") == 0); diff --git a/tests/test_exec_mode.sh b/tests/test_exec_mode.sh index 7ce7109..fe92c34 100755 --- a/tests/test_exec_mode.sh +++ b/tests/test_exec_mode.sh @@ -140,6 +140,19 @@ else FAIL=$((FAIL + 1)) fi +DUMP_USAGE=$(ssh $SSH_OPTS localhost "dump -n nope" 2>/dev/null) +DUMP_USAGE_STATUS=$? +printf '%s\n' "$DUMP_USAGE" | grep -q '^dump: 用法: dump \[N\] | dump -n N$' +if [ $? -eq 0 ] && [ "$DUMP_USAGE_STATUS" -eq 64 ]; then + echo "✓ dump usage follows TNT_LANG and exits 64" + PASS=$((PASS + 1)) +else + echo "✗ dump usage output unexpected" + printf '%s\n' "$DUMP_USAGE" + echo "exit status: $DUMP_USAGE_STATUS" + FAIL=$((FAIL + 1)) +fi + POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true) if [ "$POST_OUTPUT" = "posted" ]; then echo "✓ post publishes a message" @@ -161,6 +174,17 @@ else FAIL=$((FAIL + 1)) fi +DUMP_OUTPUT=$(ssh $SSH_OPTS localhost "dump -n 1" 2>/dev/null || true) +printf '%s\n' "$DUMP_OUTPUT" | grep -q '|execposter|hello from exec$' +if [ $? -eq 0 ]; then + echo "✓ dump returns persisted message log records" + PASS=$((PASS + 1)) +else + echo "✗ dump output unexpected" + printf '%s\n' "$DUMP_OUTPUT" + FAIL=$((FAIL + 1)) +fi + PERSIST_FAIL_MARKER="persist-fail-marker" rm -f "$STATE_DIR/messages.log" mkdir "$STATE_DIR/messages.log" @@ -261,6 +285,17 @@ else FAIL=$((FAIL + 1)) fi +TNTCTL_DUMP=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "dump" "-n" "1" 2>/dev/null || true) +printf '%s\n' "$TNTCTL_DUMP" | grep -q '|ctlposter|hello from tntctl$' +if [ $? -eq 0 ]; then + echo "✓ tntctl dump returns persisted message log records" + PASS=$((PASS + 1)) +else + echo "✗ tntctl dump output unexpected" + printf '%s\n' "$TNTCTL_DUMP" + FAIL=$((FAIL + 1)) +fi + EXPECT_SCRIPT="${STATE_DIR}/watcher.expect" WATCHER_READY="${STATE_DIR}/watcher.ready" cat >"$EXPECT_SCRIPT" </dev/null 2>&1 REMOTE_STATUS=$? if [ "$REMOTE_STATUS" -eq 64 ]; then diff --git a/tests/unit/test_exec_catalog.c b/tests/unit/test_exec_catalog.c index 480960e..c52bbbc 100644 --- a/tests/unit/test_exec_catalog.c +++ b/tests/unit/test_exec_catalog.c @@ -28,12 +28,14 @@ TEST(generates_localized_exec_help) { assert(strstr(en, "TNT exec interface") != NULL); assert(strstr(en, "Commands:") != NULL); assert(strstr(en, "users [--json]") != NULL); + assert(strstr(en, "dump [N]") != NULL); assert(strstr(en, "post MESSAGE") != NULL); assert(strstr(en, "support") == NULL); assert(strstr(zh, "TNT exec 接口") != NULL); assert(strstr(zh, "命令:") != NULL); assert(strstr(zh, "users [--json]") != NULL); + assert(strstr(zh, "dump [N]") != NULL); assert(strstr(zh, "post MESSAGE") != NULL); assert(strstr(zh, "support") == NULL); assert_ascii_angle_placeholders(zh); @@ -65,6 +67,10 @@ TEST(matches_exec_commands_and_args) { assert(id == TNT_EXEC_COMMAND_TAIL); assert(strcmp(args, "-n 20") == 0); + assert(exec_catalog_match("dump -n 20", &id, &args)); + assert(id == TNT_EXEC_COMMAND_DUMP); + assert(strcmp(args, "-n 20") == 0); + assert(exec_catalog_match("post hello world", &id, &args)); assert(id == TNT_EXEC_COMMAND_POST); assert(strcmp(args, "hello world") == 0); @@ -90,6 +96,9 @@ TEST(validates_argument_shapes) { assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, NULL)); assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, "-n 20")); + assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, NULL)); + assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, "-n 20")); + assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, NULL)); assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello")); } @@ -111,8 +120,8 @@ TEST(generates_localized_usage) { memset(en, 0, sizeof(en)); en_pos = 0; exec_catalog_append_usage(en, sizeof(en), &en_pos, - TNT_EXEC_COMMAND_TAIL, (ui_lang_t)99); - assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0); + TNT_EXEC_COMMAND_DUMP, (ui_lang_t)99); + assert(strcmp(en, "dump: usage: dump [N] | dump -n N\n") == 0); } int main(void) { diff --git a/tests/unit/test_message.c b/tests/unit/test_message.c index 060fa0d..b4fd842 100644 --- a/tests/unit/test_message.c +++ b/tests/unit/test_message.c @@ -208,6 +208,53 @@ TEST(message_search_skips_malformed_records) { cleanup_state_dir(); } +TEST(message_dump_exports_valid_records) { + char ts[64]; + char log_path[PATH_MAX]; + char expected_all[512]; + char expected_last_two[512]; + char *dump = NULL; + size_t dump_len = 0; + + setup_state_dir(); + format_rfc3339_now(ts, sizeof(ts)); + snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir); + + FILE *fp = fopen(log_path, "wb"); + assert(fp != NULL); + fprintf(fp, "%s|alice|first valid\n", ts); + fprintf(fp, "%s|mallory|extra|pipe\n", ts); + fprintf(fp, "%s|bob|second valid\n", ts); + fprintf(fp, "%s|carol|third valid\n", ts); + fprintf(fp, "%s|partial|truncated record", ts); + fclose(fp); + + snprintf(expected_all, sizeof(expected_all), + "%s|alice|first valid\n" + "%s|bob|second valid\n" + "%s|carol|third valid\n", + ts, ts, ts); + assert(message_dump_text(&dump, &dump_len, 0) == 0); + assert(dump != NULL); + assert(dump_len == strlen(expected_all)); + assert(strcmp(dump, expected_all) == 0); + free(dump); + + dump = NULL; + dump_len = 0; + snprintf(expected_last_two, sizeof(expected_last_two), + "%s|bob|second valid\n" + "%s|carol|third valid\n", + ts, ts); + assert(message_dump_text(&dump, &dump_len, 2) == 0); + assert(dump != NULL); + assert(dump_len == strlen(expected_last_two)); + assert(strcmp(dump, expected_last_two) == 0); + free(dump); + + cleanup_state_dir(); +} + /* Test edge cases */ TEST(message_edge_cases) { message_t msg; @@ -303,6 +350,7 @@ int main(void) { RUN_TEST(message_save_basic); RUN_TEST(message_load_skips_malformed_records); RUN_TEST(message_search_skips_malformed_records); + RUN_TEST(message_dump_exports_valid_records); RUN_TEST(message_edge_cases); RUN_TEST(message_special_characters); RUN_TEST(message_buffer_safety); diff --git a/tnt.1 b/tnt.1 index 85f879d..3d8af09 100644 --- a/tnt.1 +++ b/tnt.1 @@ -222,6 +222,7 @@ ssh host \-p 2222 help ssh host \-p 2222 users \-\-json ssh host \-p 2222 stats \-\-json ssh host \-p 2222 tail 20 +ssh host \-p 2222 dump \-n 100 ssh host \-p 2222 post "Hello from a script" ssh host \-p 2222 post "/me deploys v2.0" ssh host \-p 2222 health diff --git a/tntctl.1 b/tntctl.1 index 97a3c63..dc02696 100644 --- a/tntctl.1 +++ b/tntctl.1 @@ -73,6 +73,12 @@ Print recent messages. .B tail -n N Print recent messages. .TP +.B dump [N] +Export persisted messages. +.TP +.B dump -n N +Export persisted messages. +.TP .B post MESSAGE Post a message non-interactively. .TP @@ -82,6 +88,7 @@ Print the server exec help. .nf tntctl chat.example.com health tntctl -p 2222 chat.example.com stats --json +tntctl -p 2222 chat.example.com dump -n 100 tntctl -l operator chat.example.com post "service notice" tntctl --host-key-checking accept-new chat.example.com users .fi From 5240756f963b4a751d6332df63149dc7e7d7375b Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 09:58:56 +0800 Subject: [PATCH 12/31] Harden message log maintenance tooling --- Makefile | 8 +- README.md | 13 +++ docs/CHANGELOG.md | 5 + docs/DEPLOYMENT.md | 18 ++++ docs/MESSAGE_LOG.md | 15 +++ docs/QUICKREF.md | 6 ++ docs/ROADMAP.md | 2 +- scripts/logrotate.sh | 192 ++++++++++++++++++++++++++++++++------- scripts/release_check.sh | 4 + tests/test_logrotate.sh | 140 ++++++++++++++++++++++++++++ 10 files changed, 369 insertions(+), 34 deletions(-) create mode 100755 tests/test_logrotate.sh diff --git a/Makefile b/Makefile index db978bc..7d7e512 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ MANDIR ?= $(PREFIX)/share/man SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) -.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-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 script-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info all: $(TARGETS) @@ -108,7 +108,7 @@ check: @command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed" # Test -test: all unit-test integration-test +test: all unit-test script-test integration-test test-advisory: all unit-test @echo "Running integration tests..." @@ -120,6 +120,10 @@ unit-test: @echo "Running unit tests..." @$(MAKE) -C tests/unit run +script-test: + @echo "Running script tests..." + @cd tests && ./test_logrotate.sh + integration-test: all @echo "Running integration tests..." @cd tests && PORT=$${PORT:-2222} ./test_basic.sh diff --git a/README.md b/README.md index bd48bd6..4e88aa9 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,19 @@ tntctl -p 2222 chat.example.com dump -n 100 tntctl -l operator chat.example.com post "service notice" ``` +### Log Maintenance + +Persisted public history is stored as `messages.log` in the TNT state +directory. For manual maintenance, archive and compact it with: + +```sh +scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000 +``` + +The script archives the full log, keeps the last `KEEP_LINES` records in the +active file, compresses the archive when `gzip` is available, and can be +previewed with `--dry-run`. + ## Development ### Building diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d010817..854294b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ including parser, sanitization, and partial-record recovery rules. - Added `dump [N]` / `dump -n N` to the SSH exec interface and `tntctl` for exporting valid persisted `messages.log` v1 records. +- Added regression-tested manual log archive and compaction coverage for + `scripts/logrotate.sh`. - Added 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 @@ -58,6 +60,9 @@ - Message-log replay and search now share one strict record parser and skip malformed, invalid UTF-8, extra-separator, oversized, or unterminated records instead of accepting partial replay data. +- `scripts/logrotate.sh` now has validated arguments, stable exit statuses, + dry-run support, archive retention, gzip-aware archives, and a regression + test in the normal test suite. - The two-user lifecycle test now covers opening `:inbox` before a private message arrives, matching the way users often leave an inbox page open. - Private-message inbox access now uses its own mutex instead of sharing the diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index cf7aae4..e8cb8f9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -107,6 +107,24 @@ sudo rm /var/lib/tnt/motd.txt No restart required — TNT reads the file on each new connection. +## Manual Log Maintenance + +TNT stores public chat history in `messages.log` under the state directory. +Use the maintenance script from a source checkout when the service is stopped +or during a quiet maintenance window: + +```bash +sudo systemctl stop tnt +sudo scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000 +sudo systemctl start tnt +``` + +The arguments are `LOG_FILE MAX_SIZE_MB KEEP_LINES`. The script archives the +full log, compacts the active log to the last `KEEP_LINES` records, compresses +the archive when `gzip` is available, and keeps the newest five archives by +default. Use `--dry-run` to preview actions, or `--keep-archives N` to change +archive retention. + ## Firewall ```bash diff --git a/docs/MESSAGE_LOG.md b/docs/MESSAGE_LOG.md index b06fa9e..1bbaff4 100644 --- a/docs/MESSAGE_LOG.md +++ b/docs/MESSAGE_LOG.md @@ -60,6 +60,21 @@ interface and `tntctl`. The output format is exactly the v1 record format above. Without `N`, `dump` exports all valid records; with `N`, it exports the last `N` valid records. +## Maintenance + +`scripts/logrotate.sh` is the manual archive and compaction tool for +`messages.log`: + +```sh +scripts/logrotate.sh [--dry-run] [--keep-archives N] LOG_FILE MAX_SIZE_MB KEEP_LINES +``` + +When the log exceeds `MAX_SIZE_MB`, the script archives the full file, compacts +the active file to the last `KEEP_LINES` records, compresses the archive when +`gzip` is available, and removes older archives beyond the retention limit. +Run it while TNT is stopped or during a quiet maintenance window if strict log +consistency matters. + ## Compatibility The v1 record format is stable for TNT 1.x. Future incompatible storage diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 7bef386..4a0c688 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -54,6 +54,12 @@ EXEC COMMANDS dump [N] / dump -n N persisted messages.log v1 records post post as the SSH login name +MAINTENANCE + scripts/logrotate.sh LOG_FILE MAX_SIZE_MB KEEP_LINES + archive and compact messages.log + scripts/logrotate.sh --dry-run ... + preview log maintenance actions + STRUCTURE src/main.c entry, signals src/cli_text.c startup CLI text diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 91b0dac..923e194 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -58,7 +58,7 @@ Goal: make stored history durable, inspectable, and recoverable. - ✅ keep persisted timestamps in UTC throughout write and replay - ✅ validate persisted UTF-8 and record structure before replay/search - ✅ provide an inspection/export command for persisted records -- add log rotation and compaction tooling +- ✅ add log rotation and compaction tooling - define broader recovery tooling for truncated or partially corrupted logs ## Stage 4: Interactive UX diff --git a/scripts/logrotate.sh b/scripts/logrotate.sh index d69afe8..71871fe 100755 --- a/scripts/logrotate.sh +++ b/scripts/logrotate.sh @@ -1,44 +1,174 @@ -#!/bin/bash -# TNT Log Rotation Script -# Keeps chat history manageable and prevents disk space issues +#!/bin/sh +# Compact and archive a TNT messages.log file. +# +# This is an operator-run maintenance tool. For strict consistency, stop TNT +# or run it during a quiet maintenance window before compacting the active log. -LOG_FILE="${1:-/var/lib/tnt/messages.log}" -MAX_SIZE_MB="${2:-100}" -KEEP_LINES="${3:-10000}" +set -eu -# Check if log file exists -if [ ! -f "$LOG_FILE" ]; then - echo "Log file $LOG_FILE does not exist" +DRY_RUN=0 +KEEP_ARCHIVES=5 + +usage() { + cat <<'USAGE' +Usage: scripts/logrotate.sh [--dry-run] [--keep-archives N] [LOG_FILE [MAX_SIZE_MB [KEEP_LINES]]] + +Defaults: + LOG_FILE /var/lib/tnt/messages.log + MAX_SIZE_MB 100 + KEEP_LINES 10000 + +Exit status: + 0 success, including missing log file + 1 runtime error + 64 invalid arguments +USAGE +} + +fail_usage() { + echo "logrotate: $*" >&2 + usage >&2 + exit 64 +} + +fail() { + echo "logrotate: $*" >&2 + exit 1 +} + +is_uint() { + case "${1:-}" in + ''|*[!0-9]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +is_positive_uint() { + is_uint "$1" && [ "$1" -gt 0 ] +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --keep-archives) + [ "$#" -ge 2 ] || fail_usage "missing value for --keep-archives" + is_uint "$2" || fail_usage "invalid archive count: $2" + KEEP_ARCHIVES=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) + fail_usage "unknown option: $1" + ;; + *) + break + ;; + esac +done + +[ "$#" -le 3 ] || fail_usage "too many arguments" + +LOG_FILE=${1:-/var/lib/tnt/messages.log} +MAX_SIZE_MB=${2:-100} +KEEP_LINES=${3:-10000} + +case "$LOG_FILE" in + ''|-*) + fail_usage "invalid log path" + ;; +esac +is_uint "$MAX_SIZE_MB" || fail_usage "invalid max size: $MAX_SIZE_MB" +is_positive_uint "$KEEP_LINES" || fail_usage "invalid keep lines: $KEEP_LINES" + +if [ ! -e "$LOG_FILE" ]; then + echo "logrotate: $LOG_FILE does not exist" exit 0 fi +[ -f "$LOG_FILE" ] || fail "$LOG_FILE is not a regular file" -# Get file size in MB -FILE_SIZE=$(du -m "$LOG_FILE" | cut -f1) +MAX_BYTES=$((MAX_SIZE_MB * 1024 * 1024)) +FILE_SIZE=$(wc -c < "$LOG_FILE" | tr -d ' ') +[ -n "$FILE_SIZE" ] || fail "could not read log size" -# Rotate if file is too large -if [ "$FILE_SIZE" -gt "$MAX_SIZE_MB" ]; then - echo "Log file size: ${FILE_SIZE}MB, rotating..." +compact_log() { + timestamp=$(date -u +%Y%m%dT%H%M%SZ) + backup="${LOG_FILE}.${timestamp}" + suffix=1 - # Create backup - BACKUP="${LOG_FILE}.$(date +%Y%m%d_%H%M%S)" - cp "$LOG_FILE" "$BACKUP" + while [ -e "$backup" ] || [ -e "${backup}.gz" ]; do + backup="${LOG_FILE}.${timestamp}.${suffix}" + suffix=$((suffix + 1)) + done - # Keep only last N lines - tail -n "$KEEP_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp" - mv "${LOG_FILE}.tmp" "$LOG_FILE" + if [ "$DRY_RUN" -eq 1 ]; then + echo "logrotate: would archive $LOG_FILE to $backup" + echo "logrotate: would keep last $KEEP_LINES lines" + return 0 + fi - # Compress old backup - gzip "$BACKUP" + tmp="${LOG_FILE}.tmp.$$" + rm -f "$tmp" + cp -p "$LOG_FILE" "$backup" || fail "failed to create archive" + if ! tail -n "$KEEP_LINES" "$LOG_FILE" > "$tmp"; then + rm -f "$tmp" + fail "failed to compact log" + fi + if ! cat "$tmp" > "$LOG_FILE"; then + rm -f "$tmp" + fail "failed to replace log" + fi + rm -f "$tmp" - echo "Log rotated. Backup: ${BACKUP}.gz" - echo "Kept last $KEEP_LINES lines" + if command -v gzip >/dev/null 2>&1; then + gzip -f "$backup" || fail "failed to compress archive" + backup="${backup}.gz" + fi + + echo "logrotate: archived $backup" + echo "logrotate: kept last $KEEP_LINES lines" +} + +cleanup_archives() { + [ "$KEEP_ARCHIVES" -ge 0 ] || return 0 + + archives=$( + ls -1t "$LOG_FILE".*.gz "$LOG_FILE".[0-9]* 2>/dev/null || true + ) + [ -n "$archives" ] || return 0 + + printf '%s\n' "$archives" | + awk '!seen[$0]++' | + awk -v keep="$KEEP_ARCHIVES" 'NR > keep' | + while IFS= read -r old; do + [ -n "$old" ] || continue + if [ "$DRY_RUN" -eq 1 ]; then + echo "logrotate: would remove $old" + else + rm -f "$old" + fi + done +} + +if [ "$FILE_SIZE" -gt "$MAX_BYTES" ]; then + echo "logrotate: size ${FILE_SIZE} bytes exceeds ${MAX_BYTES} bytes" + compact_log else - echo "Log file size: ${FILE_SIZE}MB (under ${MAX_SIZE_MB}MB limit)" + echo "logrotate: size ${FILE_SIZE} bytes is within ${MAX_BYTES} bytes" fi -# Clean up old compressed logs (keep last 5) -LOG_DIR=$(dirname "$LOG_FILE") -cd "$LOG_DIR" || exit -ls -t messages.log.*.gz 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null - -echo "Log rotation complete" +cleanup_archives +echo "logrotate: complete" diff --git a/scripts/release_check.sh b/scripts/release_check.sh index 27dedc6..aa8c0c8 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -13,6 +13,7 @@ Default checks: - version metadata alignment - clean build - unit tests + - script tests - staged install layout with PREFIX=/usr and DESTDIR - installer shell syntax - Debian packaging metadata @@ -102,6 +103,9 @@ step "running unit tests" make -C tests/unit clean make -C tests/unit run +step "running script tests" +make script-test + step "checking client I/O ownership boundaries" ! grep -R "client_send(target" src include >/dev/null || fail "cross-client target writes must be queued through client_queue_bell" diff --git a/tests/test_logrotate.sh b/tests/test_logrotate.sh new file mode 100755 index 0000000..6ca9dfe --- /dev/null +++ b/tests/test_logrotate.sh @@ -0,0 +1,140 @@ +#!/bin/sh +# Maintenance-script regression tests for scripts/logrotate.sh. + +set -u + +PASS=0 +FAIL=0 +SCRIPT="../scripts/logrotate.sh" +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-logrotate-test.XXXXXX") + +cleanup() { + rm -rf "$STATE_DIR" +} +trap cleanup EXIT + +pass() { + echo "✓ $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "✗ $1" + FAIL=$((FAIL + 1)) +} + +archive_payload() { + archive=$1 + case "$archive" in + *.gz) gzip -cd "$archive" ;; + *) cat "$archive" ;; + esac +} + +echo "=== TNT Logrotate Tests ===" + +if [ ! -x "$SCRIPT" ]; then + echo "Error: script $SCRIPT not found or not executable." + exit 1 +fi + +MISSING_OUTPUT=$("$SCRIPT" "$STATE_DIR/missing.log" 100 10 2>&1) +MISSING_STATUS=$? +printf '%s\n' "$MISSING_OUTPUT" | grep -q 'does not exist' +if [ "$MISSING_STATUS" -eq 0 ] && [ $? -eq 0 ]; then + pass "missing log is a successful no-op" +else + fail "missing log handling" + printf '%s\n' "$MISSING_OUTPUT" +fi + +LOG="$STATE_DIR/messages.log" +cat > "$LOG" <<'EOF' +2026-01-01T00:00:01Z|alice|one +2026-01-01T00:00:02Z|bob|two +2026-01-01T00:00:03Z|carol|three +EOF + +if "$SCRIPT" "$LOG" 100 2 >/dev/null 2>&1 && + grep -q 'alice|one' "$LOG" && + [ "$(ls "$LOG".* 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then + pass "small log stays unmodified" +else + fail "small log no-op" + cat "$LOG" 2>/dev/null +fi + +ROTATE_OUTPUT=$("$SCRIPT" "$LOG" 0 2 2>&1) +ROTATE_STATUS=$? +ARCHIVE=$(ls "$LOG".*.gz "$LOG".[0-9]* 2>/dev/null | head -n 1) +if [ "$ROTATE_STATUS" -eq 0 ] && + printf '%s\n' "$ROTATE_OUTPUT" | grep -q 'kept last 2 lines' && + ! grep -q 'alice|one' "$LOG" && + grep -q 'bob|two' "$LOG" && + grep -q 'carol|three' "$LOG" && + [ -n "$ARCHIVE" ] && + archive_payload "$ARCHIVE" | grep -q 'alice|one'; then + pass "oversize log is archived and compacted" +else + fail "oversize rotation" + printf '%s\n' "$ROTATE_OUTPUT" + cat "$LOG" 2>/dev/null +fi + +DRY_LOG="$STATE_DIR/dry.log" +printf 'line1\nline2\nline3\n' > "$DRY_LOG" +DRY_BEFORE=$(cat "$DRY_LOG") +DRY_OUTPUT=$("$SCRIPT" --dry-run "$DRY_LOG" 0 1 2>&1) +DRY_STATUS=$? +if [ "$DRY_STATUS" -eq 0 ] && + [ "$(cat "$DRY_LOG")" = "$DRY_BEFORE" ] && + printf '%s\n' "$DRY_OUTPUT" | grep -q 'would archive'; then + pass "dry run does not modify the log" +else + fail "dry run handling" + printf '%s\n' "$DRY_OUTPUT" +fi + +INVALID_OUTPUT=$("$SCRIPT" "$LOG" nope 2 2>&1) +INVALID_STATUS=$? +if [ "$INVALID_STATUS" -eq 64 ] && + printf '%s\n' "$INVALID_OUTPUT" | grep -q 'invalid max size'; then + pass "invalid arguments exit 64" +else + fail "invalid argument status" + printf '%s\n' "$INVALID_OUTPUT" + echo "exit status: $INVALID_STATUS" +fi + +DIR_OUTPUT=$("$SCRIPT" "$STATE_DIR" 0 1 2>&1) +DIR_STATUS=$? +if [ "$DIR_STATUS" -eq 1 ] && + printf '%s\n' "$DIR_OUTPUT" | grep -q 'not a regular file'; then + pass "non-regular log path is rejected" +else + fail "non-regular path handling" + printf '%s\n' "$DIR_OUTPUT" + echo "exit status: $DIR_STATUS" +fi + +RET_LOG="$STATE_DIR/retention.log" +printf 'a\nb\nc\n' > "$RET_LOG" +printf old1 > "$RET_LOG.20000101T000000Z.gz" +sleep 1 +printf old2 > "$RET_LOG.20010101T000000Z.gz" +sleep 1 +printf old3 > "$RET_LOG.20020101T000000Z.gz" + +if "$SCRIPT" --keep-archives 2 "$RET_LOG" 100 2 >/dev/null 2>&1 && + [ "$(ls "$RET_LOG".*.gz 2>/dev/null | wc -l | tr -d ' ')" -eq 2 ]; then + pass "archive retention removes older archives" +else + fail "archive retention" + ls "$RET_LOG".* 2>/dev/null || true +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" From 3252e4583cb84c30c018fabc68788a649e9080cf Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 10:08:32 +0800 Subject: [PATCH 13/31] Split message log record module --- docs/CHANGELOG.md | 2 + docs/Development-Guide.md | 2 + docs/QUICKREF.md | 1 + include/message_log.h | 21 +++++ src/message.c | 163 ++++++++------------------------------ src/message_log.c | 129 ++++++++++++++++++++++++++++++ tests/unit/Makefile | 5 +- 7 files changed, 190 insertions(+), 133 deletions(-) create mode 100644 include/message_log.h create mode 100644 src/message_log.c diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 854294b..61d6975 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -63,6 +63,8 @@ - `scripts/logrotate.sh` now has validated arguments, stable exit statuses, dry-run support, archive retention, gzip-aware archives, and a regression test in the normal test suite. +- `messages.log` v1 record parsing and formatting now live in a dedicated + `message_log` module instead of being embedded in `message.c`. - The two-user lifecycle test now covers opening `:inbox` before a private message arrives, matching the way users often leave an inbox page open. - Private-message inbox access now uses its own mutex instead of sharing the diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index a52eceb..51003e4 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -81,6 +81,7 @@ src/ ├── exec.c - SSH exec command dispatch ├── chat_room.c - Chat room state, message ring, and update sequence ├── message.c - Message persistence (RFC3339 format) +├── message_log.c - messages.log v1 parsing and formatting ├── history_view.c - NORMAL-mode scroll window rules ├── tui.c - Terminal UI rendering (ANSI escape codes) ├── tui_status.c - Mode/status/input-line rendering @@ -103,6 +104,7 @@ include/ ├── bootstrap.h - SSH session bootstrap interface ├── chat_room.h - Chat room interface ├── message.h - Message structure and persistence +├── message_log.h - messages.log v1 parser/formatter interface ├── command_catalog.h - COMMAND-mode command metadata interface ├── history_view.h - Scroll-state helpers ├── tui.h - TUI rendering functions diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 4a0c688..1dfbbba 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -71,6 +71,7 @@ STRUCTURE src/exec_catalog.c SSH exec command matching, usage, argument shape src/exec.c SSH exec command dispatch src/message.c persistence, search + src/message_log.c messages.log v1 parsing and formatting src/history_view.c message viewport / scroll state src/help_text.c full-screen key reference text src/manual.c concise manual panel rendering diff --git a/include/message_log.h b/include/message_log.h new file mode 100644 index 0000000..7919ae9 --- /dev/null +++ b/include/message_log.h @@ -0,0 +1,21 @@ +#ifndef MESSAGE_LOG_H +#define MESSAGE_LOG_H + +#include "message.h" + +#define MESSAGE_LOG_MAX_LINE 2048 + +void message_log_format_timestamp_utc(time_t ts, char *buffer, + size_t buf_size); + +/* Parse one complete messages.log v1 record. `now` is used to reject records + * outside TNT's accepted replay window. */ +bool message_log_parse_record(const char *line, message_t *out, time_t now); + +/* Format one messages.log v1 record. record_len receives the number of bytes + * that would be written, excluding the trailing NUL. Passing NULL/0 for the + * output buffer is allowed when only the length is needed. */ +int message_log_format_record(const message_t *msg, char *buffer, + size_t buf_size, size_t *record_len); + +#endif /* MESSAGE_LOG_H */ diff --git a/src/message.c b/src/message.c index c71e6c1..1865937 100644 --- a/src/message.c +++ b/src/message.c @@ -1,10 +1,12 @@ #ifndef _DEFAULT_SOURCE -#define _DEFAULT_SOURCE /* for timegm() on glibc */ +#define _DEFAULT_SOURCE /* for strcasestr() on glibc */ #endif #if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE) -#define _DARWIN_C_SOURCE /* for timegm() on macOS */ +#define _DARWIN_C_SOURCE /* for strcasestr() on macOS */ #endif + #include "message.h" +#include "message_log.h" #include "utf8.h" #include #include @@ -12,32 +14,6 @@ static pthread_mutex_t g_message_file_lock = PTHREAD_MUTEX_INITIALIZER; -static time_t parse_rfc3339_utc(const char *timestamp_str) { - struct tm tm = {0}; - - if (!timestamp_str) { - return (time_t)-1; - } - - char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm); - if (!result || *result != '\0') { - return (time_t)-1; - } - - return timegm(&tm); -} - -static void format_rfc3339_utc(time_t ts, char *buffer, size_t buf_size) { - struct tm tm_info; - - if (!buffer || buf_size == 0) { - return; - } - - gmtime_r(&ts, &tm_info); - strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info); -} - static void discard_line_remainder(FILE *fp) { int c; @@ -45,96 +21,23 @@ static void discard_line_remainder(FILE *fp) { } } -static bool parse_log_record(const char *line, message_t *out, - time_t now) { - char line_copy[2048]; - char *first_sep; - char *second_sep; - char *timestamp_str; - char *username; - char *content; - time_t msg_time; - size_t line_len; - - if (!line || !out) { - return false; - } - - line_len = strlen(line); - if (line_len == 0 || line[line_len - 1] != '\n') { - return false; - } - if (line_len >= sizeof(line_copy)) { - return false; - } - - memcpy(line_copy, line, line_len + 1); - line_copy[line_len - 1] = '\0'; - - first_sep = strchr(line_copy, '|'); - if (!first_sep) { - return false; - } - second_sep = strchr(first_sep + 1, '|'); - if (!second_sep || strchr(second_sep + 1, '|')) { - return false; - } - - *first_sep = '\0'; - *second_sep = '\0'; - timestamp_str = line_copy; - username = first_sep + 1; - content = second_sep + 1; - - if (timestamp_str[0] == '\0' || username[0] == '\0' || - content[0] == '\0') { - return false; - } - if (strlen(username) >= MAX_USERNAME_LEN || - strlen(content) >= MAX_MESSAGE_LEN) { - return false; - } - if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) { - return false; - } - - msg_time = parse_rfc3339_utc(timestamp_str); - if (msg_time == (time_t)-1) { - return false; - } - if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) { - return false; - } - - out->timestamp = msg_time; - strncpy(out->username, username, MAX_USERNAME_LEN - 1); - out->username[MAX_USERNAME_LEN - 1] = '\0'; - strncpy(out->content, content, MAX_MESSAGE_LEN - 1); - out->content[MAX_MESSAGE_LEN - 1] = '\0'; - return true; -} - static int append_dump_record(char **output, size_t *capacity, size_t *len, const message_t *msg) { - char timestamp[64]; - int needed; + size_t needed; size_t available; if (!output || !capacity || !len || !msg) { return -1; } - format_rfc3339_utc(msg->timestamp, timestamp, sizeof(timestamp)); - needed = snprintf(NULL, 0, "%s|%s|%s\n", timestamp, msg->username, - msg->content); - if (needed < 0) { + if (message_log_format_record(msg, NULL, 0, &needed) < 0) { return -1; } available = *capacity > *len ? *capacity - *len : 0; - if ((size_t)needed + 1 > available) { + if (needed + 1 > available) { size_t new_capacity = *capacity ? *capacity : 1024; - while ((size_t)needed + 1 > new_capacity - *len) { + while (needed + 1 > new_capacity - *len) { if (new_capacity > SIZE_MAX / 2) { return -1; } @@ -149,9 +52,11 @@ static int append_dump_record(char **output, size_t *capacity, *capacity = new_capacity; } - snprintf(*output + *len, *capacity - *len, "%s|%s|%s\n", timestamp, - msg->username, msg->content); - *len += (size_t)needed; + if (message_log_format_record(msg, *output + *len, *capacity - *len, + NULL) < 0) { + return -1; + } + *len += needed; return 0; } @@ -247,7 +152,7 @@ int message_load(message_t **messages, int max_messages) { fseek(fp, 0, SEEK_SET); read_messages:; - char line[2048]; + char line[MESSAGE_LOG_MAX_LINE]; int count = 0; time_t now = time(NULL); @@ -261,7 +166,7 @@ read_messages:; } message_t parsed; - if (!parse_log_record(line, &parsed, now)) { + if (!message_log_parse_record(line, &parsed, now)) { continue; } @@ -277,6 +182,9 @@ read_messages:; /* Save a message to log file */ int message_save(const message_t *msg) { char log_path[PATH_MAX]; + message_t safe_msg; + char record[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48]; + size_t record_len = 0; int rc = 0; if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) { @@ -291,36 +199,29 @@ int message_save(const message_t *msg) { return -1; } - /* Format timestamp as RFC3339 */ - char timestamp[64]; - struct tm tm_info; - gmtime_r(&msg->timestamp, &tm_info); - strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &tm_info); - /* Sanitize username and content to prevent log injection */ - char safe_username[MAX_USERNAME_LEN]; - char safe_content[MAX_MESSAGE_LEN]; + safe_msg.timestamp = msg->timestamp; + strncpy(safe_msg.username, msg->username, sizeof(safe_msg.username) - 1); + safe_msg.username[sizeof(safe_msg.username) - 1] = '\0'; - strncpy(safe_username, msg->username, sizeof(safe_username) - 1); - safe_username[sizeof(safe_username) - 1] = '\0'; - - strncpy(safe_content, msg->content, sizeof(safe_content) - 1); - safe_content[sizeof(safe_content) - 1] = '\0'; + strncpy(safe_msg.content, msg->content, sizeof(safe_msg.content) - 1); + safe_msg.content[sizeof(safe_msg.content) - 1] = '\0'; /* Replace pipe characters and newlines to prevent log format corruption */ - for (char *p = safe_username; *p; p++) { + for (char *p = safe_msg.username; *p; p++) { if (*p == '|' || *p == '\n' || *p == '\r') { *p = '_'; } } - for (char *p = safe_content; *p; p++) { + for (char *p = safe_msg.content; *p; p++) { if (*p == '|' || *p == '\n' || *p == '\r') { *p = ' '; } } - /* Write to file: timestamp|username|content */ - if (fprintf(fp, "%s|%s|%s\n", timestamp, safe_username, safe_content) < 0 || + if (message_log_format_record(&safe_msg, record, sizeof(record), + &record_len) < 0 || + fwrite(record, 1, record_len, fp) != record_len || fflush(fp) != 0) { rc = -1; } @@ -361,7 +262,7 @@ int message_search(const char *query, message_t **results, int max_results) { return 0; } - char line[2048]; + char line[MESSAGE_LOG_MAX_LINE]; int count = 0; time_t now = time(NULL); @@ -373,7 +274,7 @@ int message_search(const char *query, message_t **results, int max_results) { } message_t m; - if (!parse_log_record(line, &m, now)) continue; + if (!message_log_parse_record(line, &m, now)) continue; if (strcasestr(m.username, query) == NULL && strcasestr(m.content, query) == NULL) continue; @@ -440,7 +341,7 @@ int message_dump_text(char **output, size_t *output_len, int max_records) { return 0; } - char line[2048]; + char line[MESSAGE_LOG_MAX_LINE]; time_t now = time(NULL); while (fgets(line, sizeof(line), fp)) { size_t line_len = strlen(line); @@ -450,7 +351,7 @@ int message_dump_text(char **output, size_t *output_len, int max_records) { } message_t parsed; - if (!parse_log_record(line, &parsed, now)) { + if (!message_log_parse_record(line, &parsed, now)) { continue; } diff --git a/src/message_log.c b/src/message_log.c new file mode 100644 index 0000000..72b884e --- /dev/null +++ b/src/message_log.c @@ -0,0 +1,129 @@ +#ifndef _DEFAULT_SOURCE +#define _DEFAULT_SOURCE /* for timegm() on glibc */ +#endif +#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE) +#define _DARWIN_C_SOURCE /* for timegm() on macOS */ +#endif + +#include "message_log.h" +#include "utf8.h" + +static time_t parse_rfc3339_utc(const char *timestamp_str) { + struct tm tm = {0}; + + if (!timestamp_str) { + return (time_t)-1; + } + + char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm); + if (!result || *result != '\0') { + return (time_t)-1; + } + + return timegm(&tm); +} + +void message_log_format_timestamp_utc(time_t ts, char *buffer, + size_t buf_size) { + struct tm tm_info; + + if (!buffer || buf_size == 0) { + return; + } + + gmtime_r(&ts, &tm_info); + strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info); +} + +bool message_log_parse_record(const char *line, message_t *out, time_t now) { + char line_copy[MESSAGE_LOG_MAX_LINE]; + char *first_sep; + char *second_sep; + char *timestamp_str; + char *username; + char *content; + time_t msg_time; + size_t line_len; + + if (!line || !out) { + return false; + } + + line_len = strlen(line); + if (line_len == 0 || line[line_len - 1] != '\n') { + return false; + } + if (line_len >= sizeof(line_copy)) { + return false; + } + + memcpy(line_copy, line, line_len + 1); + line_copy[line_len - 1] = '\0'; + + first_sep = strchr(line_copy, '|'); + if (!first_sep) { + return false; + } + second_sep = strchr(first_sep + 1, '|'); + if (!second_sep || strchr(second_sep + 1, '|')) { + return false; + } + + *first_sep = '\0'; + *second_sep = '\0'; + timestamp_str = line_copy; + username = first_sep + 1; + content = second_sep + 1; + + if (timestamp_str[0] == '\0' || username[0] == '\0' || + content[0] == '\0') { + return false; + } + if (strlen(username) >= MAX_USERNAME_LEN || + strlen(content) >= MAX_MESSAGE_LEN) { + return false; + } + if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) { + return false; + } + + msg_time = parse_rfc3339_utc(timestamp_str); + if (msg_time == (time_t)-1) { + return false; + } + if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) { + return false; + } + + out->timestamp = msg_time; + strncpy(out->username, username, MAX_USERNAME_LEN - 1); + out->username[MAX_USERNAME_LEN - 1] = '\0'; + strncpy(out->content, content, MAX_MESSAGE_LEN - 1); + out->content[MAX_MESSAGE_LEN - 1] = '\0'; + return true; +} + +int message_log_format_record(const message_t *msg, char *buffer, + size_t buf_size, size_t *record_len) { + char timestamp[64]; + int needed; + + if (!msg) { + return -1; + } + + message_log_format_timestamp_utc(msg->timestamp, timestamp, + sizeof(timestamp)); + needed = snprintf(buffer, buf_size, "%s|%s|%s\n", timestamp, + msg->username, msg->content); + if (needed < 0) { + return -1; + } + if (record_len) { + *record_len = (size_t)needed; + } + if (!buffer || buf_size == 0) { + return 0; + } + return (size_t)needed < buf_size ? 0 : -1; +} diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 0a69667..c6667c6 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -12,6 +12,7 @@ endif # Source files UTF8_SRC = ../../src/utf8.c MESSAGE_SRC = ../../src/message.c +MESSAGE_LOG_SRC = ../../src/message_log.c COMMON_SRC = ../../src/common.c COMMAND_CATALOG_SRC = ../../src/command_catalog.c CLI_TEXT_SRC = ../../src/cli_text.c @@ -34,10 +35,10 @@ all: $(TESTS) test_utf8: test_utf8.c $(UTF8_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC) +test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC) +test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) test_history_view: test_history_view.c $(HISTORY_VIEW_SRC) From 1c451b77227b8d170931ac8e5efcf04b72aae07b Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 10:26:50 +0800 Subject: [PATCH 14/31] Add offline message log recovery modes --- Makefile | 3 +- README.md | 11 +++ docs/CHANGELOG.md | 6 ++ docs/DEPLOYMENT.md | 10 +++ docs/Development-Guide.md | 2 + docs/MESSAGE_LOG.md | 24 ++++++ docs/QUICKREF.md | 4 + docs/ROADMAP.md | 2 +- include/cli_text.h | 1 + include/message_log_tool.h | 9 +++ src/cli_text.c | 11 +++ src/main.c | 29 +++++++ src/message_log_tool.c | 111 +++++++++++++++++++++++++++ tests/test_message_log_tool.sh | 134 +++++++++++++++++++++++++++++++++ tests/unit/test_cli_text.c | 7 ++ tnt.1 | 20 +++++ 16 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 include/message_log_tool.h create mode 100644 src/message_log_tool.c create mode 100755 tests/test_message_log_tool.sh diff --git a/Makefile b/Makefile index 7d7e512..c5281c9 100644 --- a/Makefile +++ b/Makefile @@ -120,9 +120,10 @@ unit-test: @echo "Running unit tests..." @$(MAKE) -C tests/unit run -script-test: +script-test: all @echo "Running script tests..." @cd tests && ./test_logrotate.sh + @cd tests && ./test_message_log_tool.sh integration-test: all @echo "Running integration tests..." diff --git a/README.md b/README.md index 4e88aa9..202fe2f 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,17 @@ The script archives the full log, keeps the last `KEEP_LINES` records in the active file, compresses the archive when `gzip` is available, and can be previewed with `--dry-run`. +Installed binaries also include offline checks for the v1 log format: + +```sh +tnt --log-check /var/lib/tnt/messages.log +tnt --log-recover /var/lib/tnt/messages.log > messages.recovered.log +``` + +`--log-check` prints record counts and exits non-zero when invalid records are +found. `--log-recover` writes valid records to stdout and reports skipped +records to stderr; it never edits the source log in place. + ## Development ### Building diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 61d6975..8f8f4b0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,9 @@ exporting valid persisted `messages.log` v1 records. - Added regression-tested manual log archive and compaction coverage for `scripts/logrotate.sh`. +- Added offline `tnt --log-check` and `tnt --log-recover` modes for auditing + and recovering valid `messages.log` v1 records without editing the source + log in place. - Added a public security policy, supported-version guidance, and GitHub issue templates for bug reports and feature requests. - Added `tntctl`, a thin local wrapper around the documented SSH exec @@ -65,6 +68,9 @@ test in the normal test suite. - `messages.log` v1 record parsing and formatting now live in a dedicated `message_log` module instead of being embedded in `message.c`. +- Offline message-log recovery shares the same `message_log` parser used by + replay, search, and `dump`, so recovery behavior follows the documented v1 + contract. - The two-user lifecycle test now covers opening `:inbox` before a private message arrives, matching the way users often leave an inbox page open. - Private-message inbox access now uses its own mutex instead of sharing the diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e8cb8f9..7fa5bb6 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -125,6 +125,16 @@ the archive when `gzip` is available, and keeps the newest five archives by default. Use `--dry-run` to preview actions, or `--keep-archives N` to change archive retention. +Before replacing a suspicious log, inspect and recover it offline: + +```bash +tnt --log-check /var/lib/tnt/messages.log +tnt --log-recover /var/lib/tnt/messages.log > /var/lib/tnt/messages.recovered.log +``` + +`--log-recover` writes valid records to stdout and reports skipped records to +stderr. Review the recovered file before replacing the active log. + ## Firewall ```bash diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index 51003e4..251f9aa 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -82,6 +82,7 @@ src/ ├── chat_room.c - Chat room state, message ring, and update sequence ├── message.c - Message persistence (RFC3339 format) ├── message_log.c - messages.log v1 parsing and formatting +├── message_log_tool.c - Offline messages.log check/recover CLI ├── history_view.c - NORMAL-mode scroll window rules ├── tui.c - Terminal UI rendering (ANSI escape codes) ├── tui_status.c - Mode/status/input-line rendering @@ -105,6 +106,7 @@ include/ ├── chat_room.h - Chat room interface ├── message.h - Message structure and persistence ├── message_log.h - messages.log v1 parser/formatter interface +├── message_log_tool.h - Offline log check/recover interface ├── command_catalog.h - COMMAND-mode command metadata interface ├── history_view.h - Scroll-state helpers ├── tui.h - TUI rendering functions diff --git a/docs/MESSAGE_LOG.md b/docs/MESSAGE_LOG.md index 1bbaff4..9d5f245 100644 --- a/docs/MESSAGE_LOG.md +++ b/docs/MESSAGE_LOG.md @@ -75,6 +75,30 @@ the active file to the last `KEEP_LINES` records, compresses the archive when Run it while TNT is stopped or during a quiet maintenance window if strict log consistency matters. +## Recovery + +Installed `tnt` binaries provide offline log checking and recovery: + +```sh +tnt --log-check LOG_FILE +tnt --log-recover LOG_FILE > recovered.messages.log +``` + +`--log-check` prints a summary: + +```text +path /var/lib/tnt/messages.log +records_seen 120 +valid_records 119 +invalid_records 1 +first_invalid_line 120 +``` + +It exits `0` when every record is valid and `1` when invalid records are found +or the log cannot be read. `--log-recover` writes only valid v1 records to +stdout, prints the same summary to stderr, and also exits `1` if records were +skipped. It never modifies the source log. + ## Compatibility The v1 record format is stable for TNT 1.x. Future incompatible storage diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 1dfbbba..ca3f015 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -59,6 +59,9 @@ MAINTENANCE archive and compact messages.log scripts/logrotate.sh --dry-run ... preview log maintenance actions + tnt --log-check LOG_FILE audit messages.log v1 records + tnt --log-recover LOG_FILE > OUT + write valid records to stdout STRUCTURE src/main.c entry, signals @@ -72,6 +75,7 @@ STRUCTURE src/exec.c SSH exec command dispatch src/message.c persistence, search src/message_log.c messages.log v1 parsing and formatting + src/message_log_tool.c offline messages.log check/recover CLI src/history_view.c message viewport / scroll state src/help_text.c full-screen key reference text src/manual.c concise manual panel rendering diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 923e194..ad07aa3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -59,7 +59,7 @@ Goal: make stored history durable, inspectable, and recoverable. - ✅ validate persisted UTF-8 and record structure before replay/search - ✅ provide an inspection/export command for persisted records - ✅ add log rotation and compaction tooling -- define broader recovery tooling for truncated or partially corrupted logs +- ✅ define broader recovery tooling for truncated or partially corrupted logs ## Stage 4: Interactive UX diff --git a/include/cli_text.h b/include/cli_text.h index bbf0edc..24a0a2b 100644 --- a/include/cli_text.h +++ b/include/cli_text.h @@ -7,6 +7,7 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, const char *program_name, ui_lang_t lang); const char *cli_text_invalid_port_format(ui_lang_t lang); const char *cli_text_invalid_value_format(ui_lang_t lang); +const char *cli_text_option_requires_arg_format(ui_lang_t lang); const char *cli_text_unknown_option_format(ui_lang_t lang); const char *cli_text_short_usage_format(ui_lang_t lang); diff --git a/include/message_log_tool.h b/include/message_log_tool.h new file mode 100644 index 0000000..f76c1a4 --- /dev/null +++ b/include/message_log_tool.h @@ -0,0 +1,9 @@ +#ifndef MESSAGE_LOG_TOOL_H +#define MESSAGE_LOG_TOOL_H + +#include "common.h" + +int message_log_tool_check(const char *path); +int message_log_tool_recover(const char *path); + +#endif /* MESSAGE_LOG_TOOL_H */ diff --git a/src/cli_text.c b/src/cli_text.c index 6b3f5b1..87f1e3a 100644 --- a/src/cli_text.c +++ b/src/cli_text.c @@ -18,6 +18,8 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, " --rate-limit 0|1 Disable/enable rate-based blocking\n" " --idle-timeout SECONDS Idle disconnect timeout\n" " --ssh-log-level LEVEL libssh log level 0..4\n" + " --log-check FILE Check messages.log v1 records\n" + " --log-recover FILE Write valid records to stdout\n" " -V, --version Show version\n" " -h, --help Show this help\n" "\n" @@ -42,6 +44,8 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, " --rate-limit 0|1 禁用/启用速率封禁\n" " --idle-timeout SECONDS 空闲断开时间\n" " --ssh-log-level LEVEL libssh 日志级别 0..4\n" + " --log-check FILE 检查 messages.log v1 记录\n" + " --log-recover FILE 将有效记录写入 stdout\n" " -V, --version 显示版本\n" " -h, --help 显示此帮助\n" "\n" @@ -74,6 +78,13 @@ const char *cli_text_invalid_value_format(ui_lang_t lang) { return i18n_string(text, lang); } +const char *cli_text_option_requires_arg_format(ui_lang_t lang) { + static const i18n_string_t text = + I18N_STRING("Option requires argument: %s\n", + "选项需要参数: %s\n"); + return i18n_string(text, lang); +} + const char *cli_text_unknown_option_format(ui_lang_t lang) { static const i18n_string_t text = I18N_STRING("Unknown option: %s\n", "未知选项: %s\n"); diff --git a/src/main.c b/src/main.c index 036df2c..81ea22f 100644 --- a/src/main.c +++ b/src/main.c @@ -3,6 +3,7 @@ #include "common.h" #include "i18n.h" #include "message.h" +#include "message_log_tool.h" #include "ssh_server.h" #include #include @@ -77,6 +78,8 @@ static int set_numeric_env_option(const char *env_name, const char *opt_name, int main(int argc, char **argv) { int port = DEFAULT_PORT; ui_lang_t lang = i18n_default_ui_lang(); + const char *log_check_path = NULL; + const char *log_recover_path = NULL; /* Environment provides defaults; command-line flags override it. */ const char *port_env = getenv("PORT"); @@ -179,6 +182,20 @@ int main(int argc, char **argv) { return rc; } i++; + } else if (strcmp(argv[i], "--log-check") == 0) { + if (i + 1 >= argc || argv[i + 1][0] == '\0') { + fprintf(stderr, cli_text_option_requires_arg_format(lang), + argv[i]); + return TNT_EXIT_USAGE; + } + log_check_path = argv[++i]; + } else if (strcmp(argv[i], "--log-recover") == 0) { + if (i + 1 >= argc || argv[i + 1][0] == '\0') { + fprintf(stderr, cli_text_option_requires_arg_format(lang), + argv[i]); + return TNT_EXIT_USAGE; + } + log_recover_path = argv[++i]; } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) { printf("tnt %s\n", TNT_VERSION); return TNT_EXIT_OK; @@ -196,6 +213,18 @@ int main(int argc, char **argv) { } } + if (log_check_path && log_recover_path) { + fprintf(stderr, cli_text_invalid_value_format(lang), + "--log-check", "--log-recover"); + return TNT_EXIT_USAGE; + } + if (log_check_path) { + return message_log_tool_check(log_check_path); + } + if (log_recover_path) { + return message_log_tool_recover(log_recover_path); + } + /* Setup signal handlers */ signal(SIGINT, signal_handler); signal(SIGTERM, signal_handler); diff --git a/src/message_log_tool.c b/src/message_log_tool.c new file mode 100644 index 0000000..79fad96 --- /dev/null +++ b/src/message_log_tool.c @@ -0,0 +1,111 @@ +#include "message_log_tool.h" + +#include "message_log.h" + +#include + +typedef struct { + long records_seen; + long valid_records; + long invalid_records; + long first_invalid_line; +} message_log_report_t; + +static void discard_line_remainder(FILE *fp) { + int c; + + while ((c = fgetc(fp)) != '\n' && c != EOF) { + } +} + +static int print_recovered_record(const message_t *msg) { + char record[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48]; + size_t record_len = 0; + + if (message_log_format_record(msg, record, sizeof(record), + &record_len) < 0) { + return -1; + } + return fwrite(record, 1, record_len, stdout) == record_len ? 0 : -1; +} + +static void print_report(FILE *stream, const char *path, + const message_log_report_t *report) { + fprintf(stream, + "path %s\n" + "records_seen %ld\n" + "valid_records %ld\n" + "invalid_records %ld\n" + "first_invalid_line %ld\n", + path, + report->records_seen, + report->valid_records, + report->invalid_records, + report->first_invalid_line); +} + +static int scan_log(const char *path, bool recover) { + FILE *fp; + char line[MESSAGE_LOG_MAX_LINE]; + long line_no = 0; + time_t now = time(NULL); + message_log_report_t report = {0}; + + if (!path || path[0] == '\0') { + fprintf(stderr, "log: invalid path\n"); + return TNT_EXIT_USAGE; + } + + fp = fopen(path, "r"); + if (!fp) { + fprintf(stderr, "log: %s: %s\n", path, strerror(errno)); + return TNT_EXIT_ERROR; + } + + while (fgets(line, sizeof(line), fp)) { + size_t line_len = strlen(line); + message_t parsed; + bool valid = false; + + line_no++; + report.records_seen++; + + if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') { + discard_line_remainder(fp); + } else { + valid = message_log_parse_record(line, &parsed, now); + } + + if (valid) { + report.valid_records++; + if (recover && print_recovered_record(&parsed) < 0) { + fclose(fp); + fprintf(stderr, "log: failed to write recovered output\n"); + return TNT_EXIT_ERROR; + } + } else { + report.invalid_records++; + if (report.first_invalid_line == 0) { + report.first_invalid_line = line_no; + } + } + } + + if (ferror(fp)) { + fclose(fp); + fprintf(stderr, "log: failed to read %s\n", path); + return TNT_EXIT_ERROR; + } + fclose(fp); + + print_report(recover ? stderr : stdout, path, &report); + return report.invalid_records == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR; +} + +int message_log_tool_check(const char *path) { + return scan_log(path, false); +} + +int message_log_tool_recover(const char *path) { + return scan_log(path, true); +} diff --git a/tests/test_message_log_tool.sh b/tests/test_message_log_tool.sh new file mode 100755 index 0000000..f60b741 --- /dev/null +++ b/tests/test_message_log_tool.sh @@ -0,0 +1,134 @@ +#!/bin/sh +# Offline messages.log check/recover regression tests. + +set -u + +PASS=0 +FAIL=0 +BIN="../tnt" +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-log-tool-test.XXXXXX") + +cleanup() { + rm -rf "$STATE_DIR" +} +trap cleanup EXIT + +pass() { + echo "✓ $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "✗ $1" + FAIL=$((FAIL + 1)) +} + +ts_now() { + date -u +%Y-%m-%dT%H:%M:%SZ +} + +echo "=== TNT Message Log Tool Tests ===" + +if [ ! -x "$BIN" ]; then + echo "Error: binary $BIN not found. Run make first." + exit 1 +fi + +TS=$(ts_now) +CLEAN_LOG="$STATE_DIR/clean.log" +cat > "$CLEAN_LOG" <&1) +CHECK_STATUS=$? +printf '%s\n' "$CHECK_OUTPUT" | grep -q '^valid_records 2$' && +printf '%s\n' "$CHECK_OUTPUT" | grep -q '^invalid_records 0$' +if [ "$CHECK_STATUS" -eq 0 ] && [ $? -eq 0 ]; then + pass "clean log check exits 0" +else + fail "clean log check" + printf '%s\n' "$CHECK_OUTPUT" + echo "exit status: $CHECK_STATUS" +fi + +BAD_LOG="$STATE_DIR/bad.log" +cat > "$BAD_LOG" <> "$BAD_LOG" + +BAD_CHECK_OUTPUT=$("$BIN" --log-check "$BAD_LOG" 2>&1) +BAD_CHECK_STATUS=$? +printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^records_seen 4$' && +printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^valid_records 2$' && +printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^invalid_records 2$' && +printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^first_invalid_line 2$' +if [ "$BAD_CHECK_STATUS" -eq 1 ] && [ $? -eq 0 ]; then + pass "bad log check reports skipped records" +else + fail "bad log check" + printf '%s\n' "$BAD_CHECK_OUTPUT" + echo "exit status: $BAD_CHECK_STATUS" +fi + +RECOVERED="$STATE_DIR/recovered.log" +RECOVER_REPORT="$STATE_DIR/recover.report" +"$BIN" --log-recover "$BAD_LOG" > "$RECOVERED" 2> "$RECOVER_REPORT" +RECOVER_STATUS=$? +if [ "$RECOVER_STATUS" -eq 1 ] && + grep -q '^valid_records 2$' "$RECOVER_REPORT" && + grep -q '^invalid_records 2$' "$RECOVER_REPORT" && + grep -q "$TS|alice|one" "$RECOVERED" && + grep -q "$TS|bob|two" "$RECOVERED" && + ! grep -q 'mallory' "$RECOVERED" && + ! grep -q 'partial' "$RECOVERED"; then + pass "recover writes valid records and reports skipped records" +else + fail "bad log recovery" + cat "$RECOVERED" 2>/dev/null + cat "$RECOVER_REPORT" 2>/dev/null + echo "exit status: $RECOVER_STATUS" +fi + +MISSING_OUTPUT=$("$BIN" --log-check "$STATE_DIR/missing.log" 2>&1) +MISSING_STATUS=$? +if [ "$MISSING_STATUS" -eq 1 ] && + printf '%s\n' "$MISSING_OUTPUT" | grep -q 'No such file'; then + pass "missing log exits 1" +else + fail "missing log handling" + printf '%s\n' "$MISSING_OUTPUT" + echo "exit status: $MISSING_STATUS" +fi + +USAGE_OUTPUT=$("$BIN" --log-check 2>&1) +USAGE_STATUS=$? +if [ "$USAGE_STATUS" -eq 64 ] && + printf '%s\n' "$USAGE_OUTPUT" | grep -q 'Option requires argument: --log-check'; then + pass "missing log-check argument exits 64" +else + fail "missing log-check argument" + printf '%s\n' "$USAGE_OUTPUT" + echo "exit status: $USAGE_STATUS" +fi + +CONFLICT_OUTPUT=$("$BIN" --log-check "$CLEAN_LOG" --log-recover "$CLEAN_LOG" 2>&1) +CONFLICT_STATUS=$? +if [ "$CONFLICT_STATUS" -eq 64 ] && + printf '%s\n' "$CONFLICT_OUTPUT" | grep -q 'Invalid --log-check: --log-recover'; then + pass "conflicting log modes exit 64" +else + fail "conflicting log modes" + printf '%s\n' "$CONFLICT_OUTPUT" + echo "exit status: $CONFLICT_STATUS" +fi + +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_cli_text.c b/tests/unit/test_cli_text.c index 66b5967..e5d12d4 100644 --- a/tests/unit/test_cli_text.c +++ b/tests/unit/test_cli_text.c @@ -24,6 +24,8 @@ TEST(help_matches_language) { assert(strstr(output, "Usage: tnt [options]") != NULL); assert(strstr(output, "--bind ADDR") != NULL); assert(strstr(output, "--max-connections N") != NULL); + assert(strstr(output, "--log-check FILE") != NULL); + assert(strstr(output, "--log-recover FILE") != NULL); assert(strstr(output, "TNT_LANG") != NULL); memset(output, 0, sizeof(output)); @@ -39,6 +41,7 @@ TEST(help_matches_language) { assert(strstr(output, "[选项]") == NULL); assert(strstr(output, "--public-host HOST") != NULL); assert(strstr(output, "--idle-timeout SECONDS") != NULL); + assert(strstr(output, "--log-check FILE") != NULL); assert(strstr(output, "TNT_LANG") != NULL); } @@ -51,6 +54,10 @@ TEST(error_formats_match_language) { "Invalid %s: %s\n") == 0); assert(strcmp(cli_text_invalid_value_format(UI_LANG_ZH), "%s 无效: %s\n") == 0); + assert(strcmp(cli_text_option_requires_arg_format(UI_LANG_EN), + "Option requires argument: %s\n") == 0); + assert(strcmp(cli_text_option_requires_arg_format(UI_LANG_ZH), + "选项需要参数: %s\n") == 0); assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN), "Unknown option: %s\n") == 0); assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH), diff --git a/tnt.1 b/tnt.1 index 3d8af09..3befbc9 100644 --- a/tnt.1 +++ b/tnt.1 @@ -26,6 +26,14 @@ tnt \- anonymous SSH chat server with Vim\-style TUI .IR level ] .RB [ \-V | \-\-version ] .RB [ \-h | \-\-help ] +.br +.B tnt +.B \-\-log\-check +.I file +.br +.B tnt +.B \-\-log\-recover +.I file .SH DESCRIPTION .B tnt is a multi\-user anonymous chat server accessed over SSH. @@ -117,6 +125,18 @@ Overrides the .B TNT_SSH_LOG_LEVEL environment variable. .TP +.BR \-\-log\-check " " \fIfile\fR +Check a +.I messages.log +v1 file and print record counts. +Exits non-zero when invalid records are found or the file cannot be read. +.TP +.BR \-\-log\-recover " " \fIfile\fR +Write valid +.I messages.log +v1 records to standard output and print a recovery summary to standard error. +The source file is not modified. +.TP .BR \-V ", " \-\-version Print version and exit. .TP From 797ecbb9924f807751a839beeea48dedc8595384 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 19:24:55 +0800 Subject: [PATCH 15/31] Improve TUI pager and search ergonomics --- docs/CHANGELOG.md | 8 ++ docs/EASY_SETUP.md | 6 + docs/USER_LIFECYCLE.md | 9 +- src/help_text.c | 26 +++- src/input.c | 225 +++++++++++++++++++++----------- src/manual_text.c | 8 +- src/tui_status.c | 55 +++++++- tests/test_interactive_input.sh | 126 ++++++++++++++++++ tests/test_user_lifecycle.sh | 6 + 9 files changed, 380 insertions(+), 89 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8f8f4b0..73d689b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,8 @@ - Added a VHS tape draft for recording the core TNT terminal-chat experience. - Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and an open inbox refreshes when a new private message arrives. +- Added `/` in NORMAL mode as a fast history-search entrypoint backed by the + existing `:search` command. - Added `make slow-client-test`, an opt-in regression for an unread interactive SSH client under backpressure while health, stats, post, tail, and server survival stay responsive. @@ -73,6 +75,12 @@ contract. - The two-user lifecycle test now covers opening `:inbox` before a private message arrives, matching the way users often leave an inbox page open. +- Help and command-output pagers now accept arrow keys, PgUp/PgDn, Home/End, + and Space/`b` in addition to the existing Vim-style keys. +- Pre-login username entry now handles Ctrl+C/Ctrl+D cancel, Ctrl+U clear + line, and Ctrl+W delete-word before the user joins the room. +- Long COMMAND-mode input is now left-truncated with a visible marker in the + status line instead of wrapping and damaging the TUI. - Private-message inbox access now uses its own mutex instead of sharing the SSH channel write lock, reducing unrelated contention on slow clients. - Client writes now check the SSH channel's remote window before writing and diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index b4a2dbd..7af0f4d 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -64,7 +64,10 @@ Esc enter NORMAL mode i return to INSERT mode : enter COMMAND mode ? open the full key reference +/ search message history G or End jump to latest messages +Up/Down recall sent messages in INSERT mode +Tab complete @mention in INSERT mode Ctrl+C disconnect from NORMAL mode ``` @@ -209,7 +212,10 @@ Esc 进入 NORMAL 模式 i 回到 INSERT 模式 : 输入命令 ? 查看完整按键参考 +/ 搜索消息历史 G 或 End 回到最新消息 +Up/Down 在 INSERT 模式调出已发送消息 +Tab 在 INSERT 模式补全 @mention :help 查看简明手册 :lang en|zh 切换界面语言 :q 断开连接 diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md index 1f03184..d2ea47e 100644 --- a/docs/USER_LIFECYCLE.md +++ b/docs/USER_LIFECYCLE.md @@ -11,8 +11,9 @@ The product path should stay short: 4. User lands in INSERT mode at the live tail and can type immediately. 5. User presses Esc to browse history with Vim-style movement. 6. User uses `:help` for the concise manual or `?` for the full key reference. -7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`, - `:search`, `:nick`, `:mute-joins`, and `:q`. +7. User searches from NORMAL with `/term`, or uses commands when needed: + `:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`, + and `:q`. 8. Scripts and operators use `tntctl` or SSH exec commands for `health`, `stats`, `users`, `tail`, `dump`, and `post`. @@ -23,6 +24,10 @@ The product path should stay short: - INSERT mode is the default because most users arrive to send a message. - NORMAL mode opens at the latest messages, not the oldest history. Users can move upward for older context and use `G` or End to return to live chat. +- NORMAL mode accepts `/` as the fast path for history search, matching a + common terminal-reader habit while reusing the existing `:search` command. +- INSERT mode keeps a small per-session sent-message history on Up/Down and + completes trailing `@mention` prefixes with Tab. - `:help` is a compact manual, while `?` is a full key reference. Do not add parallel support commands for the same task. - Command syntax stays ASCII even in localized UI text. Translations explain; diff --git a/src/help_text.c b/src/help_text.c index 0a8c919..9c10e0d 100644 --- a/src/help_text.c +++ b/src/help_text.c @@ -19,6 +19,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " Backspace - Delete character\n" " Ctrl+W - Delete last word\n" " Ctrl+U - Delete line\n" + " Up/Down - Recall sent messages\n" + " Tab - Complete @mention\n" " Ctrl+C - Enter NORMAL mode\n" "\n" "NORMAL MODE KEYS:\n" @@ -26,6 +28,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " Follows latest until you scroll up\n" " i - Return to INSERT mode\n" " : - Enter COMMAND mode\n" + " / - Search message history\n" " j/k - Scroll down/up one line\n" " Ctrl+D/U - Scroll half page down/up\n" " Ctrl+F/B - Scroll full page down/up\n" @@ -49,6 +52,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " Backspace - 删除字符\n" " Ctrl+W - 删除上个单词\n" " Ctrl+U - 删除整行\n" + " Up/Down - 调出已发送消息\n" + " Tab - 补全 @mention\n" " Ctrl+C - 进入 NORMAL 模式\n" "\n" "NORMAL 模式按键:\n" @@ -56,6 +61,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " 未向上翻阅时自动跟随最新消息\n" " i - 返回 INSERT 模式\n" " : - 进入 COMMAND 模式\n" + " / - 搜索消息历史\n" " j/k - 向下/上滚动一行\n" " Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+F/B - 向下/上滚动整页\n" @@ -71,9 +77,12 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "\n" "COMMAND OUTPUT KEYS:\n" " q, ESC - Close output\n" - " j/k - Scroll down/up\n" + " j/k, arrows - Scroll down/up\n" " Ctrl+D/U - Scroll half page down/up\n" " Ctrl+F/B - Scroll full page down/up\n" + " Space/b - Scroll full page down/up\n" + " PgDn/PgUp - Scroll full page down/up\n" + " End/Home - Jump to bottom/top\n" " g/G - Jump to top/bottom\n" " r - Refresh live output (:inbox)\n" "\n" @@ -83,17 +92,23 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "\n" "HELP SCREEN KEYS:\n" " q, ESC - Close help\n" - " j/k - Scroll down/up\n" + " j/k, arrows - Scroll down/up\n" " Ctrl+D/U - Scroll half page down/up\n" " Ctrl+F/B - Scroll full page down/up\n" + " Space/b - Scroll full page down/up\n" + " PgDn/PgUp - Scroll full page down/up\n" + " End/Home - Jump to bottom/top\n" " g/G - Jump to top/bottom\n" " l - Cycle UI language\n", "\n" "命令输出按键:\n" " q, ESC - 关闭输出\n" - " j/k - 向下/上滚动\n" + " j/k, arrows - 向下/上滚动\n" " Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+F/B - 向下/上滚动整页\n" + " Space/b - 向下/上滚动整页\n" + " PgDn/PgUp - 向下/上滚动整页\n" + " End/Home - 跳到底部/顶部\n" " g/G - 跳到顶部/底部\n" " r - 刷新动态输出 (:inbox)\n" "\n" @@ -103,9 +118,12 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "\n" "帮助界面按键:\n" " q, ESC - 关闭帮助\n" - " j/k - 向下/上滚动\n" + " j/k, arrows - 向下/上滚动\n" " Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+F/B - 向下/上滚动整页\n" + " Space/b - 向下/上滚动整页\n" + " PgDn/PgUp - 向下/上滚动整页\n" + " End/Home - 跳到底部/顶部\n" " g/G - 跳到顶部/底部\n" " l - 切换界面语言\n" ); diff --git a/src/input.c b/src/input.c index cf54e22..01eadc9 100644 --- a/src/input.c +++ b/src/input.c @@ -32,10 +32,10 @@ static int read_username(client_t *client) { char username[MAX_USERNAME_LEN] = {0}; int pos = 0; char buf[4]; + const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT); tui_render_welcome(client); - client_printf(client, "%s", i18n_text(client->ui_lang, - I18N_USERNAME_PROMPT)); + client_printf(client, "%s", prompt); while (1) { int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */ @@ -54,6 +54,18 @@ static int read_username(client_t *client) { if (b == '\r' || b == '\n') { break; + } else if (b == 3 || b == 4) { /* Ctrl+C / Ctrl+D */ + return -1; + } else if (b == 21) { /* Ctrl+U: clear line */ + username[0] = '\0'; + pos = 0; + client_printf(client, "\r\033[K%s", prompt); + } else if (b == 23) { /* Ctrl+W: delete word */ + if (username[0] != '\0') { + utf8_remove_last_word(username); + pos = (int)strlen(username); + client_printf(client, "\r\033[K%s%s", prompt, username); + } } else if (b == 127 || b == 8) { /* Backspace */ if (pos > 0) { /* Compute width of the last character before removing it */ @@ -230,6 +242,110 @@ static void dismiss_command_output(client_t *client) { tui_render_screen(client); } +typedef enum { + PAGER_ACTION_NONE, + PAGER_ACTION_SCROLL, + PAGER_ACTION_CLOSE, + PAGER_ACTION_REFRESH +} pager_action_t; + +static int pager_page_height(client_t *client) { + int page = client->height - 2; + if (page < 1) page = 1; + return page; +} + +static void pager_scroll_by(int *scroll_pos, int delta) { + *scroll_pos += delta; + if (*scroll_pos < 0) { + *scroll_pos = 0; + } +} + +static pager_action_t pager_apply_key(client_t *client, unsigned char key, + int *scroll_pos, bool allow_refresh) { + int page = pager_page_height(client); + int half = page / 2; + if (half < 1) half = 1; + + if (key == 'q') { + return PAGER_ACTION_CLOSE; + } else if (key == 'j') { + pager_scroll_by(scroll_pos, 1); + return PAGER_ACTION_SCROLL; + } else if (key == 'k') { + pager_scroll_by(scroll_pos, -1); + return PAGER_ACTION_SCROLL; + } else if (key == 4) { /* Ctrl+D: half page down */ + pager_scroll_by(scroll_pos, half); + return PAGER_ACTION_SCROLL; + } else if (key == 21) { /* Ctrl+U: half page up */ + pager_scroll_by(scroll_pos, -half); + return PAGER_ACTION_SCROLL; + } else if (key == 6 || key == ' ') { /* Ctrl+F / Space: page down */ + pager_scroll_by(scroll_pos, page); + return PAGER_ACTION_SCROLL; + } else if (key == 2 || key == 'b') { /* Ctrl+B / b: page up */ + pager_scroll_by(scroll_pos, -page); + return PAGER_ACTION_SCROLL; + } else if (key == 'g') { + *scroll_pos = 0; + return PAGER_ACTION_SCROLL; + } else if (key == 'G') { + *scroll_pos = 999; + return PAGER_ACTION_SCROLL; + } else if ((key == 'r' || key == 'R') && allow_refresh) { + return PAGER_ACTION_REFRESH; + } else if (key == 27) { + char seq[3]; + int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50); + if (n != 1) { + return PAGER_ACTION_CLOSE; + } + if (seq[0] != '[') { + return PAGER_ACTION_NONE; + } + + n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50); + if (n != 1) { + return PAGER_ACTION_NONE; + } + + if (seq[1] == 'A') { /* Up arrow */ + pager_scroll_by(scroll_pos, -1); + return PAGER_ACTION_SCROLL; + } else if (seq[1] == 'B') { /* Down arrow */ + pager_scroll_by(scroll_pos, 1); + return PAGER_ACTION_SCROLL; + } else if (seq[1] == 'H') { /* Home */ + *scroll_pos = 0; + return PAGER_ACTION_SCROLL; + } else if (seq[1] == 'F') { /* End */ + *scroll_pos = 999; + return PAGER_ACTION_SCROLL; + } else if (seq[1] >= '1' && seq[1] <= '6') { + n = ssh_channel_read_timeout(client->channel, &seq[2], 1, 0, 50); + if (n == 1 && seq[2] == '~') { + if (seq[1] == '5') { /* PageUp */ + pager_scroll_by(scroll_pos, -page); + return PAGER_ACTION_SCROLL; + } else if (seq[1] == '6') { /* PageDown */ + pager_scroll_by(scroll_pos, page); + return PAGER_ACTION_SCROLL; + } else if (seq[1] == '1') { /* Home */ + *scroll_pos = 0; + return PAGER_ACTION_SCROLL; + } else if (seq[1] == '4') { /* End */ + *scroll_pos = 999; + return PAGER_ACTION_SCROLL; + } + } + } + } + + return PAGER_ACTION_NONE; +} + /* Handle a single key press. Returns true if the key was fully consumed * (no further character buffering needed). */ static bool handle_key(client_t *client, unsigned char key, char *input) { @@ -257,44 +373,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { /* Handle help screen */ if (client->show_help) { - /* Page size: roughly the visible help body region. */ - int page = client->height - 2; - if (page < 1) page = 1; - int half = page / 2; - if (half < 1) half = 1; + pager_action_t action; - if (key == 'q' || key == 27) { - client->show_help = false; - tui_render_screen(client); - } else if (key == 'l' || key == 'L') { + if (key == 'l' || key == 'L') { client->ui_lang = i18n_next_ui_lang(client->ui_lang); client->help_scroll_pos = 0; tui_render_help(client); - } else if (key == 'j') { - client->help_scroll_pos++; - tui_render_help(client); - } else if (key == 'k' && client->help_scroll_pos > 0) { - client->help_scroll_pos--; - tui_render_help(client); - } else if (key == 4) { /* Ctrl+D: half page down */ - client->help_scroll_pos += half; - tui_render_help(client); - } else if (key == 21) { /* Ctrl+U: half page up */ - client->help_scroll_pos -= half; - if (client->help_scroll_pos < 0) client->help_scroll_pos = 0; - tui_render_help(client); - } else if (key == 6) { /* Ctrl+F: full page down */ - client->help_scroll_pos += page; - tui_render_help(client); - } else if (key == 2) { /* Ctrl+B: full page up */ - client->help_scroll_pos -= page; - if (client->help_scroll_pos < 0) client->help_scroll_pos = 0; - tui_render_help(client); - } else if (key == 'g') { - client->help_scroll_pos = 0; - tui_render_help(client); - } else if (key == 'G') { - client->help_scroll_pos = 999; /* Large number */ + return true; + } + + action = pager_apply_key(client, key, &client->help_scroll_pos, false); + if (action == PAGER_ACTION_CLOSE) { + client->show_help = false; + tui_render_screen(client); + } else if (action == PAGER_ACTION_SCROLL) { tui_render_help(client); } return true; /* Key consumed */ @@ -303,56 +395,23 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { /* Handle command output / MOTD display. MOTD remains a simple notice; * command output behaves like a small pager so long results can be read. */ if (client->command_output[0] != '\0') { - int page = client->height - 2; - int half; + pager_action_t action; if (client->show_motd) { dismiss_command_output(client); return true; } - if (page < 1) page = 1; - half = page / 2; - if (half < 1) half = 1; - - if (key == 'q' || key == 27) { + action = pager_apply_key(client, key, &client->command_output_scroll, + true); + if (action == PAGER_ACTION_CLOSE) { dismiss_command_output(client); - } else if (key == 'j') { - client->command_output_scroll++; + } else if (action == PAGER_ACTION_SCROLL) { tui_render_command_output(client); - } else if (key == 'k') { - client->command_output_scroll--; - if (client->command_output_scroll < 0) { - client->command_output_scroll = 0; + } else if (action == PAGER_ACTION_REFRESH) { + if (commands_refresh_active_output(client)) { + tui_render_command_output(client); } - tui_render_command_output(client); - } else if (key == 4) { /* Ctrl+D: half page down */ - client->command_output_scroll += half; - tui_render_command_output(client); - } else if (key == 21) { /* Ctrl+U: half page up */ - client->command_output_scroll -= half; - if (client->command_output_scroll < 0) { - client->command_output_scroll = 0; - } - tui_render_command_output(client); - } else if (key == 6) { /* Ctrl+F: full page down */ - client->command_output_scroll += page; - tui_render_command_output(client); - } else if (key == 2) { /* Ctrl+B: full page up */ - client->command_output_scroll -= page; - if (client->command_output_scroll < 0) { - client->command_output_scroll = 0; - } - tui_render_command_output(client); - } else if (key == 'g') { - client->command_output_scroll = 0; - tui_render_command_output(client); - } else if (key == 'G') { - client->command_output_scroll = 999; - tui_render_command_output(client); - } else if ((key == 'r' || key == 'R') && - commands_refresh_active_output(client)) { - tui_render_command_output(client); } return true; /* Key consumed */ } @@ -571,6 +630,12 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { client->command_input[0] = '\0'; tui_render_screen(client); return true; + } else if (key == '/') { + client->mode = MODE_COMMAND; + snprintf(client->command_input, sizeof(client->command_input), + "search "); + tui_render_screen(client); + return true; } else if (key == 'j') { normal_scroll_by(client, 1); tui_render_screen(client); @@ -953,6 +1018,8 @@ main_loop: client->command_input[len] = b; client->command_input[len + 1] = '\0'; tui_render_screen(client); + } else { + client_send(client, "\a", 1); } } else if (b >= 128) { /* UTF-8 multi-byte */ int char_len = utf8_byte_length(b); @@ -965,10 +1032,12 @@ main_loop: } if (!utf8_is_valid_sequence(buf, char_len)) continue; size_t len = strlen(client->command_input); - if (len + (size_t)char_len < sizeof(client->command_input) - 1) { + if (len + (size_t)char_len <= sizeof(client->command_input) - 1) { memcpy(client->command_input + len, buf, char_len); client->command_input[len + char_len] = '\0'; tui_render_screen(client); + } else { + client_send(client, "\a", 1); } } } diff --git a/src/manual_text.c b/src/manual_text.c index 0887b72..f7badaf 100644 --- a/src/manual_text.c +++ b/src/manual_text.c @@ -12,8 +12,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size, " TNT - SSH terminal chat room\n" "\n" "\033[1;37mUse\033[0m\n" - " Type a message and press Enter; Esc browses; G latest; i types\n" - " : runs commands; ? opens the full key reference\n" + " Type, Enter sends; Up/Down recalls; Tab completes @mentions\n" + " Esc browses; / searches; G latest; i types; : commands; ? keys\n" "\n" "\033[1;37mCommands\033[0m\n", "\033[1;36mTNT(1) 帮助\033[0m\n" @@ -22,8 +22,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size, " TNT - SSH 终端聊天室\n" "\n" "\033[1;37m使用\033[0m\n" - " 输入消息并 Enter 发送;Esc 浏览历史;G 最新;i 输入\n" - " : 运行命令;? 打开完整按键参考\n" + " 输入并 Enter 发送;Up/Down 调出消息;Tab 补全 @mention\n" + " Esc 浏览;/ 搜索;G 最新;i 输入;: 命令;? 按键\n" "\n" "\033[1;37m命令\033[0m\n" ); diff --git a/src/tui_status.c b/src/tui_status.c index ed3b851..af72a7e 100644 --- a/src/tui_status.c +++ b/src/tui_status.c @@ -1,6 +1,54 @@ #include "tui_status.h" #include "i18n.h" #include "ssh_server.h" +#include "utf8.h" + +static void format_command_input_tail(const char *input, int avail_width, + char *display, size_t display_size) { + if (!input || !display || display_size == 0) return; + + display[0] = '\0'; + if (avail_width < 1) { + return; + } + + if (utf8_string_width(input) <= avail_width) { + strncpy(display, input, display_size - 1); + display[display_size - 1] = '\0'; + return; + } + + const char *marker = "<"; + int marker_width = 1; + int tail_width = avail_width - marker_width; + if (tail_width < 1) { + snprintf(display, display_size, "%s", marker); + return; + } + + const char *p = input + strlen(input); + const char *tail = p; + int width = 0; + + while (p > input && width < tail_width) { + const char *q = p - 1; + while (q > input && ((*q & 0xC0) == 0x80)) { + q--; + } + + int bytes_read = 0; + uint32_t cp = utf8_decode(q, &bytes_read); + int char_width = utf8_char_width(cp); + if (width + char_width > tail_width) { + break; + } + width += char_width; + tail = q; + p = q; + } + + snprintf(display, display_size, "%s%s", marker, tail); +} void tui_status_append(char *buffer, size_t buf_size, size_t *pos, const struct client *client, int msg_count, @@ -48,7 +96,12 @@ void tui_status_append(char *buffer, size_t buf_size, size_t *pos, i18n_text(client->ui_lang, I18N_NORMAL_LATEST)); } } else if (client->mode == MODE_COMMAND) { + char display[sizeof(client->command_input) + 2]; + int avail = client->width - 1; + if (avail < 1) avail = 1; + format_command_input_tail(client->command_input, avail, display, + sizeof(display)); buffer_appendf(buffer, buf_size, pos, - "\033[35m:\033[0m%s\033[K", client->command_input); + "\033[35m:\033[0m%s\033[K", display); } } diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index 26322d6..da7b6f8 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -58,6 +58,51 @@ else exit 1 fi +USERNAME_CANCEL_SCRIPT="$STATE_DIR/username-cancel.expect" +cat >"$USERNAME_CANCEL_SCRIPT" <"$STATE_DIR/username-cancel.log" 2>&1; then + echo "✓ Ctrl+C cancels before username join" + PASS=$((PASS + 1)) +else + echo "x Ctrl+C before username failed" + sed -n '1,120p' "$STATE_DIR/username-cancel.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +USERNAME_EDIT_SCRIPT="$STATE_DIR/username-edit.expect" +cat >"$USERNAME_EDIT_SCRIPT" <"$STATE_DIR/username-edit.log" 2>&1 && + grep -q 'editeduser' "$STATE_DIR/messages.log" && + ! grep -q 'wrongediteduser' "$STATE_DIR/messages.log"; then + echo "✓ Ctrl+U edits username before join" + PASS=$((PASS + 1)) +else + echo "x username line editing failed" + sed -n '1,120p' "$STATE_DIR/username-edit.log" 2>/dev/null || true + cat "$STATE_DIR/messages.log" 2>/dev/null || true + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect" cat >"$EXPECT_SCRIPT" <"$HELP_PAGER_KEYS_SCRIPT" <"$STATE_DIR/help-pager-keys.log" 2>&1; then + echo "✓ help pager accepts terminal paging keys" + PASS=$((PASS + 1)) +else + echo "x help pager terminal keys failed" + sed -n '1,220p' "$STATE_DIR/help-pager-keys.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect" cat >"$UNKNOWN_SCRIPT" <"$COMMAND_INPUT_WRAP_SCRIPT" <"$STATE_DIR/command-input-wrap.log" 2>&1; then + echo "✓ long command input stays on one status line" + PASS=$((PASS + 1)) +else + echo "x long command input display failed" + sed -n '1,220p' "$STATE_DIR/command-input-wrap.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect" cat >"$SYSTEM_MESSAGES_SCRIPT" < Date: Thu, 28 May 2026 08:59:54 +0800 Subject: [PATCH 16/31] Tighten CLI option diagnostics --- Makefile | 3 +- README.md | 5 ++- docs/CHANGELOG.md | 9 +++++ docs/EASY_SETUP.md | 8 +++- src/main.c | 67 +++++++++++++++++++++++--------- src/tntctl.c | 11 +----- tests/test_cli_options.sh | 82 +++++++++++++++++++++++++++++++++++++++ tests/test_tntctl_cli.sh | 11 ++++++ 8 files changed, 165 insertions(+), 31 deletions(-) create mode 100755 tests/test_cli_options.sh diff --git a/Makefile b/Makefile index c5281c9..a7de198 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d) TARGET = tnt CTL_TARGET = tntctl -CTL_OBJECTS = $(OBJ_DIR)/tntctl.o +CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o TARGETS = $(TARGET) $(CTL_TARGET) PREFIX ?= /usr/local @@ -122,6 +122,7 @@ unit-test: script-test: all @echo "Running script tests..." + @cd tests && ./test_cli_options.sh @cd tests && ./test_logrotate.sh @cd tests && ./test_message_log_tool.sh diff --git a/README.md b/README.md index 202fe2f..7adf3e0 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,12 @@ PORT=3333 tnt # via env var ### Connecting ```sh -ssh -p 2222 chat.example.com +ssh -p 2222 localhost ``` +For a deployed server, replace `localhost` with your public host, for example +`chat.example.com`. + **Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers. ## Usage diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 73d689b..6889382 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -100,6 +100,13 @@ source release. - Release documentation now creates the local tag before strict release checks, matching the strict gate's tag-at-HEAD requirement. +- Startup option parsing now reports missing values for `--bind`, `-p`, + `--idle-timeout`, and related flags with the localized + "option requires argument" diagnostic instead of treating the option as + unknown. +- `tntctl` now reuses the SSH exec command matcher for local command + validation, so `tntctl host --help` reaches the server-side exec help alias + instead of being rejected locally. - 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. @@ -121,6 +128,8 @@ reducing duplicate command knowledge in `src/exec.c`. - Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so public documentation does not imply a specific production host. +- First-run connection examples now use `localhost`, keeping + `chat.example.com` for deployed public-host examples. - Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so `src/exec.c` no longer duplicates `--json` and required-message validation. - Moved interactive command usage text and first-pass argument-shape checks diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index 7af0f4d..9af0b91 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -37,9 +37,11 @@ tnt -p 2222 -d /var/lib/tnt ## Connect ```sh -ssh -p 2222 chat.example.com +ssh -p 2222 localhost ``` +For a deployed server, replace `localhost` with your public host. + Default access rules: - Any SSH username is accepted. @@ -199,9 +201,11 @@ tnt ### 连接 ```sh -ssh -p 2222 chat.example.com +ssh -p 2222 localhost ``` +部署到公网后,将 `localhost` 替换为你的域名。 + 默认情况下,任意 SSH 用户名和空密码都可以连接。进入后 TNT 会询问显示名称。 ### 常用操作 diff --git a/src/main.c b/src/main.c index 81ea22f..b1f8058 100644 --- a/src/main.c +++ b/src/main.c @@ -75,6 +75,16 @@ static int set_numeric_env_option(const char *env_name, const char *opt_name, return TNT_EXIT_OK; } +static bool require_option_arg(int argc, char **argv, int index, + ui_lang_t lang) { + if (index + 1 >= argc || argv[index + 1][0] == '\0') { + fprintf(stderr, cli_text_option_requires_arg_format(lang), + argv[index]); + return false; + } + return true; +} + int main(int argc, char **argv) { int port = DEFAULT_PORT; ui_lang_t lang = i18n_default_ui_lang(); @@ -93,9 +103,11 @@ int main(int argc, char **argv) { /* Parse command line arguments */ for (int i = 1; i < argc; i++) { - if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) && - i + 1 < argc) { + if (strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) { int val; + if (!require_option_arg(argc, argv, i, lang)) { + return TNT_EXIT_USAGE; + } if (!parse_int_arg(argv[i + 1], 1, 65535, &val)) { fprintf(stderr, cli_text_invalid_port_format(lang), argv[i + 1]); @@ -103,18 +115,19 @@ int main(int argc, char **argv) { } port = val; i++; - } else if ((strcmp(argv[i], "-d") == 0 || - strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) { - if (argv[i + 1][0] == '\0') { - fprintf(stderr, cli_text_invalid_value_format(lang), - argv[i], argv[i + 1]); + } else if (strcmp(argv[i], "-d") == 0 || + strcmp(argv[i], "--state-dir") == 0) { + if (!require_option_arg(argc, argv, i, lang)) { return TNT_EXIT_USAGE; } if (set_env_option("TNT_STATE_DIR", argv[i + 1]) != 0) { return TNT_EXIT_ERROR; } i++; - } else if (strcmp(argv[i], "--bind") == 0 && i + 1 < argc) { + } else if (strcmp(argv[i], "--bind") == 0) { + if (!require_option_arg(argc, argv, i, lang)) { + return TNT_EXIT_USAGE; + } if (!is_config_token(argv[i + 1])) { fprintf(stderr, cli_text_invalid_value_format(lang), argv[i], argv[i + 1]); @@ -124,7 +137,10 @@ int main(int argc, char **argv) { return TNT_EXIT_ERROR; } i++; - } else if (strcmp(argv[i], "--public-host") == 0 && i + 1 < argc) { + } else if (strcmp(argv[i], "--public-host") == 0) { + if (!require_option_arg(argc, argv, i, lang)) { + return TNT_EXIT_USAGE; + } if (!is_config_token(argv[i + 1])) { fprintf(stderr, cli_text_invalid_value_format(lang), argv[i], argv[i + 1]); @@ -134,8 +150,10 @@ int main(int argc, char **argv) { return TNT_EXIT_ERROR; } i++; - } else if (strcmp(argv[i], "--max-connections") == 0 && - i + 1 < argc) { + } else if (strcmp(argv[i], "--max-connections") == 0) { + if (!require_option_arg(argc, argv, i, lang)) { + return TNT_EXIT_USAGE; + } int rc = set_numeric_env_option("TNT_MAX_CONNECTIONS", argv[i], argv[i + 1], 1, MAX_CONFIGURED_CLIENTS, lang); @@ -143,8 +161,10 @@ int main(int argc, char **argv) { return rc; } i++; - } else if (strcmp(argv[i], "--max-conn-per-ip") == 0 && - i + 1 < argc) { + } else if (strcmp(argv[i], "--max-conn-per-ip") == 0) { + if (!require_option_arg(argc, argv, i, lang)) { + return TNT_EXIT_USAGE; + } int rc = set_numeric_env_option("TNT_MAX_CONN_PER_IP", argv[i], argv[i + 1], 1, MAX_CONFIGURED_CLIENTS, lang); @@ -152,8 +172,10 @@ int main(int argc, char **argv) { return rc; } i++; - } else if (strcmp(argv[i], "--max-conn-rate-per-ip") == 0 && - i + 1 < argc) { + } else if (strcmp(argv[i], "--max-conn-rate-per-ip") == 0) { + if (!require_option_arg(argc, argv, i, lang)) { + return TNT_EXIT_USAGE; + } int rc = set_numeric_env_option("TNT_MAX_CONN_RATE_PER_IP", argv[i], argv[i + 1], 1, MAX_CONFIGURED_CLIENTS, lang); @@ -161,21 +183,30 @@ int main(int argc, char **argv) { return rc; } i++; - } else if (strcmp(argv[i], "--rate-limit") == 0 && i + 1 < argc) { + } else if (strcmp(argv[i], "--rate-limit") == 0) { + if (!require_option_arg(argc, argv, i, lang)) { + return TNT_EXIT_USAGE; + } int rc = set_numeric_env_option("TNT_RATE_LIMIT", argv[i], argv[i + 1], 0, 1, lang); if (rc != TNT_EXIT_OK) { return rc; } i++; - } else if (strcmp(argv[i], "--idle-timeout") == 0 && i + 1 < argc) { + } else if (strcmp(argv[i], "--idle-timeout") == 0) { + if (!require_option_arg(argc, argv, i, lang)) { + return TNT_EXIT_USAGE; + } int rc = set_numeric_env_option("TNT_IDLE_TIMEOUT", argv[i], argv[i + 1], 0, 86400, lang); if (rc != TNT_EXIT_OK) { return rc; } i++; - } else if (strcmp(argv[i], "--ssh-log-level") == 0 && i + 1 < argc) { + } else if (strcmp(argv[i], "--ssh-log-level") == 0) { + if (!require_option_arg(argc, argv, i, lang)) { + return TNT_EXIT_USAGE; + } int rc = set_numeric_env_option("TNT_SSH_LOG_LEVEL", argv[i], argv[i + 1], 0, 4, lang); if (rc != TNT_EXIT_OK) { diff --git a/src/tntctl.c b/src/tntctl.c index 9d21850..673e8b8 100644 --- a/src/tntctl.c +++ b/src/tntctl.c @@ -1,4 +1,5 @@ #include "common.h" +#include "exec_catalog.h" #include #include @@ -73,15 +74,7 @@ static bool is_host_key_checking_mode(const char *value) { } 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, "dump") == 0 || - strcmp(command, "post") == 0 || - strcmp(command, "help") == 0 || - strcmp(command, "exit") == 0); + return exec_catalog_match(command, NULL, NULL); } static int build_remote_command(char *buffer, size_t buf_size, int argc, diff --git a/tests/test_cli_options.sh b/tests/test_cli_options.sh new file mode 100755 index 0000000..0deb788 --- /dev/null +++ b/tests/test_cli_options.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# CLI option parsing regression tests. + +BIN="../tnt" +PASS=0 +FAIL=0 + +pass() { + echo "✓ $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "✗ $1" + if [ -n "$2" ]; then + printf '%s\n' "$2" + fi + FAIL=$((FAIL + 1)) +} + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +expect_missing_arg() { + opt="$1" + output=$("$BIN" "$opt" 2>&1) + status=$? + + if [ "$status" -eq 64 ] && + printf '%s\n' "$output" | grep -q "Option requires argument: $opt"; then + pass "$opt reports missing argument" + else + fail "$opt missing argument diagnostic unexpected" "$output" + fi +} + +echo "=== TNT CLI Option Tests ===" + +for opt in \ + -p \ + --port \ + -d \ + --state-dir \ + --bind \ + --public-host \ + --max-connections \ + --max-conn-per-ip \ + --max-conn-rate-per-ip \ + --rate-limit \ + --idle-timeout \ + --ssh-log-level \ + --log-check \ + --log-recover +do + expect_missing_arg "$opt" +done + +ZH_OUTPUT=$(TNT_LANG=zh "$BIN" --bind 2>&1) +ZH_STATUS=$? +if [ "$ZH_STATUS" -eq 64 ] && + printf '%s\n' "$ZH_OUTPUT" | grep -q '选项需要参数: --bind'; then + pass "missing argument diagnostic follows TNT_LANG" +else + fail "localized missing argument diagnostic unexpected" "$ZH_OUTPUT" +fi + +BAD_PORT_OUTPUT=$("$BIN" --port abc 2>&1) +BAD_PORT_STATUS=$? +if [ "$BAD_PORT_STATUS" -eq 64 ] && + printf '%s\n' "$BAD_PORT_OUTPUT" | grep -q 'Invalid port: abc'; then + pass "invalid port still reports invalid value" +else + fail "invalid port diagnostic unexpected" "$BAD_PORT_OUTPUT" +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/test_tntctl_cli.sh b/tests/test_tntctl_cli.sh index 12b66e0..face0e0 100755 --- a/tests/test_tntctl_cli.sh +++ b/tests/test_tntctl_cli.sh @@ -119,6 +119,17 @@ else FAIL=$((FAIL + 1)) fi +run_ok "remote help alias is accepted" "$BIN" example.com --help +grep -q '^--help$' "$SSH_LOG" +if [ $? -eq 0 ]; then + echo "✓ --help after host is forwarded as exec help" + PASS=$((PASS + 1)) +else + echo "✗ remote --help command 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 From b23b1ba19475c580d79a3968b64f47994b908f5d Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 09:04:24 +0800 Subject: [PATCH 17/31] Localize tntctl help and diagnostics --- Makefile | 2 +- docs/CHANGELOG.md | 2 + src/tntctl.c | 176 +++++++++++++++++++++++++++++++-------- tests/test_tntctl_cli.sh | 27 ++++++ 4 files changed, 172 insertions(+), 35 deletions(-) diff --git a/Makefile b/Makefile index a7de198..162d177 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d) TARGET = tnt CTL_TARGET = tntctl -CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o +CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/i18n.o TARGETS = $(TARGET) $(CTL_TARGET) PREFIX ?= /usr/local diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6889382..8e4f63a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -107,6 +107,8 @@ - `tntctl` now reuses the SSH exec command matcher for local command validation, so `tntctl host --help` reaches the server-side exec help alias instead of being rejected locally. +- `tntctl` local help and local validation errors now follow `TNT_LANG` and + locale selection, matching the server CLI's i18n behavior. - 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/src/tntctl.c b/src/tntctl.c index 673e8b8..20f4590 100644 --- a/src/tntctl.c +++ b/src/tntctl.c @@ -1,5 +1,6 @@ #include "common.h" #include "exec_catalog.h" +#include "i18n.h" #include #include @@ -7,21 +8,126 @@ #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, dump, post, help, and exit.\n"); +typedef enum { + TNTCTL_TEXT_USAGE, + TNTCTL_TEXT_INVALID_PORT, + TNTCTL_TEXT_INVALID_LOGIN, + TNTCTL_TEXT_INVALID_HOST_KEY_MODE, + TNTCTL_TEXT_INVALID_KNOWN_HOSTS, + TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT, + TNTCTL_TEXT_MISSING_HOST, + TNTCTL_TEXT_INVALID_HOST, + TNTCTL_TEXT_LOGIN_HOST_CONFLICT, + TNTCTL_TEXT_UNKNOWN_COMMAND, + TNTCTL_TEXT_INVALID_REMOTE_COMMAND, + TNTCTL_TEXT_DESTINATION_TOO_LONG, + TNTCTL_TEXT_INVALID_DESTINATION, + TNTCTL_TEXT_OUT_OF_MEMORY, + TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG, + TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG, + TNTCTL_TEXT_COUNT +} tntctl_text_id_t; + +static const i18n_string_t tntctl_text_catalog[TNTCTL_TEXT_COUNT] = { + [TNTCTL_TEXT_USAGE] = I18N_STRING( + "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, dump, post, help, and exit.\n", + "用法: tntctl [options] host command [args...]\n" + "\n" + "选项:\n" + " -p, --port PORT SSH 端口 (默认: 2222)\n" + " -l, --login USER SSH 登录名,用作 exec 身份\n" + " --host-key-checking MODE\n" + " OpenSSH 主机密钥模式: yes, accept-new, no\n" + " --known-hosts FILE OpenSSH known_hosts 文件\n" + " -V, --version 输出版本并退出\n" + " -h, --help 输出此帮助并退出\n" + "\n" + "命令对应 TNT SSH exec 接口: health, stats, users,\n" + "tail, dump, post, help 和 exit.\n" + ), + [TNTCTL_TEXT_INVALID_PORT] = I18N_STRING( + "invalid port", "端口无效" + ), + [TNTCTL_TEXT_INVALID_LOGIN] = I18N_STRING( + "invalid login", "登录名无效" + ), + [TNTCTL_TEXT_INVALID_HOST_KEY_MODE] = I18N_STRING( + "invalid host-key checking mode", "主机密钥检查模式无效" + ), + [TNTCTL_TEXT_INVALID_KNOWN_HOSTS] = I18N_STRING( + "invalid known_hosts path", "known_hosts 路径无效" + ), + [TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT] = I18N_STRING( + "unknown option: %s", "未知选项: %s" + ), + [TNTCTL_TEXT_MISSING_HOST] = I18N_STRING( + "missing host", "缺少 host" + ), + [TNTCTL_TEXT_INVALID_HOST] = I18N_STRING( + "invalid host", "host 无效" + ), + [TNTCTL_TEXT_LOGIN_HOST_CONFLICT] = I18N_STRING( + "use either --login or user@host, not both", + "只能使用 --login 或 user@host 之一" + ), + [TNTCTL_TEXT_UNKNOWN_COMMAND] = I18N_STRING( + "unknown or missing command", "未知命令或缺少命令" + ), + [TNTCTL_TEXT_INVALID_REMOTE_COMMAND] = I18N_STRING( + "invalid or too-long command", "命令无效或过长" + ), + [TNTCTL_TEXT_DESTINATION_TOO_LONG] = I18N_STRING( + "destination too long", "目标地址过长" + ), + [TNTCTL_TEXT_INVALID_DESTINATION] = I18N_STRING( + "invalid destination", "目标地址无效" + ), + [TNTCTL_TEXT_OUT_OF_MEMORY] = I18N_STRING( + "out of memory", "内存不足" + ), + [TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG] = I18N_STRING( + "host-key option too long", "主机密钥选项过长" + ), + [TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG] = I18N_STRING( + "known_hosts option too long", "known_hosts 选项过长" + ) +}; +typedef char tntctl_text_catalog_must_cover_enum[ + sizeof(tntctl_text_catalog) / sizeof(tntctl_text_catalog[0]) == + TNTCTL_TEXT_COUNT ? 1 : -1]; + +static const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id) { + if (id < 0 || id >= TNTCTL_TEXT_COUNT) { + return ""; + } + return i18n_string(tntctl_text_catalog[id], lang); +} + +static void print_usage(FILE *stream, ui_lang_t lang) { + fputs(tntctl_text(lang, TNTCTL_TEXT_USAGE), stream); +} + +static void print_error(ui_lang_t lang, tntctl_text_id_t id) { + fprintf(stderr, "tntctl: %s\n", tntctl_text(lang, id)); +} + +static void print_error_format(ui_lang_t lang, tntctl_text_id_t id, + const char *value) { + fprintf(stderr, "tntctl: "); + fprintf(stderr, tntctl_text(lang, id), value); + fputc('\n', stderr); } static bool is_valid_port(const char *value) { @@ -153,6 +259,7 @@ int main(int argc, char **argv) { char **ssh_argv = NULL; int ssh_argc = 0; int rc; + ui_lang_t lang = i18n_default_ui_lang(); for (i = 1; i < argc; i++) { if (strcmp(argv[i], "--") == 0) { @@ -160,7 +267,7 @@ int main(int argc, char **argv) { break; } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { - print_usage(stdout); + print_usage(stdout, lang); return TNT_EXIT_OK; } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) { @@ -169,7 +276,7 @@ int main(int argc, char **argv) { } 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"); + print_error(lang, TNTCTL_TEXT_INVALID_PORT); return TNT_EXIT_USAGE; } port = argv[++i]; @@ -177,26 +284,27 @@ int main(int argc, char **argv) { 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"); + print_error(lang, TNTCTL_TEXT_INVALID_LOGIN); return TNT_EXIT_USAGE; } login = argv[++i]; } else if (strcmp(argv[i], "--host-key-checking") == 0) { if (i + 1 >= argc || !is_host_key_checking_mode(argv[i + 1])) { - fprintf(stderr, "tntctl: invalid host-key checking mode\n"); + print_error(lang, TNTCTL_TEXT_INVALID_HOST_KEY_MODE); return TNT_EXIT_USAGE; } host_key_checking = argv[++i]; } else if (strcmp(argv[i], "--known-hosts") == 0) { if (i + 1 >= argc || argv[i + 1][0] == '\0' || has_newline(argv[i + 1])) { - fprintf(stderr, "tntctl: invalid known_hosts path\n"); + print_error(lang, TNTCTL_TEXT_INVALID_KNOWN_HOSTS); 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); + print_error_format(lang, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT, + argv[i]); + print_usage(stderr, lang); return TNT_EXIT_USAGE; } else { break; @@ -204,29 +312,29 @@ int main(int argc, char **argv) { } if (i >= argc) { - fprintf(stderr, "tntctl: missing host\n"); - print_usage(stderr); + print_error(lang, TNTCTL_TEXT_MISSING_HOST); + print_usage(stderr, lang); return TNT_EXIT_USAGE; } host = argv[i++]; if (is_safe_ssh_token(host)) { - fprintf(stderr, "tntctl: invalid host\n"); + print_error(lang, TNTCTL_TEXT_INVALID_HOST); return TNT_EXIT_USAGE; } if (login && strchr(host, '@')) { - fprintf(stderr, "tntctl: use either --login or user@host, not both\n"); + print_error(lang, TNTCTL_TEXT_LOGIN_HOST_CONFLICT); return TNT_EXIT_USAGE; } if (i >= argc || !is_known_exec_command(argv[i])) { - fprintf(stderr, "tntctl: unknown or missing command\n"); + print_error(lang, TNTCTL_TEXT_UNKNOWN_COMMAND); 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"); + print_error(lang, TNTCTL_TEXT_INVALID_REMOTE_COMMAND); return TNT_EXIT_USAGE; } @@ -234,24 +342,24 @@ int main(int argc, char **argv) { int n = snprintf(destination, sizeof(destination), "%s@%s", login, host); if (n < 0 || n >= (int)sizeof(destination)) { - fprintf(stderr, "tntctl: destination too long\n"); + print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG); return TNT_EXIT_USAGE; } } else { int n = snprintf(destination, sizeof(destination), "%s", host); if (n < 0 || n >= (int)sizeof(destination)) { - fprintf(stderr, "tntctl: destination too long\n"); + print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG); return TNT_EXIT_USAGE; } } if (destination[0] == '-') { - fprintf(stderr, "tntctl: invalid destination\n"); + print_error(lang, TNTCTL_TEXT_INVALID_DESTINATION); return TNT_EXIT_USAGE; } ssh_argv = calloc((size_t)argc * 2u + 8u, sizeof(*ssh_argv)); if (!ssh_argv) { - fprintf(stderr, "tntctl: out of memory\n"); + print_error(lang, TNTCTL_TEXT_OUT_OF_MEMORY); return TNT_EXIT_ERROR; } @@ -262,7 +370,7 @@ int main(int argc, char **argv) { 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"); + print_error(lang, TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG); free(ssh_argv); return TNT_EXIT_USAGE; } @@ -273,7 +381,7 @@ int main(int argc, char **argv) { 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"); + print_error(lang, TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG); free(ssh_argv); return TNT_EXIT_USAGE; } diff --git a/tests/test_tntctl_cli.sh b/tests/test_tntctl_cli.sh index face0e0..2acf5e9 100755 --- a/tests/test_tntctl_cli.sh +++ b/tests/test_tntctl_cli.sh @@ -73,6 +73,33 @@ case "$VERSION_OUTPUT" in *) echo "✗ version output unexpected: $VERSION_OUTPUT"; FAIL=$((FAIL + 1)) ;; esac +HELP_ZH=$(TNT_LANG=zh "$BIN" --help 2>/dev/null || true) +printf '%s\n' "$HELP_ZH" | grep -q '^用法: tntctl \[options\] host command \[args...\]' && +printf '%s\n' "$HELP_ZH" | grep -q '^选项:$' +if [ $? -eq 0 ]; then + echo "✓ local help follows TNT_LANG" + PASS=$((PASS + 1)) +else + echo "✗ localized help output unexpected" + printf '%s\n' "$HELP_ZH" + FAIL=$((FAIL + 1)) +fi + +rm -f "$SSH_LOG" +BAD_PORT_ZH=$(PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" TNT_LANG=zh "$BIN" -p nope example.com health 2>&1) +BAD_PORT_STATUS=$? +if [ "$BAD_PORT_STATUS" -eq 64 ] && + [ ! -f "$SSH_LOG" ] && + printf '%s\n' "$BAD_PORT_ZH" | grep -q '^tntctl: 端口无效$'; then + echo "✓ local diagnostics follow TNT_LANG" + PASS=$((PASS + 1)) +else + echo "✗ localized diagnostic unexpected" + printf '%s\n' "$BAD_PORT_ZH" + [ -f "$SSH_LOG" ] && echo "fake ssh was invoked" + FAIL=$((FAIL + 1)) +fi + run_ok "basic argv shape" "$BIN" -p 2222 example.com health grep -q '^example.com$' "$SSH_LOG" && grep -q '^health$' "$SSH_LOG" From 51f264bca2cca3627587cc6c987a0b3a3ca3c524 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 09:09:02 +0800 Subject: [PATCH 18/31] Add package system user metadata --- docs/CHANGELOG.md | 2 ++ packaging/arch/.SRCINFO | 2 ++ packaging/arch/PKGBUILD | 8 ++++++-- packaging/arch/tnt-chat.sysusers | 1 + packaging/debian/README.md | 2 ++ packaging/debian/debian/control | 3 ++- packaging/debian/debian/postinst | 10 ++++++++++ scripts/release_check.sh | 12 ++++++++++++ 8 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 packaging/arch/tnt-chat.sysusers create mode 100755 packaging/debian/debian/postinst diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8e4f63a..84171f4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -109,6 +109,8 @@ instead of being rejected locally. - `tntctl` local help and local validation errors now follow `TNT_LANG` and locale selection, matching the server CLI's i18n behavior. +- Arch and Debian packaging drafts now create the `tnt` system user used by + the packaged systemd unit, and release preflight checks that metadata. - 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/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO index 1c7bcb6..981cea4 100644 --- a/packaging/arch/.SRCINFO +++ b/packaging/arch/.SRCINFO @@ -10,6 +10,8 @@ pkgbase = tnt-chat makedepends = make depends = libssh source = tnt-chat-1.0.1.tar.gz::https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz + source = tnt-chat.sysusers sha256sums = SKIP + sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed pkgname = tnt-chat diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 1c1c1ab..f3bf187 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -9,8 +9,10 @@ url='https://github.com/m1ngsama/TNT' license=('MIT') depends=('libssh') makedepends=('gcc' 'make') -source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz") -sha256sums=('SKIP') +source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz" + "${pkgname}.sysusers") +sha256sums=('SKIP' + '8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed') build() { cd "TNT-${pkgver}" @@ -21,5 +23,7 @@ package() { cd "TNT-${pkgver}" make DESTDIR="${pkgdir}" PREFIX=/usr install make DESTDIR="${pkgdir}" PREFIX=/usr install-systemd + install -Dm644 "${srcdir}/${pkgname}.sysusers" \ + "${pkgdir}/usr/lib/sysusers.d/${pkgname}.conf" install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" } diff --git a/packaging/arch/tnt-chat.sysusers b/packaging/arch/tnt-chat.sysusers new file mode 100644 index 0000000..3040c61 --- /dev/null +++ b/packaging/arch/tnt-chat.sysusers @@ -0,0 +1 @@ +u tnt - "TNT chat server" /var/lib/tnt - diff --git a/packaging/debian/README.md b/packaging/debian/README.md index 377cc30..0e4e82e 100644 --- a/packaging/debian/README.md +++ b/packaging/debian/README.md @@ -47,3 +47,5 @@ debuild -S - Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl` - Runtime dependency: `libssh` - Optional systemd unit: `/usr/lib/systemd/system/tnt.service` +- System user: package maintainer scripts create `tnt:tnt`; the systemd unit + owns `/var/lib/tnt` through `StateDirectory=tnt` diff --git a/packaging/debian/debian/control b/packaging/debian/debian/control index d1c28fb..317c4ea 100644 --- a/packaging/debian/debian/control +++ b/packaging/debian/debian/control @@ -15,7 +15,8 @@ Package: tnt-chat Architecture: any Depends: ${misc:Depends}, - ${shlibs:Depends} + ${shlibs:Depends}, + adduser Description: SSH-native terminal chat server TNT is a minimalist terminal chat server accessed over SSH. It provides a Vim-style terminal interface, anonymous access by default, persistent message diff --git a/packaging/debian/debian/postinst b/packaging/debian/debian/postinst new file mode 100755 index 0000000..d7cf2c1 --- /dev/null +++ b/packaging/debian/debian/postinst @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +if [ "$1" = "configure" ] && ! getent passwd tnt >/dev/null; then + adduser --system --group --home /var/lib/tnt --no-create-home --gecos "TNT chat server" tnt +fi + +#DEBHELPER# + +exit 0 diff --git a/scripts/release_check.sh b/scripts/release_check.sh index aa8c0c8..d0ed501 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -164,8 +164,20 @@ sh -n install.sh step "checking Debian packaging metadata" [ -x packaging/debian/debian/rules ] || fail "packaging/debian/debian/rules must be executable" +[ -x packaging/debian/debian/postinst ] || + fail "packaging/debian/debian/postinst must be executable" grep -q "^3.0 (quilt)$" packaging/debian/debian/source/format || fail "unsupported Debian source format" +grep -q "adduser .* tnt" packaging/debian/debian/postinst || + fail "Debian postinst must create the tnt system user" +grep -q " adduser" packaging/debian/debian/control || + fail "Debian package must depend on adduser for postinst user creation" + +step "checking packaged system user metadata" +grep -q '^u tnt ' packaging/arch/tnt-chat.sysusers || + fail "Arch sysusers file must create the tnt system user" +grep -q 'usr/lib/sysusers.d' packaging/arch/PKGBUILD || + fail "PKGBUILD must install the sysusers.d file" step "checking packaging syntax" if command -v bash >/dev/null 2>&1; then From 57d0f931b5b3c439582afda9ee2a79e3556d0674 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 09:11:25 +0800 Subject: [PATCH 19/31] Add Homebrew service metadata --- docs/CHANGELOG.md | 2 ++ packaging/homebrew/README.md | 2 ++ packaging/homebrew/tnt-chat.rb | 11 +++++++++++ scripts/release_check.sh | 6 ++++++ 4 files changed, 21 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 84171f4..3ac38ea 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -111,6 +111,8 @@ locale selection, matching the server CLI's i18n behavior. - Arch and Debian packaging drafts now create the `tnt` system user used by the packaged systemd unit, and release preflight checks that metadata. +- The Homebrew formula draft now defines a `brew services` entry that runs the + installed `tnt` binary with state under `var/tnt`. - 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/packaging/homebrew/README.md b/packaging/homebrew/README.md index 1e5f717..fbc6cf9 100644 --- a/packaging/homebrew/README.md +++ b/packaging/homebrew/README.md @@ -6,6 +6,7 @@ project tap first, not Homebrew core: ```sh brew tap m1ngsama/tnt brew install tnt-chat +brew services start tnt-chat ``` Homebrew core should wait until TNT has stable releases and broader usage. @@ -18,6 +19,7 @@ From a tap repository: brew audit --strict --online tnt-chat brew install --build-from-source ./Formula/tnt-chat.rb brew test tnt-chat +brew services run tnt-chat ``` For local syntax-only validation from this repository: diff --git a/packaging/homebrew/tnt-chat.rb b/packaging/homebrew/tnt-chat.rb index 9611df5..967373c 100644 --- a/packaging/homebrew/tnt-chat.rb +++ b/packaging/homebrew/tnt-chat.rb @@ -15,6 +15,17 @@ class TntChat < Formula bin.install "#{buildpath}/stage#{prefix}/bin/tntctl" man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1" man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tntctl.1" + + (var/"tnt").mkpath + (var/"log").mkpath + end + + service do + run [opt_bin/"tnt", "-d", var/"tnt"] + keep_alive true + working_dir var/"tnt" + log_path var/"log/tnt.log" + error_log_path var/"log/tnt.log" end test do diff --git a/scripts/release_check.sh b/scripts/release_check.sh index d0ed501..090382c 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -179,6 +179,12 @@ grep -q '^u tnt ' packaging/arch/tnt-chat.sysusers || grep -q 'usr/lib/sysusers.d' packaging/arch/PKGBUILD || fail "PKGBUILD must install the sysusers.d file" +step "checking Homebrew service metadata" +grep -q "service do" packaging/homebrew/tnt-chat.rb || + fail "Homebrew formula must define a brew services entry" +grep -q 'opt_bin/"tnt"' packaging/homebrew/tnt-chat.rb || + fail "Homebrew service must run the installed tnt binary" + step "checking packaging syntax" if command -v bash >/dev/null 2>&1; then bash -n packaging/arch/PKGBUILD From d893351c5aaa9604bd0c4aa2da5dbc1b802eb308 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 09:14:36 +0800 Subject: [PATCH 20/31] Add language-keyed i18n string initializers --- docs/CHANGELOG.md | 3 +++ docs/Development-Guide.md | 7 ++++++- include/i18n.h | 6 +++++- tests/unit/test_i18n.c | 11 +++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3ac38ea..25ead24 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -113,6 +113,9 @@ the packaged systemd unit, and release preflight checks that metadata. - The Homebrew formula draft now defines a `brew services` entry that runs the installed `tnt` binary with state under `var/tnt`. +- The i18n helper now supports language-keyed string initializers through + `I18N_STRING_MAP`, so future languages can be added incrementally without + changing every existing two-language string initializer. - Split UI-language parsing from localized text lookup: `src/i18n.c` now owns locale/code parsing, while `src/i18n_text.c` owns the table-driven text catalog with coverage checks for every message ID. diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index 251f9aa..cd11dbf 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -480,6 +480,10 @@ keys. fragments. - Keep placeholders visible and stable, for example `%s`, `%d`, ``, and ``. + - Use `I18N_STRING(en, zh)` for ordinary two-language entries. Use + `I18N_STRING_MAP(I18N_EN(...), I18N_ZH(...))` when an entry needs + language-keyed initialization so future languages can be added without + changing every existing initializer. - Every new user-facing string needs tests for at least English fallback and Chinese output while this project has two UI languages. @@ -488,7 +492,8 @@ keys. The current `src/i18n_text.c` implementation is a small-project translation table implemented in C, not a full gettext catalog. It is acceptable for two languages because message lookup is already split from language parsing in -`src/i18n.c`, but adding more languages should move toward catalog-like +`src/i18n.c`, and localized strings can now be initialized by language key. +Adding many more languages should still move toward external catalog-like storage instead of adding ad hoc branches for every locale. Relevant conventions: diff --git a/include/i18n.h b/include/i18n.h index 219965b..fc0bd10 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -7,8 +7,12 @@ typedef struct { const char *text[UI_LANG_COUNT]; } i18n_string_t; +#define I18N_LANG_TEXT(lang, value) [lang] = (value) +#define I18N_EN(value) I18N_LANG_TEXT(UI_LANG_EN, value) +#define I18N_ZH(value) I18N_LANG_TEXT(UI_LANG_ZH, value) +#define I18N_STRING_MAP(...) {{ __VA_ARGS__ }} #define I18N_STRING(en_text, zh_text) \ - {{ [UI_LANG_EN] = (en_text), [UI_LANG_ZH] = (zh_text) }} + I18N_STRING_MAP(I18N_EN(en_text), I18N_ZH(zh_text)) typedef enum { I18N_USERNAME_PROMPT, diff --git a/tests/unit/test_i18n.c b/tests/unit/test_i18n.c index 73675ed..838dbff 100644 --- a/tests/unit/test_i18n.c +++ b/tests/unit/test_i18n.c @@ -80,10 +80,21 @@ TEST(default_uses_locale_when_no_tnt_lang) { TEST(text_lookup_matches_language) { i18n_string_t sample = I18N_STRING("fallback", "替代"); + i18n_string_t mapped = I18N_STRING_MAP( + I18N_EN("mapped fallback"), + I18N_ZH("映射替代") + ); + i18n_string_t english_only = I18N_STRING_MAP( + I18N_EN("english only") + ); assert(strcmp(i18n_string(sample, UI_LANG_EN), "fallback") == 0); assert(strcmp(i18n_string(sample, UI_LANG_ZH), "替代") == 0); assert(strcmp(i18n_string(sample, (ui_lang_t)99), "fallback") == 0); + assert(strcmp(i18n_string(mapped, UI_LANG_EN), "mapped fallback") == 0); + assert(strcmp(i18n_string(mapped, UI_LANG_ZH), "映射替代") == 0); + assert(strcmp(i18n_string(english_only, UI_LANG_ZH), + "english only") == 0); assert(strstr(i18n_text(UI_LANG_EN, I18N_USERNAME_PROMPT), "display name") != NULL); From d22d5160d75a01db60322cac23d1d3dd3cc7b663 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 09:23:43 +0800 Subject: [PATCH 21/31] Add Debian source package assembly --- Makefile | 5 +- docs/CHANGELOG.md | 3 ++ packaging/README.md | 13 +++++- packaging/debian/README.md | 11 ++--- scripts/package_debian_source.sh | 79 ++++++++++++++++++++++++++++++++ scripts/release_check.sh | 10 ++++ 6 files changed, 112 insertions(+), 9 deletions(-) create mode 100755 scripts/package_debian_source.sh diff --git a/Makefile b/Makefile index 162d177..fdbec19 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ MANDIR ?= $(PREFIX)/share/man SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) -.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test script-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info +.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info all: $(TARGETS) @@ -94,6 +94,9 @@ release-check: release-check-strict: ./scripts/release_check.sh --strict +debian-source-package: + ./scripts/package_debian_source.sh $${OUT_DIR:-dist/debian-source} + asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer asan: LDFLAGS += -fsanitize=address asan: clean $(TARGETS) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 25ead24..d76e1c5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -113,6 +113,9 @@ the packaged systemd unit, and release preflight checks that metadata. - The Homebrew formula draft now defines a `brew services` entry that runs the installed `tnt` binary with state under `var/tnt`. +- Added `scripts/package_debian_source.sh` and `make debian-source-package` + to assemble Debian/Ubuntu source-package trees from the current project + without publishing or uploading anything. - The i18n helper now supports language-keyed string initializers through `I18N_STRING_MAP`, so future languages can be added incrementally without changing every existing two-language string initializer. diff --git a/packaging/README.md b/packaging/README.md index d980509..45eeaf3 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -26,13 +26,22 @@ Package installs include both `tnt` and `tntctl`. `tnt` is the server process; make release-check ``` -6. Before submitting package recipes, replace checksum placeholders and run: +6. Assemble a Debian/PPA source tree when preparing Ubuntu packaging: + + ```sh + make debian-source-package + ``` + + Use `scripts/package_debian_source.sh --build` on a Debian/Ubuntu system + with `dpkg-buildpackage` installed to build the unsigned source package. + +7. Before submitting package recipes, replace checksum placeholders and run: ```sh make release-check-strict ``` -7. Submit packages manually: +8. Submit packages manually: - Arch: upload `PKGBUILD` and generated `.SRCINFO` to AUR. - Homebrew: open a PR to the project tap, or later Homebrew core if eligible. - Ubuntu: build Debian source packages and upload to a Launchpad PPA. diff --git a/packaging/debian/README.md b/packaging/debian/README.md index 0e4e82e..11c99b3 100644 --- a/packaging/debian/README.md +++ b/packaging/debian/README.md @@ -6,18 +6,17 @@ the project has a stable release cadence. ## Draft metadata -The `debian/` directory in this folder is a packaging draft. To test it against -an upstream release tree, copy it to the root of a clean source checkout: +The `debian/` directory in this folder is a packaging draft. To assemble it +against a clean source tree: ```sh -cp -a packaging/debian/debian ./debian -dpkg-buildpackage -us -uc +make debian-source-package ``` -For PPA uploads, build a signed source package instead: +For PPA uploads, build a source package on Debian/Ubuntu: ```sh -debuild -S +scripts/package_debian_source.sh --build ``` ## Recommended path diff --git a/scripts/package_debian_source.sh b/scripts/package_debian_source.sh new file mode 100755 index 0000000..3e6af8b --- /dev/null +++ b/scripts/package_debian_source.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# Assemble a Debian/Ubuntu source-package tree. This script never uploads. + +set -eu + +usage() { + cat <<'USAGE' +Usage: scripts/package_debian_source.sh [--build] [OUT_DIR] + +Create OUT_DIR/tnt-chat-$TNT_VERSION from tracked source files and copy the +draft Debian metadata to OUT_DIR/tnt-chat-$TNT_VERSION/debian. + +Options: + --build run dpkg-buildpackage -S -us -uc after assembly + +Default OUT_DIR: dist/debian-source +USAGE +} + +fail() { + echo "package-debian-source: $*" >&2 + exit 1 +} + +BUILD=0 +OUT_DIR=${TNT_DEBIAN_SOURCE_OUT:-dist/debian-source} +OUT_SET=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --build) + BUILD=1 + ;; + -h|--help) + usage + exit 0 + ;; + -*) + fail "unknown option: $1" + ;; + *) + [ "$OUT_SET" -eq 0 ] || fail "multiple output directories" + OUT_DIR=$1 + OUT_SET=1 + ;; + esac + shift +done + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +VERSION=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h) +[ -n "$VERSION" ] || fail "could not read TNT_VERSION" + +SOURCE_NAME="tnt-chat-$VERSION" +SOURCE_ROOT="$OUT_DIR/$SOURCE_NAME" + +[ ! -e "$SOURCE_ROOT" ] || fail "$SOURCE_ROOT already exists" +mkdir -p "$OUT_DIR" +mkdir -p "$SOURCE_ROOT" + +git ls-files -z | cpio -0 -pdm "$SOURCE_ROOT" >/dev/null 2>&1 +cp -R "$ROOT/packaging/debian/debian" "$SOURCE_ROOT/debian" + +[ -f "$SOURCE_ROOT/debian/control" ] || fail "missing debian/control" +[ -x "$SOURCE_ROOT/debian/rules" ] || fail "missing executable debian/rules" +[ -x "$SOURCE_ROOT/debian/postinst" ] || fail "missing executable debian/postinst" + +echo "Debian source tree assembled: $SOURCE_ROOT" + +if [ "$BUILD" -eq 1 ]; then + command -v dpkg-buildpackage >/dev/null 2>&1 || + fail "dpkg-buildpackage not found" + ( + cd "$SOURCE_ROOT" + dpkg-buildpackage -S -us -uc + ) +fi diff --git a/scripts/release_check.sh b/scripts/release_check.sh index 090382c..9139f9f 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -173,6 +173,16 @@ grep -q "adduser .* tnt" packaging/debian/debian/postinst || grep -q " adduser" packaging/debian/debian/control || fail "Debian package must depend on adduser for postinst user creation" +step "checking Debian source assembly" +sh -n scripts/package_debian_source.sh +scripts/package_debian_source.sh "$tmpdir/debian-source" +[ -f "$tmpdir/debian-source/tnt-chat-$version/debian/control" ] || + fail "assembled Debian source tree is missing debian/control" +[ -x "$tmpdir/debian-source/tnt-chat-$version/debian/rules" ] || + fail "assembled Debian source tree is missing executable debian/rules" +[ -x "$tmpdir/debian-source/tnt-chat-$version/debian/postinst" ] || + fail "assembled Debian source tree is missing executable debian/postinst" + step "checking packaged system user metadata" grep -q '^u tnt ' packaging/arch/tnt-chat.sysusers || fail "Arch sysusers file must create the tnt system user" From b71aa89a454f31a1e58636dad299fef490c03df5 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 09:25:12 +0800 Subject: [PATCH 22/31] Smoke-test installed log maintenance modes --- docs/CHANGELOG.md | 2 ++ scripts/release_check.sh | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d76e1c5..fef20c3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -116,6 +116,8 @@ - Added `scripts/package_debian_source.sh` and `make debian-source-package` to assemble Debian/Ubuntu source-package trees from the current project without publishing or uploading anything. +- Release preflight now smoke-tests the staged installed `tnt` binary's + `--log-check` and `--log-recover` modes, catching package artifact drift. - The i18n helper now supports language-keyed string initializers through `I18N_STRING_MAP`, so future languages can be added incrementally without changing every existing two-language string initializer. diff --git a/scripts/release_check.sh b/scripts/release_check.sh index 9139f9f..a06b309 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -158,6 +158,36 @@ make DESTDIR="$tmpdir" PREFIX=/usr install-systemd grep -q "^ExecStart=/usr/bin/tnt$" "$tmpdir/usr/lib/systemd/system/tnt.service" || fail "systemd unit ExecStart does not match PREFIX=/usr install path" +step "checking installed log maintenance modes" +log_smoke="$tmpdir/messages.log" +recovered_log="$tmpdir/recovered.messages.log" +recover_report="$tmpdir/recovered.report" +smoke_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) +cat > "$log_smoke" <"$tmpdir/log-check.out" 2>&1; then + fail "installed tnt --log-check should report invalid records" +fi +grep -q '^valid_records 2$' "$tmpdir/log-check.out" || + fail "installed tnt --log-check did not report valid records" +grep -q '^invalid_records 1$' "$tmpdir/log-check.out" || + fail "installed tnt --log-check did not report invalid records" +if "$tmpdir/usr/bin/tnt" --log-recover "$log_smoke" \ + >"$recovered_log" 2>"$recover_report"; then + fail "installed tnt --log-recover should report invalid records" +fi +grep -q "$smoke_ts|alice|one" "$recovered_log" || + fail "installed tnt --log-recover missed alice record" +grep -q "$smoke_ts|bob|two" "$recovered_log" || + fail "installed tnt --log-recover missed bob record" +! grep -q 'mallory' "$recovered_log" || + fail "installed tnt --log-recover preserved invalid record" +grep -q '^invalid_records 1$' "$recover_report" || + fail "installed tnt --log-recover did not report invalid records" + step "checking installer syntax" sh -n install.sh From 4175bd520f49335a006e9cb6aed47160bcbacc3a Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 09:26:27 +0800 Subject: [PATCH 23/31] Refresh release readiness roadmap --- docs/ROADMAP.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ad07aa3..1c1047b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -25,6 +25,7 @@ Goal: make TNT predictable for operators, scripts, and package maintainers. - `stats` - `users` - `tail` + - `dump` - `post` - ✅ support text and JSON output modes where machine use is likely - ✅ normalize command parsing, help text, and error reporting @@ -43,12 +44,13 @@ Goal: make long-running operation boring and reliable. - ✅ 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 + delivery where new features need cross-client notifications - ✅ add bounded outbound queues so closed SSH windows cannot immediately stall interactive output writes - separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly -- make room/client capacity fully runtime-configurable with no hidden compile-time ceiling -- document hard guarantees and soft limits +- ✅ make room/client capacity fully runtime-configurable with no hidden + compile-time ceiling +- ✅ document hard guarantees and soft limits ## Stage 3: Data and Persistence @@ -65,26 +67,30 @@ Goal: make stored history durable, inspectable, and recoverable. Goal: keep the interface efficient for terminal users without sacrificing simplicity. -- keep the current modal editing model, but make its behavior precise and documented -- support resize, cursor movement, command history, and predictable paste behavior +- ✅ keep the current modal editing model precise and documented +- ✅ support resize, command history, pager navigation, and predictable paste + behavior +- add in-line cursor movement/editing only if it can stay simple and testable - add useful chat commands with clear semantics: - ✅ `:nick` / `:name` — nickname change with broadcast - ✅ `/me` — action messages - ✅ `:last N` — show last N messages from disk history - ✅ `:search ` — case-insensitive full-text search - ✅ `:mute-joins` — per-client join/leave notification toggle -- improve discoverability of NORMAL and COMMAND mode actions -- make status lines and help output concise enough for small terminals +- ✅ improve discoverability of NORMAL and COMMAND mode actions +- ✅ make status lines and help output concise enough for small terminals ## Stage 5: Operations and Security Goal: make public deployment manageable. -- provide clear distinction between concurrent session limits and connection-rate limits +- ✅ provide clear distinction between concurrent session limits and + connection-rate limits - add admin-only controls for read-only mode, mute, and ban - ✅ expose a minimal health and stats surface suitable for monitoring - support systemd-friendly readiness and watchdog behavior -- document recommended production defaults for public, private, and localhost-only deployments +- ✅ document recommended production defaults for public, private, and + localhost-only deployments - tighten CI around authentication, limits, and restart behavior ## Stage 6: Release Quality @@ -97,6 +103,9 @@ Goal: make regressions harder to introduce. interface availability - ✅ add deeper slow-client coverage with a deliberately backpressured SSH client +- ✅ verify staged package installs, systemd unit paths, packaging metadata, + Debian source assembly, Homebrew service metadata, and installed log + maintenance modes in release preflight - keep deployment and test docs aligned with actual runtime behavior - require every user-visible interface change to update docs and tests in the same change set @@ -106,3 +115,7 @@ These are the next changes that should happen before new feature work expands th 1. Replace remaining release placeholders with real maintainer metadata and source-archive checksums when cutting a public package release. +2. Create or move the `v1.0.1` tag only when the release commit is final, then + run `make release-check-strict`. +3. Decide whether admin-only moderation controls belong in 1.0.x or should + wait for a later minor release. From fab8b315a5ac9e254a1e196041a00134dba3f563 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 09:40:55 +0800 Subject: [PATCH 24/31] Split tntctl local text catalog --- .gitignore | 1 + Makefile | 4 +- README.md | 1 + docs/CHANGELOG.md | 2 + docs/CONTRIBUTING.md | 1 + docs/QUICKREF.md | 1 + include/tntctl_text.h | 28 +++++++++ src/tntctl.c | 108 +--------------------------------- src/tntctl_text.c | 89 ++++++++++++++++++++++++++++ tests/unit/Makefile | 9 ++- tests/unit/test_tntctl_text.c | 53 +++++++++++++++++ 11 files changed, 187 insertions(+), 110 deletions(-) create mode 100644 include/tntctl_text.h create mode 100644 src/tntctl_text.c create mode 100644 tests/unit/test_tntctl_text.c diff --git a/.gitignore b/.gitignore index e4ea543..75e99b0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ tests/unit/test_help_text tests/unit/test_manual_text tests/unit/test_support_text tests/unit/test_cli_text +tests/unit/test_tntctl_text tests/unit/test_ratelimit diff --git a/Makefile b/Makefile index fdbec19..bb1828f 100644 --- a/Makefile +++ b/Makefile @@ -20,12 +20,12 @@ SRC_DIR = src INC_DIR = include OBJ_DIR = obj -SOURCES = $(filter-out $(SRC_DIR)/tntctl.c,$(wildcard $(SRC_DIR)/*.c)) +SOURCES = $(filter-out $(SRC_DIR)/tntctl.c $(SRC_DIR)/tntctl_text.c,$(wildcard $(SRC_DIR)/*.c)) OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d) TARGET = tnt CTL_TARGET = tntctl -CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/i18n.o +CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/tntctl_text.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/i18n.o TARGETS = $(TARGET) $(CTL_TARGET) PREFIX ?= /usr/local diff --git a/README.md b/README.md index 7adf3e0..d2018c9 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,7 @@ TNT/ │ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape │ ├── exec.c # SSH exec command dispatch │ ├── tntctl.c # local wrapper around the SSH exec interface +│ ├── tntctl_text.c # tntctl help and option text │ ├── ssh_server.c # SSH server implementation │ ├── bootstrap.c # SSH authentication and session bootstrap │ ├── chat_room.c # chat room logic diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fef20c3..9a0fe78 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased ### Added +- Added a dedicated `tntctl_text` module with unit coverage for local + `tntctl` help and validation diagnostics. - Documented the stable SSH exec interface contract, including exit statuses and JSON field shapes for package tests, scripts, and future `tntctl` work. - Documented `messages.log` v1 as the stable TNT 1.x persisted history format, diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 872d6d4..0024080 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -40,6 +40,7 @@ make check ``` main.c → entry point, signal handling cli_text.c → startup CLI text +tntctl_text.c → tntctl local help and diagnostics command_catalog.c → COMMAND-mode command metadata, usage, and argument shape commands.c → COMMAND-mode command dispatch exec_catalog.c → SSH exec command matching, usage, and argument shape diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index ca3f015..65809b0 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -66,6 +66,7 @@ MAINTENANCE STRUCTURE src/main.c entry, signals src/cli_text.c startup CLI text + src/tntctl_text.c tntctl local help and diagnostics src/command_catalog.c command metadata, usage, argument shape src/ssh_server.c SSH listener and server setup src/bootstrap.c SSH auth/session bootstrap diff --git a/include/tntctl_text.h b/include/tntctl_text.h new file mode 100644 index 0000000..ed8c385 --- /dev/null +++ b/include/tntctl_text.h @@ -0,0 +1,28 @@ +#ifndef TNTCTL_TEXT_H +#define TNTCTL_TEXT_H + +#include "common.h" + +typedef enum { + TNTCTL_TEXT_USAGE, + TNTCTL_TEXT_INVALID_PORT, + TNTCTL_TEXT_INVALID_LOGIN, + TNTCTL_TEXT_INVALID_HOST_KEY_MODE, + TNTCTL_TEXT_INVALID_KNOWN_HOSTS, + TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT, + TNTCTL_TEXT_MISSING_HOST, + TNTCTL_TEXT_INVALID_HOST, + TNTCTL_TEXT_LOGIN_HOST_CONFLICT, + TNTCTL_TEXT_UNKNOWN_COMMAND, + TNTCTL_TEXT_INVALID_REMOTE_COMMAND, + TNTCTL_TEXT_DESTINATION_TOO_LONG, + TNTCTL_TEXT_INVALID_DESTINATION, + TNTCTL_TEXT_OUT_OF_MEMORY, + TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG, + TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG, + TNTCTL_TEXT_COUNT +} tntctl_text_id_t; + +const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id); + +#endif /* TNTCTL_TEXT_H */ diff --git a/src/tntctl.c b/src/tntctl.c index 20f4590..47dbb0c 100644 --- a/src/tntctl.c +++ b/src/tntctl.c @@ -1,6 +1,7 @@ #include "common.h" #include "exec_catalog.h" #include "i18n.h" +#include "tntctl_text.h" #include #include @@ -8,113 +9,6 @@ #include #include -typedef enum { - TNTCTL_TEXT_USAGE, - TNTCTL_TEXT_INVALID_PORT, - TNTCTL_TEXT_INVALID_LOGIN, - TNTCTL_TEXT_INVALID_HOST_KEY_MODE, - TNTCTL_TEXT_INVALID_KNOWN_HOSTS, - TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT, - TNTCTL_TEXT_MISSING_HOST, - TNTCTL_TEXT_INVALID_HOST, - TNTCTL_TEXT_LOGIN_HOST_CONFLICT, - TNTCTL_TEXT_UNKNOWN_COMMAND, - TNTCTL_TEXT_INVALID_REMOTE_COMMAND, - TNTCTL_TEXT_DESTINATION_TOO_LONG, - TNTCTL_TEXT_INVALID_DESTINATION, - TNTCTL_TEXT_OUT_OF_MEMORY, - TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG, - TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG, - TNTCTL_TEXT_COUNT -} tntctl_text_id_t; - -static const i18n_string_t tntctl_text_catalog[TNTCTL_TEXT_COUNT] = { - [TNTCTL_TEXT_USAGE] = I18N_STRING( - "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, dump, post, help, and exit.\n", - "用法: tntctl [options] host command [args...]\n" - "\n" - "选项:\n" - " -p, --port PORT SSH 端口 (默认: 2222)\n" - " -l, --login USER SSH 登录名,用作 exec 身份\n" - " --host-key-checking MODE\n" - " OpenSSH 主机密钥模式: yes, accept-new, no\n" - " --known-hosts FILE OpenSSH known_hosts 文件\n" - " -V, --version 输出版本并退出\n" - " -h, --help 输出此帮助并退出\n" - "\n" - "命令对应 TNT SSH exec 接口: health, stats, users,\n" - "tail, dump, post, help 和 exit.\n" - ), - [TNTCTL_TEXT_INVALID_PORT] = I18N_STRING( - "invalid port", "端口无效" - ), - [TNTCTL_TEXT_INVALID_LOGIN] = I18N_STRING( - "invalid login", "登录名无效" - ), - [TNTCTL_TEXT_INVALID_HOST_KEY_MODE] = I18N_STRING( - "invalid host-key checking mode", "主机密钥检查模式无效" - ), - [TNTCTL_TEXT_INVALID_KNOWN_HOSTS] = I18N_STRING( - "invalid known_hosts path", "known_hosts 路径无效" - ), - [TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT] = I18N_STRING( - "unknown option: %s", "未知选项: %s" - ), - [TNTCTL_TEXT_MISSING_HOST] = I18N_STRING( - "missing host", "缺少 host" - ), - [TNTCTL_TEXT_INVALID_HOST] = I18N_STRING( - "invalid host", "host 无效" - ), - [TNTCTL_TEXT_LOGIN_HOST_CONFLICT] = I18N_STRING( - "use either --login or user@host, not both", - "只能使用 --login 或 user@host 之一" - ), - [TNTCTL_TEXT_UNKNOWN_COMMAND] = I18N_STRING( - "unknown or missing command", "未知命令或缺少命令" - ), - [TNTCTL_TEXT_INVALID_REMOTE_COMMAND] = I18N_STRING( - "invalid or too-long command", "命令无效或过长" - ), - [TNTCTL_TEXT_DESTINATION_TOO_LONG] = I18N_STRING( - "destination too long", "目标地址过长" - ), - [TNTCTL_TEXT_INVALID_DESTINATION] = I18N_STRING( - "invalid destination", "目标地址无效" - ), - [TNTCTL_TEXT_OUT_OF_MEMORY] = I18N_STRING( - "out of memory", "内存不足" - ), - [TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG] = I18N_STRING( - "host-key option too long", "主机密钥选项过长" - ), - [TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG] = I18N_STRING( - "known_hosts option too long", "known_hosts 选项过长" - ) -}; -typedef char tntctl_text_catalog_must_cover_enum[ - sizeof(tntctl_text_catalog) / sizeof(tntctl_text_catalog[0]) == - TNTCTL_TEXT_COUNT ? 1 : -1]; - -static const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id) { - if (id < 0 || id >= TNTCTL_TEXT_COUNT) { - return ""; - } - return i18n_string(tntctl_text_catalog[id], lang); -} - static void print_usage(FILE *stream, ui_lang_t lang) { fputs(tntctl_text(lang, TNTCTL_TEXT_USAGE), stream); } diff --git a/src/tntctl_text.c b/src/tntctl_text.c new file mode 100644 index 0000000..a0090b7 --- /dev/null +++ b/src/tntctl_text.c @@ -0,0 +1,89 @@ +#include "tntctl_text.h" + +#include "i18n.h" + +static const i18n_string_t text_catalog[TNTCTL_TEXT_COUNT] = { + [TNTCTL_TEXT_USAGE] = I18N_STRING( + "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, dump, post, help, and exit.\n", + "用法: tntctl [options] host command [args...]\n" + "\n" + "选项:\n" + " -p, --port PORT SSH 端口 (默认: 2222)\n" + " -l, --login USER SSH 登录名,用作 exec 身份\n" + " --host-key-checking MODE\n" + " OpenSSH 主机密钥模式: yes, accept-new, no\n" + " --known-hosts FILE OpenSSH known_hosts 文件\n" + " -V, --version 输出版本并退出\n" + " -h, --help 输出此帮助并退出\n" + "\n" + "命令对应 TNT SSH exec 接口: health, stats, users,\n" + "tail, dump, post, help 和 exit.\n" + ), + [TNTCTL_TEXT_INVALID_PORT] = I18N_STRING( + "invalid port", "端口无效" + ), + [TNTCTL_TEXT_INVALID_LOGIN] = I18N_STRING( + "invalid login", "登录名无效" + ), + [TNTCTL_TEXT_INVALID_HOST_KEY_MODE] = I18N_STRING( + "invalid host-key checking mode", "主机密钥检查模式无效" + ), + [TNTCTL_TEXT_INVALID_KNOWN_HOSTS] = I18N_STRING( + "invalid known_hosts path", "known_hosts 路径无效" + ), + [TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT] = I18N_STRING( + "unknown option: %s", "未知选项: %s" + ), + [TNTCTL_TEXT_MISSING_HOST] = I18N_STRING( + "missing host", "缺少 host" + ), + [TNTCTL_TEXT_INVALID_HOST] = I18N_STRING( + "invalid host", "host 无效" + ), + [TNTCTL_TEXT_LOGIN_HOST_CONFLICT] = I18N_STRING( + "use either --login or user@host, not both", + "只能使用 --login 或 user@host 之一" + ), + [TNTCTL_TEXT_UNKNOWN_COMMAND] = I18N_STRING( + "unknown or missing command", "未知命令或缺少命令" + ), + [TNTCTL_TEXT_INVALID_REMOTE_COMMAND] = I18N_STRING( + "invalid or too-long command", "命令无效或过长" + ), + [TNTCTL_TEXT_DESTINATION_TOO_LONG] = I18N_STRING( + "destination too long", "目标地址过长" + ), + [TNTCTL_TEXT_INVALID_DESTINATION] = I18N_STRING( + "invalid destination", "目标地址无效" + ), + [TNTCTL_TEXT_OUT_OF_MEMORY] = I18N_STRING( + "out of memory", "内存不足" + ), + [TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG] = I18N_STRING( + "host-key option too long", "主机密钥选项过长" + ), + [TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG] = I18N_STRING( + "known_hosts option too long", "known_hosts 选项过长" + ) +}; +typedef char text_catalog_must_cover_enum[ + sizeof(text_catalog) / sizeof(text_catalog[0]) == TNTCTL_TEXT_COUNT ? 1 : -1]; + +const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id) { + if (id < 0 || id >= TNTCTL_TEXT_COUNT) { + return ""; + } + return i18n_string(text_catalog[id], lang); +} diff --git a/tests/unit/Makefile b/tests/unit/Makefile index c6667c6..25bc8b5 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -16,6 +16,7 @@ MESSAGE_LOG_SRC = ../../src/message_log.c COMMON_SRC = ../../src/common.c COMMAND_CATALOG_SRC = ../../src/command_catalog.c CLI_TEXT_SRC = ../../src/cli_text.c +TNTCTL_TEXT_SRC = ../../src/tntctl_text.c CHAT_ROOM_SRC = ../../src/chat_room.c HISTORY_VIEW_SRC = ../../src/history_view.c I18N_SRC = ../../src/i18n.c @@ -26,7 +27,7 @@ HELP_TEXT_SRC = ../../src/help_text.c MANUAL_TEXT_SRC = ../../src/manual_text.c RATELIMIT_SRC = ../../src/ratelimit.c -TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_ratelimit +TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit .PHONY: all clean run @@ -65,6 +66,9 @@ test_manual_text: test_manual_text.c $(MANUAL_TEXT_SRC) $(COMMAND_CATALOG_SRC) $ test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) +test_tntctl_text: test_tntctl_text.c $(TNTCTL_TEXT_SRC) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + test_ratelimit: test_ratelimit.c $(RATELIMIT_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) @@ -102,6 +106,9 @@ run: all @echo "=== Running CLI Text Tests ===" ./test_cli_text @echo "" + @echo "=== Running tntctl Text Tests ===" + ./test_tntctl_text + @echo "" @echo "=== Running Rate Limit Tests ===" ./test_ratelimit diff --git a/tests/unit/test_tntctl_text.c b/tests/unit/test_tntctl_text.c new file mode 100644 index 0000000..b303ad5 --- /dev/null +++ b/tests/unit/test_tntctl_text.c @@ -0,0 +1,53 @@ +/* Unit tests for tntctl local help and diagnostic text */ + +#include "../../include/tntctl_text.h" +#include +#include +#include + +#define TEST(name) static void test_##name() +#define RUN_TEST(name) do { \ + printf("Running %s... ", #name); \ + test_##name(); \ + printf("✓\n"); \ + tests_passed++; \ +} while(0) + +static int tests_passed = 0; + +TEST(usage_matches_language) { + const char *en = tntctl_text(UI_LANG_EN, TNTCTL_TEXT_USAGE); + const char *zh = tntctl_text(UI_LANG_ZH, TNTCTL_TEXT_USAGE); + + assert(strstr(en, "Usage: tntctl [options] host command [args...]") != NULL); + assert(strstr(en, "--host-key-checking MODE") != NULL); + assert(strstr(en, "health, stats, users") != NULL); + assert(strstr(zh, "用法: tntctl [options] host command [args...]") != NULL); + assert(strstr(zh, "OpenSSH 主机密钥模式") != NULL); + assert(strstr(zh, "health, stats, users") != NULL); +} + +TEST(errors_match_language) { + assert(strcmp(tntctl_text(UI_LANG_EN, TNTCTL_TEXT_INVALID_PORT), + "invalid port") == 0); + assert(strcmp(tntctl_text(UI_LANG_ZH, TNTCTL_TEXT_INVALID_PORT), + "端口无效") == 0); + assert(strcmp(tntctl_text(UI_LANG_EN, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT), + "unknown option: %s") == 0); + assert(strcmp(tntctl_text(UI_LANG_ZH, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT), + "未知选项: %s") == 0); + assert(strcmp(tntctl_text((ui_lang_t)99, TNTCTL_TEXT_INVALID_PORT), + "invalid port") == 0); + assert(strcmp(tntctl_text(UI_LANG_EN, + (tntctl_text_id_t)TNTCTL_TEXT_COUNT), "") == 0); +} + +int main(void) { + printf("Running tntctl text unit tests...\n\n"); + + RUN_TEST(usage_matches_language); + RUN_TEST(errors_match_language); + + printf("\n✓ All %d tests passed!\n", tests_passed); + return 0; +} From f2be702a158789ecff18fd7c50ce84f677785fa4 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 10:28:02 +0800 Subject: [PATCH 25/31] Guard active help surfaces --- Makefile | 1 + docs/CHANGELOG.md | 3 ++ tests/test_docs_help_surface.sh | 74 +++++++++++++++++++++++++++++++++ tnt.1 | 23 ++++++++-- 4 files changed, 97 insertions(+), 4 deletions(-) create mode 100755 tests/test_docs_help_surface.sh diff --git a/Makefile b/Makefile index bb1828f..04a54f6 100644 --- a/Makefile +++ b/Makefile @@ -126,6 +126,7 @@ unit-test: script-test: all @echo "Running script tests..." @cd tests && ./test_cli_options.sh + @cd tests && ./test_docs_help_surface.sh @cd tests && ./test_logrotate.sh @cd tests && ./test_message_log_tool.sh diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9a0fe78..7168302 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -37,6 +37,9 @@ and server survival stay responsive. ### Changed +- Updated `tnt(1)` to document the current TUI search and pager keys, and + added script coverage to keep active help surfaces free of removed support + commands. - `make install-systemd` now rewrites the installed unit's `ExecStart` to match the selected `PREFIX`/`BINDIR`, so package builds that install to `/usr` produce a unit pointing at `/usr/bin/tnt`. diff --git a/tests/test_docs_help_surface.sh b/tests/test_docs_help_surface.sh new file mode 100755 index 0000000..6d768d3 --- /dev/null +++ b/tests/test_docs_help_surface.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# Regression checks for active help/manual surfaces. + +PASS=0 +FAIL=0 + +pass() { + echo "✓ $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "✗ $1" + if [ -n "$2" ]; then + printf '%s\n' "$2" + fi + FAIL=$((FAIL + 1)) +} + +require_fixed() { + file="$1" + text="$2" + label="$3" + + if grep -F -q "$text" "../$file"; then + pass "$label" + else + fail "$label missing" "$file: $text" + fi +} + +forbid_fixed() { + file="$1" + text="$2" + label="$3" + + if grep -F -q "$text" "../$file"; then + fail "$label still mentions $text" "$file" + else + pass "$label excludes $text" + fi +} + +echo "=== TNT Help Surface Tests ===" + +require_fixed "tnt.1" "/ Search message history" "manual documents NORMAL search" +require_fixed "tnt.1" "Space/b Scroll full page down/up" "manual documents space/b paging" +require_fixed "tnt.1" "PageDown/PageUp Scroll full page down/up" "manual documents page keys" +require_fixed "tnt.1" "End/Home Jump to bottom/top" "manual documents end/home" +require_fixed "tnt.1" "g/G Jump to top/bottom" "manual documents g/G" +require_fixed "tnt.1" ":lang Show current UI language" "manual documents current language" +require_fixed "tnt.1" ":lang \fIen|zh\fR Switch UI language for this session" "manual documents language codes" + +for file in \ + README.md \ + docs/EASY_SETUP.md \ + docs/DEPLOYMENT.md \ + docs/INTERFACE.md \ + docs/QUICKREF.md \ + docs/USER_LIFECYCLE.md \ + tnt.1 \ + tntctl.1 \ + src/command_catalog.c \ + src/help_text.c \ + src/manual_text.c +do + forbid_fixed "$file" ":support" "$file" +done + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tnt.1 b/tnt.1 index 3befbc9..62f740e 100644 --- a/tnt.1 +++ b/tnt.1 @@ -167,6 +167,8 @@ Press to return to INSERT, .B : to enter COMMAND mode, +.B / +to search message history, .B ? to open the full key reference. .TP @@ -198,6 +200,7 @@ Ctrl+F/Ctrl+B Scroll full page down/up PageDown/PageUp Scroll full page down/up End/Home Jump to bottom/top g/G Jump to top/bottom +/ Search message history i Switch to INSERT : Enter COMMAND mode ? Open full key reference @@ -219,6 +222,7 @@ l l. :last [\fIN\fR] Show last N messages from history (1\-50, default 10) :search \fIkeyword\fR Case\-insensitive search across full message history :mute\-joins Toggle join/leave system notifications on/off +:lang Show current UI language :lang \fIen|zh\fR Switch UI language for this session :help Show concise manual :clear Clear command output @@ -227,12 +231,23 @@ Up/Down Browse command history ESC Cancel and return to NORMAL .TE .PP -Command output pages use j/k, Ctrl+D/Ctrl+U, and g/G for paging. +Command output pages use the same paging keys as the help screen. +.TS +l l. +q, ESC Close output +j/k, arrows Scroll down/up +Ctrl+D/Ctrl+U Scroll half page down/up +Ctrl+F/Ctrl+B Scroll full page down/up +Space/b Scroll full page down/up +PageDown/PageUp Scroll full page down/up +End/Home Jump to bottom/top +g/G Jump to top/bottom +r Refresh live output (:inbox) +.TE +.PP The .B :inbox -page can also be refreshed with -.B r -and refreshes automatically when a new private message arrives while it is +page refreshes automatically when a new private message arrives while it is open. .SH EXEC INTERFACE Commands can be run non\-interactively for scripting: From 8affea2508b07c27413b7f0b0dd48bc92be1632f Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 10:36:22 +0800 Subject: [PATCH 26/31] Generate tntctl command list from exec catalog --- docs/CHANGELOG.md | 2 + include/exec_catalog.h | 5 ++- include/tntctl_text.h | 3 +- src/exec.c | 2 + src/exec_catalog.c | 20 ++++++++++ src/tntctl.c | 7 +++- src/tntctl_text.c | 69 ++++++++++++++++++++-------------- tests/unit/Makefile | 2 +- tests/unit/test_exec_catalog.c | 11 ++++++ tests/unit/test_tntctl_text.c | 15 ++++++-- 10 files changed, 99 insertions(+), 37 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7168302..261edda 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -37,6 +37,8 @@ and server survival stay responsive. ### Changed +- `tntctl --help` now gets its exec command list from `exec_catalog`, reducing + duplicate command metadata between the local wrapper and SSH exec mode. - Updated `tnt(1)` to document the current TUI search and pager keys, and added script coverage to keep active help surfaces free of removed support commands. diff --git a/include/exec_catalog.h b/include/exec_catalog.h index cc7d573..211d67f 100644 --- a/include/exec_catalog.h +++ b/include/exec_catalog.h @@ -11,7 +11,8 @@ typedef enum { TNT_EXEC_COMMAND_TAIL, TNT_EXEC_COMMAND_DUMP, TNT_EXEC_COMMAND_POST, - TNT_EXEC_COMMAND_EXIT + TNT_EXEC_COMMAND_EXIT, + TNT_EXEC_COMMAND_COUNT } tnt_exec_command_id_t; bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id, @@ -19,6 +20,8 @@ bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id, bool exec_catalog_args_valid(tnt_exec_command_id_t id, const char *args); void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos, ui_lang_t lang); +void exec_catalog_append_command_list(char *buffer, size_t buf_size, + size_t *pos); void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos, tnt_exec_command_id_t id, ui_lang_t lang); diff --git a/include/tntctl_text.h b/include/tntctl_text.h index ed8c385..e987a25 100644 --- a/include/tntctl_text.h +++ b/include/tntctl_text.h @@ -4,7 +4,6 @@ #include "common.h" typedef enum { - TNTCTL_TEXT_USAGE, TNTCTL_TEXT_INVALID_PORT, TNTCTL_TEXT_INVALID_LOGIN, TNTCTL_TEXT_INVALID_HOST_KEY_MODE, @@ -23,6 +22,8 @@ typedef enum { TNTCTL_TEXT_COUNT } tntctl_text_id_t; +void tntctl_text_append_usage(char *buffer, size_t buf_size, size_t *pos, + ui_lang_t lang); const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id); #endif /* TNTCTL_TEXT_H */ diff --git a/src/exec.c b/src/exec.c index a1cccf7..845bfe2 100644 --- a/src/exec.c +++ b/src/exec.c @@ -517,6 +517,8 @@ int exec_dispatch(client_t *client) { return exec_command_post(client, args); case TNT_EXEC_COMMAND_EXIT: return TNT_EXIT_OK; + case TNT_EXEC_COMMAND_COUNT: + break; } } diff --git a/src/exec_catalog.c b/src/exec_catalog.c index d06e594..6f01d8d 100644 --- a/src/exec_catalog.c +++ b/src/exec_catalog.c @@ -155,6 +155,26 @@ void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos, } } +void exec_catalog_append_command_list(char *buffer, size_t buf_size, + size_t *pos) { + bool seen[TNT_EXEC_COMMAND_COUNT] = {0}; + size_t count = 0; + + for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { + tnt_exec_command_id_t id = entries[i].id; + + if (id < 0 || id >= TNT_EXEC_COMMAND_COUNT || seen[id]) { + continue; + } + if (count > 0) { + buffer_appendf(buffer, buf_size, pos, ", "); + } + buffer_appendf(buffer, buf_size, pos, "%s", entries[i].name); + seen[id] = true; + count++; + } +} + void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos, tnt_exec_command_id_t id, ui_lang_t lang) { const exec_catalog_entry_t *entry = entry_for_id(id); diff --git a/src/tntctl.c b/src/tntctl.c index 47dbb0c..e9d9672 100644 --- a/src/tntctl.c +++ b/src/tntctl.c @@ -10,7 +10,12 @@ #include static void print_usage(FILE *stream, ui_lang_t lang) { - fputs(tntctl_text(lang, TNTCTL_TEXT_USAGE), stream); + char output[2048]; + size_t pos = 0; + + output[0] = '\0'; + tntctl_text_append_usage(output, sizeof(output), &pos, lang); + fputs(output, stream); } static void print_error(ui_lang_t lang, tntctl_text_id_t id) { diff --git a/src/tntctl_text.c b/src/tntctl_text.c index a0090b7..3863026 100644 --- a/src/tntctl_text.c +++ b/src/tntctl_text.c @@ -1,36 +1,9 @@ #include "tntctl_text.h" +#include "exec_catalog.h" #include "i18n.h" static const i18n_string_t text_catalog[TNTCTL_TEXT_COUNT] = { - [TNTCTL_TEXT_USAGE] = I18N_STRING( - "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, dump, post, help, and exit.\n", - "用法: tntctl [options] host command [args...]\n" - "\n" - "选项:\n" - " -p, --port PORT SSH 端口 (默认: 2222)\n" - " -l, --login USER SSH 登录名,用作 exec 身份\n" - " --host-key-checking MODE\n" - " OpenSSH 主机密钥模式: yes, accept-new, no\n" - " --known-hosts FILE OpenSSH known_hosts 文件\n" - " -V, --version 输出版本并退出\n" - " -h, --help 输出此帮助并退出\n" - "\n" - "命令对应 TNT SSH exec 接口: health, stats, users,\n" - "tail, dump, post, help 和 exit.\n" - ), [TNTCTL_TEXT_INVALID_PORT] = I18N_STRING( "invalid port", "端口无效" ), @@ -79,7 +52,45 @@ static const i18n_string_t text_catalog[TNTCTL_TEXT_COUNT] = { ) }; typedef char text_catalog_must_cover_enum[ - sizeof(text_catalog) / sizeof(text_catalog[0]) == TNTCTL_TEXT_COUNT ? 1 : -1]; + sizeof(text_catalog) / sizeof(text_catalog[0]) == TNTCTL_TEXT_COUNT ? 1 : -1 +]; + +void tntctl_text_append_usage(char *buffer, size_t buf_size, size_t *pos, + ui_lang_t lang) { + static const i18n_string_t before_commands = I18N_STRING( + "Usage: tntctl [options] host command [args...]\n" + "\n" + "Options:\n" + " -p, --port PORT SSH port (default: 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:\n" + " ", + "用法: tntctl [options] host command [args...]\n" + "\n" + "选项:\n" + " -p, --port PORT SSH 端口 (默认: 2222)\n" + " -l, --login USER SSH 登录名,用作 exec 身份\n" + " --host-key-checking MODE\n" + " OpenSSH 主机密钥模式: yes, accept-new, no\n" + " --known-hosts FILE OpenSSH known_hosts 文件\n" + " -V, --version 输出版本并退出\n" + " -h, --help 输出此帮助并退出\n" + "\n" + "命令:\n" + " " + ); + + buffer_appendf(buffer, buf_size, pos, "%s", + i18n_string(before_commands, lang)); + exec_catalog_append_command_list(buffer, buf_size, pos); + buffer_appendf(buffer, buf_size, pos, "\n"); +} const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id) { if (id < 0 || id >= TNTCTL_TEXT_COUNT) { diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 25bc8b5..bf78445 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -66,7 +66,7 @@ test_manual_text: test_manual_text.c $(MANUAL_TEXT_SRC) $(COMMAND_CATALOG_SRC) $ test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -test_tntctl_text: test_tntctl_text.c $(TNTCTL_TEXT_SRC) +test_tntctl_text: test_tntctl_text.c $(TNTCTL_TEXT_SRC) $(EXEC_CATALOG_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) test_ratelimit: test_ratelimit.c $(RATELIMIT_SRC) $(COMMON_SRC) diff --git a/tests/unit/test_exec_catalog.c b/tests/unit/test_exec_catalog.c index c52bbbc..1587da7 100644 --- a/tests/unit/test_exec_catalog.c +++ b/tests/unit/test_exec_catalog.c @@ -124,6 +124,16 @@ TEST(generates_localized_usage) { assert(strcmp(en, "dump: usage: dump [N] | dump -n N\n") == 0); } +TEST(generates_unique_command_list) { + char output[256] = {0}; + size_t pos = 0; + + exec_catalog_append_command_list(output, sizeof(output), &pos); + + assert(strcmp(output, + "help, health, users, stats, tail, dump, post, exit") == 0); +} + int main(void) { printf("Running exec catalog unit tests...\n\n"); @@ -131,6 +141,7 @@ int main(void) { RUN_TEST(matches_exec_commands_and_args); RUN_TEST(validates_argument_shapes); RUN_TEST(generates_localized_usage); + RUN_TEST(generates_unique_command_list); printf("\n✓ All %d tests passed!\n", tests_passed); return 0; diff --git a/tests/unit/test_tntctl_text.c b/tests/unit/test_tntctl_text.c index b303ad5..4816d7e 100644 --- a/tests/unit/test_tntctl_text.c +++ b/tests/unit/test_tntctl_text.c @@ -16,15 +16,22 @@ static int tests_passed = 0; TEST(usage_matches_language) { - const char *en = tntctl_text(UI_LANG_EN, TNTCTL_TEXT_USAGE); - const char *zh = tntctl_text(UI_LANG_ZH, TNTCTL_TEXT_USAGE); + char en[2048] = {0}; + char zh[2048] = {0}; + size_t en_pos = 0; + size_t zh_pos = 0; + + tntctl_text_append_usage(en, sizeof(en), &en_pos, UI_LANG_EN); + tntctl_text_append_usage(zh, sizeof(zh), &zh_pos, UI_LANG_ZH); assert(strstr(en, "Usage: tntctl [options] host command [args...]") != NULL); assert(strstr(en, "--host-key-checking MODE") != NULL); - assert(strstr(en, "health, stats, users") != NULL); + assert(strstr(en, + "help, health, users, stats, tail, dump, post, exit") != NULL); assert(strstr(zh, "用法: tntctl [options] host command [args...]") != NULL); assert(strstr(zh, "OpenSSH 主机密钥模式") != NULL); - assert(strstr(zh, "health, stats, users") != NULL); + assert(strstr(zh, + "help, health, users, stats, tail, dump, post, exit") != NULL); } TEST(errors_match_language) { From f6d5765d81c1544c20f0904996f39a7f2a021a6a Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 10:38:27 +0800 Subject: [PATCH 27/31] Refresh development module map --- docs/Development-Guide.md | 10 ++++++++++ tests/test_docs_help_surface.sh | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index cd11dbf..84492e2 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -72,6 +72,7 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th ``` src/ ├── main.c - CLI entry point and startup option parsing +├── cli_text.c - Server CLI help and option diagnostics ├── ssh_server.c - SSH listener setup and connection accept loop ├── bootstrap.c - SSH authentication/session bootstrap ├── input.c - Interactive session loop and key handling @@ -79,6 +80,8 @@ src/ ├── command_catalog.c - COMMAND-mode names, aliases, and help summaries ├── exec_catalog.c - SSH exec command matching and help metadata ├── exec.c - SSH exec command dispatch +├── tntctl.c - Local wrapper around the SSH exec interface +├── tntctl_text.c - tntctl local help and diagnostics ├── chat_room.c - Chat room state, message ring, and update sequence ├── message.c - Message persistence (RFC3339 format) ├── message_log.c - messages.log v1 parsing and formatting @@ -108,12 +111,17 @@ include/ ├── message_log.h - messages.log v1 parser/formatter interface ├── message_log_tool.h - Offline log check/recover interface ├── command_catalog.h - COMMAND-mode command metadata interface +├── exec_catalog.h - SSH exec command metadata interface +├── cli_text.h - Server CLI text interface +├── tntctl_text.h - tntctl text interface ├── history_view.h - Scroll-state helpers ├── tui.h - TUI rendering functions +├── tui_status.h - TUI status/input-line rendering interface ├── i18n.h - Language and shared text IDs ├── help_text.h - Key reference text interface ├── manual.h - Concise manual panel interface ├── manual_text.h - Concise manual text interface +├── system_message.h - Localized system message builders ├── ratelimit.h - Connection limit interface └── utf8.h - UTF-8 utilities ``` @@ -414,6 +422,8 @@ tests/test_slow_client.sh # slow SSH reader/backpressure behavior tests/unit/test_i18n.c # localized shared text tests/unit/test_command_catalog.c # interactive command metadata tests/unit/test_exec_catalog.c # exec command help metadata +tests/unit/test_tntctl_text.c # tntctl local help/diagnostic text +tests/test_docs_help_surface.sh # active help/manual drift checks ``` ### Adding a New Keybinding diff --git a/tests/test_docs_help_surface.sh b/tests/test_docs_help_surface.sh index 6d768d3..d63fb5e 100755 --- a/tests/test_docs_help_surface.sh +++ b/tests/test_docs_help_surface.sh @@ -3,6 +3,8 @@ PASS=0 FAIL=0 +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) pass() { echo "✓ $1" @@ -22,7 +24,7 @@ require_fixed() { text="$2" label="$3" - if grep -F -q "$text" "../$file"; then + if grep -F -q "$text" "$REPO_ROOT/$file"; then pass "$label" else fail "$label missing" "$file: $text" @@ -34,7 +36,7 @@ forbid_fixed() { text="$2" label="$3" - if grep -F -q "$text" "../$file"; then + if grep -F -q "$text" "$REPO_ROOT/$file"; then fail "$label still mentions $text" "$file" else pass "$label excludes $text" From a800b026b3e43911f72cf33da6cae8cd2b52957c Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 11:07:44 +0800 Subject: [PATCH 28/31] Fix tntctl ASAN link flags --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 04a54f6..66a6549 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ CC = gcc CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700 LDFLAGS = -pthread -lssh +CTL_LDFLAGS = INCLUDES = -Iinclude DEPFLAGS = -MMD -MP @@ -43,7 +44,7 @@ $(TARGET): $(OBJECTS) @echo "Build complete: $(TARGET)" $(CTL_TARGET): $(CTL_OBJECTS) - $(CC) $(CTL_OBJECTS) -o $@ + $(CC) $(CTL_OBJECTS) -o $@ $(CTL_LDFLAGS) @echo "Build complete: $(CTL_TARGET)" $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) @@ -99,6 +100,7 @@ debian-source-package: asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer asan: LDFLAGS += -fsanitize=address +asan: CTL_LDFLAGS += -fsanitize=address asan: clean $(TARGETS) @echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt" From fe7419709ea8aa61117338bd394d1d62031c4447 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 11:19:25 +0800 Subject: [PATCH 29/31] Polish interactive help lifecycle --- README.md | 2 +- src/commands.c | 4 ++++ src/i18n_text.c | 12 ++++++------ src/input.c | 11 ++++++++++- src/tui.c | 6 ++++-- tests/test_exec_mode.sh | 2 +- tests/test_interactive_input.sh | 28 ++++++++++++++++------------ tests/test_user_lifecycle.sh | 8 ++++---- tnt.1 | 4 +++- 9 files changed, 49 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d2018c9..a4ada81 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Ctrl+C - Exit chat :w - Short alias for :msg :inbox - Show private messages :last [N] - Show last N messages from history (max 50, default 10) -:search - Search full message history (case-insensitive) +:search - Search message history (shows last 15 matches) :mute-joins - Toggle join/leave system notifications :lang - Switch UI language for this session :help - Show concise manual diff --git a/src/commands.c b/src/commands.c index 318c720..9e53de2 100644 --- a/src/commands.c +++ b/src/commands.c @@ -118,6 +118,10 @@ void commands_dispatch(client_t *client) { end--; } } + if (cmd[0] == ':') { + cmd++; + while (*cmd == ' ') cmd++; + } /* Save to command history */ if (cmd[0] != '\0') { diff --git a/src/i18n_text.c b/src/i18n_text.c index 5fe3f60..5f009e4 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -26,12 +26,12 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "TNT %s - SSH 匿名聊天室\r\n\r\n" ), [I18N_INSERT_HINT_WIDE] = I18N_STRING( - "Enter send · Esc browse · :help", - "Enter 发送 · Esc 浏览 · :help" + "Enter send · Esc NORMAL", + "Enter 发送 · Esc NORMAL" ), [I18N_INSERT_HINT_NARROW] = I18N_STRING( - "Enter · Esc · :help", - "Enter · Esc · :help" + "Enter · Esc", + "Enter · Esc" ), [I18N_NORMAL_LATEST] = I18N_STRING( "G latest", @@ -142,8 +142,8 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "--- 最近 %d 条消息 ---\n" ), [I18N_SEARCH_HEADER_FORMAT] = I18N_STRING( - "--- Search: \"%s\" (%d match(es)) ---\n", - "--- 搜索: \"%s\" (%d 条匹配) ---\n" + "--- Search: \"%s\" (showing last %d match(es)) ---\n", + "--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n" ), [I18N_MUTE_JOINS_FORMAT] = I18N_STRING( "Join/leave notifications: %s\n", diff --git a/src/input.c b/src/input.c index 01eadc9..a29c902 100644 --- a/src/input.c +++ b/src/input.c @@ -235,9 +235,13 @@ static void dismiss_command_output(client_t *client) { client->command_output_scroll = 0; client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->show_motd = false; - client->mode = MODE_NORMAL; if (was_motd) { + client->mode = MODE_INSERT; + client->follow_tail = true; + client->unread_mentions = 0; normal_scroll_to_latest(client); + } else { + client->mode = MODE_NORMAL; } tui_render_screen(client); } @@ -352,6 +356,11 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { /* Handle Ctrl+C (Exit or switch to NORMAL) */ if (key == 3) { client_mode_t previous_mode = client->mode; + if (client->show_help) { + client->show_help = false; + tui_render_screen(client); + return true; + } if (client->command_output[0] != '\0') { dismiss_command_output(client); return true; diff --git a/src/tui.c b/src/tui.c index be2f185..f671b70 100644 --- a/src/tui.c +++ b/src/tui.c @@ -373,7 +373,9 @@ void tui_render_screen(client_t *client) { chips[chip_count].value_color = mode_color; chip_count++; - const char *hint = i18n_text(client->ui_lang, I18N_TITLE_HELP_HINT); + const char *hint = client->mode == MODE_NORMAL + ? i18n_text(client->ui_lang, I18N_TITLE_HELP_HINT) + : ""; int hint_width = utf8_string_width(hint); const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED); int mute_width = client->mute_joins ? utf8_string_width(mute_label) + 2 : 0; @@ -401,7 +403,7 @@ void tui_render_screen(client_t *client) { /* Decide what fits. Reserve at least 1 col of gap between left and * right halves so they never visually touch. */ - int show_hint = 1; + int show_hint = hint[0] != '\0'; int show_mute = client->mute_joins ? 1 : 0; int show_unread = unread_count > 0 ? 1 : 0; int show_whisper = whisper_count > 0 ? 1 : 0; diff --git a/tests/test_exec_mode.sh b/tests/test_exec_mode.sh index fe92c34..88ad874 100755 --- a/tests/test_exec_mode.sh +++ b/tests/test_exec_mode.sh @@ -372,7 +372,7 @@ set timeout 10 spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT sender@localhost expect "请输入用户名" send "sender\r" -expect ":help" +expect "Esc NORMAL" send "\033" expect "NORMAL" send ":" diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index da7b6f8..f02e107 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -83,7 +83,7 @@ set timeout 10 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "wrong\025editeduser\r" -expect ":help" +expect "Esc NORMAL" send -- "\003" sleep 0.2 send -- "\003" @@ -109,7 +109,7 @@ set timeout 10 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "tester\r" -expect ":help" +expect "Esc NORMAL" send -- "\033\[200~" send -- "line1\nline2\nline3" send -- "\033\[201~" @@ -184,12 +184,12 @@ set timeout 10 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "helper\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- ":" expect ":" -send -- "help\r" +send -- ":help\r" expect "TNT\\(1\\) 帮助" expect "Tab 补全 @mention" expect "q:关闭" @@ -199,6 +199,10 @@ send -- "?" expect "TNT 按键参考" expect "Tab - 补全 @mention" expect "l:语言" +send -- "\003" +expect "NORMAL" +send -- "?" +expect "TNT 按键参考" send -- "l" expect "TNT KEY REFERENCE" expect "Complete @mention" @@ -235,7 +239,7 @@ stty rows 8 columns 80 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "helppager\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- "?" @@ -273,7 +277,7 @@ set timeout 10 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "mistype\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- ":" @@ -305,7 +309,7 @@ set timeout 10 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "localized\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- ":" @@ -355,7 +359,7 @@ set timeout 10 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "usageuser\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- ":" @@ -448,7 +452,7 @@ stty rows 8 columns 80 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "pageruser\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- ":" @@ -492,7 +496,7 @@ stty rows 10 columns 40 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "wrapcmd\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- ":" @@ -522,7 +526,7 @@ set timeout 10 spawn ssh $SSH_OPTS anonymous@127.0.0.1 sleep 1 send -- "systemuser\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- ":" @@ -569,7 +573,7 @@ expect "公告" expect "维护窗口" expect "按任意键继续" send -- "x" -expect "NORMAL" +expect "INSERT" sleep 0.2 send -- "\003" sleep 0.2 diff --git a/tests/test_user_lifecycle.sh b/tests/test_user_lifecycle.sh index 432942a..1f76400 100755 --- a/tests/test_user_lifecycle.sh +++ b/tests/test_user_lifecycle.sh @@ -79,7 +79,7 @@ set timeout 30 spawn ssh $SSH_OPTS bob@127.0.0.1 sleep 1 send -- "bob\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- ":" @@ -143,7 +143,7 @@ set timeout 30 spawn ssh $SSH_OPTS alice@127.0.0.1 sleep 1 send -- "alice\r" -expect ":help" +expect "Esc NORMAL" send -- "\033" expect "NORMAL" send -- "?" @@ -160,7 +160,7 @@ expect "q:关闭" send -- "q" expect "NORMAL" send -- "i" -expect ":help" +expect "Esc NORMAL" send -- "hello lifecycle alpha\r" sleep 1 send -- "\033" @@ -215,7 +215,7 @@ expect "q:关闭" send -- "q" expect "NORMAL" send -- "i" -expect ":help" +expect "Esc NORMAL" send -- "/me ships lifecycle\r" sleep 1 send -- "\003" diff --git a/tnt.1 b/tnt.1 index 62f740e..86a134b 100644 --- a/tnt.1 +++ b/tnt.1 @@ -184,6 +184,8 @@ ESC Switch to NORMAL Ctrl+W Delete last word Ctrl+U Clear input line Ctrl+C Switch to NORMAL +Up/Down Browse sent message history +Tab Complete @mention Paste Keep multi-line paste in the input buffer /me \fIaction\fR Send action message (e.g. /me waves) @\fIusername\fR Mention user (bell notification + highlight) @@ -220,7 +222,7 @@ l l. :w \fIuser text\fR Short alias for :msg :inbox Show private messages :last [\fIN\fR] Show last N messages from history (1\-50, default 10) -:search \fIkeyword\fR Case\-insensitive search across full message history +:search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches :mute\-joins Toggle join/leave system notifications on/off :lang Show current UI language :lang \fIen|zh\fR Switch UI language for this session From 0da5f51e2ee136917021a110d112b1adaeb0a838 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 11:29:25 +0800 Subject: [PATCH 30/31] Split release and package publish gates --- .github/workflows/release.yml | 44 ++++++++++++++++---- Makefile | 5 ++- README.md | 5 ++- docs/CHANGELOG.md | 15 +++++++ docs/CICD.md | 21 ++++++---- docs/DEPLOYMENT.md | 6 +-- docs/ROADMAP.md | 8 ++-- install.sh | 29 +++++++++++++ packaging/README.md | 7 ++-- packaging/arch/PKGBUILD | 2 +- packaging/arch/README.md | 9 ++-- packaging/debian/debian/changelog | 2 +- packaging/debian/debian/control | 2 +- packaging/homebrew/README.md | 10 ++--- scripts/check_release_ref.sh | 31 ++++++++++++++ scripts/package_publish_check.sh | 68 +++++++++++++++++++++++++++++++ scripts/release_check.sh | 19 +++++---- 17 files changed, 232 insertions(+), 51 deletions(-) create mode 100755 scripts/check_release_ref.sh create mode 100755 scripts/package_publish_check.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c76ddec..b467b9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Verify release tag matches source version + run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}" + - name: Install dependencies (Ubuntu) if: runner.os == 'Linux' run: | @@ -97,6 +100,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Verify release tag matches source version + run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}" + - name: Download all artifacts uses: actions/download-artifact@v4 with: @@ -122,12 +128,24 @@ jobs: body: | ## Installation + Install the libssh runtime before running TNT: + ```bash + # Ubuntu/Debian + sudo apt install libssh-4 + + # Arch + sudo pacman -S libssh + + # macOS + brew install libssh + ``` + Download the binary for your platform: **Linux AMD64:** ```bash - wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64 - wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-amd64 + curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64 + curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-amd64 chmod +x tnt-linux-amd64 chmod +x tntctl-linux-amd64 sudo mv tnt-linux-amd64 /usr/local/bin/tnt @@ -136,8 +154,8 @@ jobs: **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 + curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64 + curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-arm64 chmod +x tnt-linux-arm64 chmod +x tntctl-linux-arm64 sudo mv tnt-linux-arm64 /usr/local/bin/tnt @@ -146,8 +164,8 @@ jobs: **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 + curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64 + curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-amd64 chmod +x tnt-darwin-amd64 chmod +x tntctl-darwin-amd64 sudo mv tnt-darwin-amd64 /usr/local/bin/tnt @@ -156,8 +174,8 @@ jobs: **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 + curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64 + curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-arm64 chmod +x tnt-darwin-arm64 chmod +x tntctl-darwin-arm64 sudo mv tnt-darwin-arm64 /usr/local/bin/tnt @@ -166,7 +184,15 @@ jobs: **Verify checksums:** ```bash - sha256sum -c checksums.txt + curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.txt + + # Linux + sha256sum -c checksums.txt --ignore-missing + + # macOS + for f in tnt-* tntctl-*; do + grep " $f$" checksums.txt | shasum -a 256 -c - + done ``` ## What's Changed diff --git a/Makefile b/Makefile index 66a6549..2f4f0ea 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ MANDIR ?= $(PREFIX)/share/man SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) -.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info +.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict package-publish-check debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info all: $(TARGETS) @@ -95,6 +95,9 @@ release-check: release-check-strict: ./scripts/release_check.sh --strict +package-publish-check: + ./scripts/package_publish_check.sh + debian-source-package: ./scripts/package_debian_source.sh $${OUT_DIR:-dist/debian-source} diff --git a/README.md b/README.md index a4ada81..7b75563 100644 --- a/README.md +++ b/README.md @@ -401,10 +401,11 @@ Longer local preflight can opt into runtime soak and slow-client coverage: RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check ``` -Before publishing package recipes, replace placeholder checksums and run: +Before publishing package recipes, download the final GitHub source archive, +replace placeholder checksums, and run: ```sh -make release-check-strict +SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check ``` ## Files diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 261edda..7761def 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased ### Added +- Added a release tag/version guard used by the GitHub release workflow, so a + `vX.Y.Z` tag must match `TNT_VERSION` before release assets are built. +- Added `make package-publish-check` for verifying Arch/Homebrew source + checksums against the final GitHub source archive after a tag exists. - Added a dedicated `tntctl_text` module with unit coverage for local `tntctl` help and validation diagnostics. - Documented the stable SSH exec interface contract, including exit statuses @@ -37,6 +41,17 @@ and server survival stay responsive. ### Changed +- INSERT-mode chrome now only advertises message sending and `Esc` to NORMAL; + `? keys` appears only in NORMAL mode, matching where help keys work. +- Dismissing MOTD now returns first-time users to INSERT mode, and `Ctrl+C` + closes the full key reference before it disconnects from NORMAL mode. +- COMMAND mode now accepts an optional leading `:` in typed commands, matching + the way commands are written in the manual. +- `:search` output and docs now state that the command shows the last 15 + matches, avoiding the impression that the pager is a complete result set. +- Release checks now separate tag/source-archive readiness from package-manager + checksum publishing, avoiding self-referential checksum requirements before + the final GitHub source archive exists. - `tntctl --help` now gets its exec command list from `exec_catalog`, reducing duplicate command metadata between the local wrapper and SSH exec mode. - Updated `tnt(1)` to document the current TUI search and pager keys, and diff --git a/docs/CICD.md b/docs/CICD.md index fa62eab..4808ab2 100644 --- a/docs/CICD.md +++ b/docs/CICD.md @@ -35,8 +35,7 @@ Release policy: - packaging/arch/PKGBUILD - packaging/homebrew/tnt-chat.rb - packaging/debian/debian/changelog - - package checksums and maintainer metadata, when preparing public package - recipes + - maintainer metadata, when preparing public package recipes 2. Run the local preflight: make release-check @@ -46,7 +45,7 @@ Release policy: 3. Commit the release changes and create a local tag. Do not push the tag until strict checks pass: - git tag v1.0.1 + git tag vX.Y.Z 4. Run strict release checks: make release-check-strict @@ -56,7 +55,7 @@ Release policy: untracked and would be missing from GitHub's source archive. 5. Push the tag: - git push origin v1.0.1 + git push origin vX.Y.Z 6. GitHub Actions automatically: - Builds `tnt` and `tntctl` binaries (Linux/macOS, AMD64/ARM64) @@ -77,7 +76,9 @@ 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`. + - Verify downloaded assets against `checksums.txt` (`sha256sum -c + checksums.txt --ignore-missing` on Linux, or `shasum -a 256 -c` for each + downloaded asset on macOS). - Run downloaded `tnt --version` and `tntctl --version`. - Start a temporary server and check: ssh -p 2222 server health @@ -87,8 +88,10 @@ Before publishing a draft release: 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. + - Confirm `make release-check-strict` passed before pushing the tag. + - For package-manager recipes, download the final GitHub source archive, + replace Arch/Homebrew source checksums, then run: + SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check ROLLBACK @@ -158,8 +161,8 @@ make && make asan && make release-check ./tnt # Create release -git tag v1.0.1 -git push origin v1.0.1 +git tag vX.Y.Z +git push origin vX.Y.Z # Wait 5 minutes for builds # Deploy to production manually after validation diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7fa5bb6..8a61866 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -9,7 +9,7 @@ curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh Specific version: ```bash -VERSION=v1.0.1 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh +VERSION=vX.Y.Z curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh ``` ## Manual Install @@ -18,12 +18,12 @@ Download binary for your platform from [releases](https://github.com/m1ngsama/TN ```bash # Linux AMD64 -wget https://github.com/m1ngsama/TNT/releases/latest/download/tnt-linux-amd64 +curl -LO https://github.com/m1ngsama/TNT/releases/latest/download/tnt-linux-amd64 chmod +x tnt-linux-amd64 sudo mv tnt-linux-amd64 /usr/local/bin/tnt # macOS ARM64 (Apple Silicon) -wget https://github.com/m1ngsama/TNT/releases/latest/download/tnt-darwin-arm64 +curl -LO https://github.com/m1ngsama/TNT/releases/latest/download/tnt-darwin-arm64 chmod +x tnt-darwin-arm64 sudo mv tnt-darwin-arm64 /usr/local/bin/tnt ``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1c1047b..5aa030c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -113,9 +113,9 @@ Goal: make regressions harder to introduce. These are the next changes that should happen before new feature work expands the surface area. -1. Replace remaining release placeholders with real maintainer metadata and - source-archive checksums when cutting a public package release. -2. Create or move the `v1.0.1` tag only when the release commit is final, then - run `make release-check-strict`. +1. Replace remaining source-archive checksum placeholders only after the final + GitHub source archive exists, then run `make package-publish-check`. +2. Create or move the `vX.Y.Z` tag only when the release commit is final, then + run `make release-check-strict` before pushing it. 3. Decide whether admin-only moderation controls belong in 1.0.x or should wait for a later minor release. diff --git a/install.sh b/install.sh index dacf318..a8bd817 100755 --- a/install.sh +++ b/install.sh @@ -27,6 +27,34 @@ sha256_of() { fi } +warn_missing_libssh() { + case "$OS" in + linux) + if command -v ldconfig >/dev/null 2>&1 && + ldconfig -p 2>/dev/null | grep -q 'libssh\.so'; then + return + fi + for path in /usr/lib/libssh.so* /usr/lib64/libssh.so* \ + /lib/libssh.so* /lib64/libssh.so*; do + [ -e "$path" ] && return + done + echo "WARNING: TNT requires the libssh runtime library." + echo "Install it first, for example:" + echo " Ubuntu/Debian: sudo apt install libssh-4" + echo " Arch: sudo pacman -S libssh" + ;; + darwin) + if [ -e /opt/homebrew/opt/libssh/lib/libssh.dylib ] || + [ -e /usr/local/opt/libssh/lib/libssh.dylib ]; then + return + fi + echo "WARNING: TNT requires the libssh runtime library." + echo "Install it first:" + echo " brew install libssh" + ;; + esac +} + need_cmd curl need_cmd awk @@ -53,6 +81,7 @@ echo "OS: $OS" echo "Arch: $ARCH" echo "Version: $VERSION" echo "" +warn_missing_libssh # Get latest version if not specified if [ "$VERSION" = "latest" ]; then diff --git a/packaging/README.md b/packaging/README.md index 45eeaf3..b82fd4a 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -17,7 +17,7 @@ Package installs include both `tnt` and `tntctl`. `tnt` is the server process; 1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match. Also update package versions in Arch, Homebrew, and Debian drafts. -2. Create a GitHub release tag such as `v1.0.1`. +2. Create a GitHub release tag such as `vX.Y.Z`. 3. Build and upload release tarballs or rely on GitHub source archives. 4. Replace placeholder checksums in package drafts. 5. Verify package contents in an isolated directory: @@ -35,10 +35,11 @@ Package installs include both `tnt` and `tntctl`. `tnt` is the server process; Use `scripts/package_debian_source.sh --build` on a Debian/Ubuntu system with `dpkg-buildpackage` installed to build the unsigned source package. -7. Before submitting package recipes, replace checksum placeholders and run: +7. Before submitting package recipes, download the final GitHub source archive, + replace checksum placeholders, and run: ```sh - make release-check-strict + SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check ``` 8. Submit packages manually: diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index f3bf187..868a1cc 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,4 +1,4 @@ -# Maintainer: M1ng +# Maintainer: M1ng pkgname=tnt-chat pkgver=1.0.1 diff --git a/packaging/arch/README.md b/packaging/arch/README.md index d6d40e1..334b165 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -26,11 +26,12 @@ After editing `PKGBUILD`, regenerate `.SRCINFO`: makepkg --printsrcinfo > .SRCINFO ``` -Before AUR submission, replace `sha256sums=('SKIP')` with the real release -archive checksum, then run the project-level strict check: +Before AUR submission, replace `sha256sums=('SKIP')` with the real GitHub +source archive checksum, regenerate `.SRCINFO`, then run the package publish +check: ```sh -make release-check-strict +SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check ``` ## Manual AUR submission @@ -40,7 +41,7 @@ git clone ssh://aur@aur.archlinux.org/tnt-chat.git aur-tnt-chat cp PKGBUILD .SRCINFO aur-tnt-chat/ cd aur-tnt-chat git add PKGBUILD .SRCINFO -git commit -m "Update to 1.0.1" +git commit -m "Update to X.Y.Z" git push ``` diff --git a/packaging/debian/debian/changelog b/packaging/debian/debian/changelog index 578b9e5..b3b8299 100644 --- a/packaging/debian/debian/changelog +++ b/packaging/debian/debian/changelog @@ -2,4 +2,4 @@ tnt-chat (1.0.1-1) unstable; urgency=medium * Initial package draft. - -- M1ng Thu, 21 May 2026 00:00:00 +0800 + -- M1ng Thu, 21 May 2026 00:00:00 +0800 diff --git a/packaging/debian/debian/control b/packaging/debian/debian/control index 317c4ea..2417d3d 100644 --- a/packaging/debian/debian/control +++ b/packaging/debian/debian/control @@ -1,7 +1,7 @@ Source: tnt-chat Section: net Priority: optional -Maintainer: M1ng +Maintainer: M1ng Build-Depends: debhelper-compat (= 13), libssh-dev, diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md index fbc6cf9..4c1fcd2 100644 --- a/packaging/homebrew/README.md +++ b/packaging/homebrew/README.md @@ -30,20 +30,20 @@ ruby -c packaging/homebrew/tnt-chat.rb ## Updating the formula -1. Publish a GitHub release tag such as `v1.0.1`. +1. Publish a GitHub release tag such as `vX.Y.Z`. 2. Download or hash the release source archive: ```sh - curl -L -o tnt-chat-1.0.1.tar.gz \ - https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz - shasum -a 256 tnt-chat-1.0.1.tar.gz + curl -L -o dist/tnt-chat-vX.Y.Z.tar.gz \ + https://github.com/m1ngsama/TNT/archive/refs/tags/vX.Y.Z.tar.gz + shasum -a 256 dist/tnt-chat-vX.Y.Z.tar.gz ``` 3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`. 4. Run: ```sh - make release-check-strict + SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check ``` 5. Copy the formula into the tap repository and open a normal review PR. diff --git a/scripts/check_release_ref.sh b/scripts/check_release_ref.sh new file mode 100755 index 0000000..9a91c7b --- /dev/null +++ b/scripts/check_release_ref.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Verify that a release tag matches TNT_VERSION. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +fail() { + echo "release-ref-check: $*" >&2 + exit 1 +} + +ref=${1:-${GITHUB_REF_NAME:-}} +[ -n "$ref" ] || fail "missing release ref; pass vX.Y.Z or set GITHUB_REF_NAME" + +case "$ref" in + refs/tags/*) tag=${ref#refs/tags/} ;; + *) tag=$ref ;; +esac + +printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || + fail "release ref must be vMAJOR.MINOR.PATCH, got $tag" + +version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h) +[ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h" + +[ "$tag" = "v$version" ] || + fail "release tag $tag does not match TNT_VERSION $version" + +echo "release ref matches TNT_VERSION: $tag" diff --git a/scripts/package_publish_check.sh b/scripts/package_publish_check.sh new file mode 100755 index 0000000..921afd2 --- /dev/null +++ b/scripts/package_publish_check.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# Verify package-manager recipes against a final release source archive. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +fail() { + echo "package-publish-check: $*" >&2 + exit 1 +} + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + fail "sha256sum or shasum is required" + fi +} + +version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h) +[ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h" + +source_tarball=${SOURCE_TARBALL:-${RELEASE_SOURCE_TARBALL:-}} +[ -n "$source_tarball" ] || + fail "set SOURCE_TARBALL to the final GitHub source archive" +[ -f "$source_tarball" ] || + fail "SOURCE_TARBALL does not exist: $source_tarball" + +! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null || + fail "replace maintainer email placeholders before package publishing" + +arch_sha=$(sed -n "s/^[[:space:]]*sha256sums=('\([^']*\)'.*/\1/p" \ + packaging/arch/PKGBUILD | head -n 1) +srcinfo_sha=$(sed -n 's/^[[:space:]]*sha256sums = \([^[:space:]]*\).*/\1/p' \ + packaging/arch/.SRCINFO | head -n 1) +brew_sha=$(sed -n 's/^[[:space:]]*sha256 "\([^"]*\)".*/\1/p' \ + packaging/homebrew/tnt-chat.rb | head -n 1) + +[ -n "$arch_sha" ] || fail "could not read PKGBUILD source checksum" +[ -n "$srcinfo_sha" ] || fail "could not read .SRCINFO source checksum" +[ -n "$brew_sha" ] || fail "could not read Homebrew source checksum" +[ "$arch_sha" != "SKIP" ] || fail "replace PKGBUILD sha256sums before publishing" +[ "$srcinfo_sha" != "SKIP" ] || fail "replace .SRCINFO sha256sums before publishing" +[ "$brew_sha" != "REPLACE_WITH_RELEASE_TARBALL_SHA256" ] || + fail "replace Homebrew sha256 before publishing" + +expected_sha=$(sha256_of "$source_tarball") +[ "$arch_sha" = "$expected_sha" ] || + fail "PKGBUILD source checksum does not match SOURCE_TARBALL" +[ "$srcinfo_sha" = "$expected_sha" ] || + fail ".SRCINFO source checksum does not match SOURCE_TARBALL" +[ "$brew_sha" = "$expected_sha" ] || + fail "Homebrew source checksum does not match SOURCE_TARBALL" + +grep -q "^pkgver=$version$" packaging/arch/PKGBUILD || + fail "PKGBUILD pkgver does not match $version" +grep -q "pkgver = $version" packaging/arch/.SRCINFO || + fail ".SRCINFO pkgver does not match $version" +grep -q "v${version}.tar.gz" packaging/homebrew/tnt-chat.rb || + fail "Homebrew URL does not match v$version" +grep -q "^tnt-chat (${version}-1)" packaging/debian/debian/changelog || + fail "Debian changelog version does not match $version" + +echo "package recipes match SOURCE_TARBALL for $version: $expected_sha" diff --git a/scripts/release_check.sh b/scripts/release_check.sh index a06b309..2c52e64 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -26,8 +26,9 @@ Environment: PORT=12720 base port for integration tests 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. +matching changelog release section, non-placeholder maintainer metadata, and a +build from the tagged source archive. Run `make package-publish-check` after +the final GitHub source archive exists to verify package checksums. USAGE } @@ -190,6 +191,14 @@ grep -q '^invalid_records 1$' "$recover_report" || step "checking installer syntax" sh -n install.sh +sh -n scripts/check_release_ref.sh +sh -n scripts/package_publish_check.sh +scripts/check_release_ref.sh "v$version" +bad_ref=v0.0.0 +[ "$version" != "0.0.0" ] || bad_ref=v9.9.9 +if scripts/check_release_ref.sh "$bad_ref" >/dev/null 2>&1; then + fail "release ref check accepted a mismatched tag" +fi step "checking Debian packaging metadata" [ -x packaging/debian/debian/rules ] || @@ -248,12 +257,6 @@ if [ "$STRICT" -eq 1 ]; then 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" ! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null || fail "replace maintainer email placeholders before strict release" From d4b260c160311f7b6e892ab33db7689daa390df4 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 11:42:31 +0800 Subject: [PATCH 31/31] Centralize runtime config defaults --- Makefile | 2 +- docs/CHANGELOG.md | 2 + include/common.h | 13 +++-- include/config_defaults.h | 47 ++++++++++++++++++ src/chat_room.c | 4 +- src/cli_text.c | 18 ++++--- src/config_defaults.c | 80 +++++++++++++++++++++++++++++++ src/input.c | 5 +- src/main.c | 70 ++++++++------------------- src/ratelimit.c | 21 ++++---- src/ssh_server.c | 3 +- src/tntctl.c | 3 +- src/tntctl_text.c | 5 +- tests/unit/Makefile | 13 +++-- tests/unit/test_config_defaults.c | 66 +++++++++++++++++++++++++ 15 files changed, 271 insertions(+), 81 deletions(-) create mode 100644 include/config_defaults.h create mode 100644 src/config_defaults.c create mode 100644 tests/unit/test_config_defaults.c diff --git a/Makefile b/Makefile index 2f4f0ea..459e158 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d) TARGET = tnt CTL_TARGET = tntctl -CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/tntctl_text.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/i18n.o +CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/tntctl_text.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/config_defaults.o $(OBJ_DIR)/i18n.o TARGETS = $(TARGET) $(CTL_TARGET) PREFIX ?= /usr/local diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7761def..c338cc1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ `vX.Y.Z` tag must match `TNT_VERSION` before release assets are built. - Added `make package-publish-check` for verifying Arch/Homebrew source checksums against the final GitHub source archive after a tag exists. +- Added a `config_defaults` module and unit coverage for runtime default + values, env keys, and accepted numeric ranges. - Added a dedicated `tntctl_text` module with unit coverage for local `tntctl` help and validation diagnostics. - Documented the stable SSH exec interface contract, including exit statuses diff --git a/include/common.h b/include/common.h index 93b5b55..962ec2a 100644 --- a/include/common.h +++ b/include/common.h @@ -11,6 +11,8 @@ #include #include +#include "config_defaults.h" + /* Project Metadata */ #define TNT_VERSION "1.0.1" @@ -23,7 +25,6 @@ #define TNT_EXIT_CONFIG 78 /* Configuration constants */ -#define DEFAULT_PORT 2222 #define MAX_MESSAGES 100 #define MAX_USERNAME_LEN 64 #define MAX_MESSAGE_LEN 1024 @@ -31,13 +32,17 @@ #define MAX_COMMAND_OUTPUT_LEN 8192 #define CLIENT_OUTBOX_CAPACITY (128 * 1024) #define CLIENT_OUTBOX_FLUSH_BUDGET 32768 -#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" #define TNT_DEFAULT_STATE_DIR "." -#define DEFAULT_IDLE_TIMEOUT 1800 /* 30 minutes */ + +/* Backward-compatible names for older modules while config_defaults owns the + * actual runtime defaults and accepted ranges. */ +#define DEFAULT_PORT TNT_DEFAULT_PORT +#define DEFAULT_MAX_CLIENTS TNT_DEFAULT_MAX_CONNECTIONS +#define MAX_CONFIGURED_CLIENTS TNT_MAX_CONFIGURED_CLIENTS +#define DEFAULT_IDLE_TIMEOUT TNT_DEFAULT_IDLE_TIMEOUT /* ANSI color codes */ #define ANSI_RESET "\033[0m" diff --git a/include/config_defaults.h b/include/config_defaults.h new file mode 100644 index 0000000..72786dd --- /dev/null +++ b/include/config_defaults.h @@ -0,0 +1,47 @@ +#ifndef CONFIG_DEFAULTS_H +#define CONFIG_DEFAULTS_H + +#include + +#define TNT_STRINGIFY_VALUE(value) #value +#define TNT_STRINGIFY(value) TNT_STRINGIFY_VALUE(value) + +#define TNT_DEFAULT_PORT 2222 +#define TNT_DEFAULT_PORT_TEXT TNT_STRINGIFY(TNT_DEFAULT_PORT) +#define TNT_DEFAULT_MAX_CONNECTIONS 64 +#define TNT_DEFAULT_MAX_CONN_PER_IP 5 +#define TNT_DEFAULT_MAX_CONN_RATE_PER_IP 10 +#define TNT_DEFAULT_RATE_LIMIT_ENABLED 1 +#define TNT_DEFAULT_IDLE_TIMEOUT 1800 + +#define TNT_MIN_PORT 1 +#define TNT_MAX_PORT 65535 +#define TNT_MIN_CONFIGURED_CLIENTS 1 +#define TNT_MAX_CONFIGURED_CLIENTS 1024 +#define TNT_MIN_RATE_LIMIT_ENABLED 0 +#define TNT_MAX_RATE_LIMIT_ENABLED 1 +#define TNT_MIN_IDLE_TIMEOUT 0 +#define TNT_MAX_IDLE_TIMEOUT 86400 +#define TNT_MIN_SSH_LOG_LEVEL 0 +#define TNT_MAX_SSH_LOG_LEVEL 4 + +typedef struct { + const char *env_name; + int fallback; + int min_value; + int max_value; +} tnt_int_config_spec_t; + +extern const tnt_int_config_spec_t TNT_CONFIG_PORT; +extern const tnt_int_config_spec_t TNT_CONFIG_MAX_CONNECTIONS; +extern const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_PER_IP; +extern const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_RATE_PER_IP; +extern const tnt_int_config_spec_t TNT_CONFIG_RATE_LIMIT; +extern const tnt_int_config_spec_t TNT_CONFIG_IDLE_TIMEOUT; +extern const tnt_int_config_spec_t TNT_CONFIG_SSH_LOG_LEVEL; + +int tnt_config_env_int(const tnt_int_config_spec_t *spec); +bool tnt_config_parse_int(const char *value, const tnt_int_config_spec_t *spec, + int *out); + +#endif /* CONFIG_DEFAULTS_H */ diff --git a/src/chat_room.c b/src/chat_room.c index ccddeea..e903497 100644 --- a/src/chat_room.c +++ b/src/chat_room.c @@ -1,11 +1,11 @@ #include "chat_room.h" +#include "config_defaults.h" /* Global chat room instance */ chat_room_t *g_room = NULL; static int room_capacity_from_env(void) { - return env_int("TNT_MAX_CONNECTIONS", DEFAULT_MAX_CLIENTS, 1, - MAX_CONFIGURED_CLIENTS); + return tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS); } /* Initialize chat room */ diff --git a/src/cli_text.c b/src/cli_text.c index 87f1e3a..c04c60f 100644 --- a/src/cli_text.c +++ b/src/cli_text.c @@ -1,5 +1,6 @@ #include "cli_text.h" +#include "config_defaults.h" #include "i18n.h" void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, @@ -12,7 +13,7 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, " -d, --state-dir DIR Store host key and logs in DIR\n" " --bind ADDR Bind to ADDR (default: 0.0.0.0)\n" " --public-host HOST Show HOST in startup connection hints\n" - " --max-connections N Global connection limit (default: 64)\n" + " --max-connections N Global connection limit (default: %d)\n" " --max-conn-per-ip N Per-IP concurrent session limit\n" " --max-conn-rate-per-ip N Per-IP connection-rate limit\n" " --rate-limit 0|1 Disable/enable rate-based blocking\n" @@ -28,9 +29,9 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, " TNT_STATE_DIR State directory\n" " TNT_ACCESS_TOKEN Require this password for SSH auth\n" " TNT_LANG UI language: en or zh (default: locale)\n" - " TNT_MAX_CONNECTIONS Global connection limit (default: 64)\n" + " TNT_MAX_CONNECTIONS Global connection limit (default: %d)\n" " TNT_RATE_LIMIT Set to 0 to disable rate limiting\n" - " TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: 1800)\n", + " TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: %d)\n", "tnt %s - 匿名 SSH 聊天服务器\n\n" "用法: %s [options]\n\n" "选项:\n" @@ -38,7 +39,7 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, " -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n" " --bind ADDR 绑定到 ADDR (默认: 0.0.0.0)\n" " --public-host HOST 在启动提示中显示 HOST\n" - " --max-connections N 全局连接数限制 (默认: 64)\n" + " --max-connections N 全局连接数限制 (默认: %d)\n" " --max-conn-per-ip N 单 IP 并发会话限制\n" " --max-conn-rate-per-ip N 单 IP 连接速率限制\n" " --rate-limit 0|1 禁用/启用速率封禁\n" @@ -54,16 +55,19 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, " TNT_STATE_DIR 状态目录\n" " TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n" " TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n" - " TNT_MAX_CONNECTIONS 全局连接数限制 (默认: 64)\n" + " TNT_MAX_CONNECTIONS 全局连接数限制 (默认: %d)\n" " TNT_RATE_LIMIT 设为 0 可禁用速率限制\n" - " TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: 1800)\n" + " TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: %d)\n" ); const char *program = (program_name && program_name[0] != '\0') ? program_name : "tnt"; buffer_appendf(buffer, buf_size, pos, i18n_string(help_format, lang), - TNT_VERSION, program, DEFAULT_PORT); + TNT_VERSION, program, TNT_DEFAULT_PORT, + TNT_DEFAULT_MAX_CONNECTIONS, + TNT_DEFAULT_MAX_CONNECTIONS, + TNT_DEFAULT_IDLE_TIMEOUT); } const char *cli_text_invalid_port_format(ui_lang_t lang) { diff --git a/src/config_defaults.c b/src/config_defaults.c new file mode 100644 index 0000000..16b0432 --- /dev/null +++ b/src/config_defaults.c @@ -0,0 +1,80 @@ +#include "config_defaults.h" +#include "common.h" + +#include + +const tnt_int_config_spec_t TNT_CONFIG_PORT = { + "PORT", + TNT_DEFAULT_PORT, + TNT_MIN_PORT, + TNT_MAX_PORT, +}; + +const tnt_int_config_spec_t TNT_CONFIG_MAX_CONNECTIONS = { + "TNT_MAX_CONNECTIONS", + TNT_DEFAULT_MAX_CONNECTIONS, + TNT_MIN_CONFIGURED_CLIENTS, + TNT_MAX_CONFIGURED_CLIENTS, +}; + +const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_PER_IP = { + "TNT_MAX_CONN_PER_IP", + TNT_DEFAULT_MAX_CONN_PER_IP, + TNT_MIN_CONFIGURED_CLIENTS, + TNT_MAX_CONFIGURED_CLIENTS, +}; + +const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_RATE_PER_IP = { + "TNT_MAX_CONN_RATE_PER_IP", + TNT_DEFAULT_MAX_CONN_RATE_PER_IP, + TNT_MIN_CONFIGURED_CLIENTS, + TNT_MAX_CONFIGURED_CLIENTS, +}; + +const tnt_int_config_spec_t TNT_CONFIG_RATE_LIMIT = { + "TNT_RATE_LIMIT", + TNT_DEFAULT_RATE_LIMIT_ENABLED, + TNT_MIN_RATE_LIMIT_ENABLED, + TNT_MAX_RATE_LIMIT_ENABLED, +}; + +const tnt_int_config_spec_t TNT_CONFIG_IDLE_TIMEOUT = { + "TNT_IDLE_TIMEOUT", + TNT_DEFAULT_IDLE_TIMEOUT, + TNT_MIN_IDLE_TIMEOUT, + TNT_MAX_IDLE_TIMEOUT, +}; + +const tnt_int_config_spec_t TNT_CONFIG_SSH_LOG_LEVEL = { + "TNT_SSH_LOG_LEVEL", + 0, + TNT_MIN_SSH_LOG_LEVEL, + TNT_MAX_SSH_LOG_LEVEL, +}; + +int tnt_config_env_int(const tnt_int_config_spec_t *spec) { + if (!spec) { + return 0; + } + return env_int(spec->env_name, spec->fallback, spec->min_value, + spec->max_value); +} + +bool tnt_config_parse_int(const char *value, const tnt_int_config_spec_t *spec, + int *out) { + char *end = NULL; + long val; + + if (!value || value[0] == '\0' || !spec || !out) { + return false; + } + + val = strtol(value, &end, 10); + if (!end || *end != '\0' || val < spec->min_value || + val > spec->max_value) { + return false; + } + + *out = (int)val; + return true; +} diff --git a/src/input.c b/src/input.c index a29c902..49b8bb4 100644 --- a/src/input.c +++ b/src/input.c @@ -2,6 +2,7 @@ #include "chat_room.h" #include "client.h" #include "commands.h" +#include "config_defaults.h" #include "common.h" #include "exec.h" #include "history_view.h" @@ -20,11 +21,11 @@ #include #include -static int g_idle_timeout = DEFAULT_IDLE_TIMEOUT; +static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT; static ui_lang_t g_default_ui_lang = UI_LANG_EN; void input_init(void) { - g_idle_timeout = env_int("TNT_IDLE_TIMEOUT", DEFAULT_IDLE_TIMEOUT, 0, 86400); + g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT); g_default_ui_lang = i18n_default_ui_lang(); } diff --git a/src/main.c b/src/main.c index b1f8058..c7a9e19 100644 --- a/src/main.c +++ b/src/main.c @@ -1,5 +1,6 @@ #include "chat_room.h" #include "cli_text.h" +#include "config_defaults.h" #include "common.h" #include "i18n.h" #include "message.h" @@ -19,24 +20,6 @@ static void signal_handler(int sig) { _exit(0); } -static bool parse_int_arg(const char *value, int min_val, int max_val, - int *out) { - char *end = NULL; - long val; - - if (!value || value[0] == '\0' || !out) { - return false; - } - - val = strtol(value, &end, 10); - if (!end || *end != '\0' || val < min_val || val > max_val) { - return false; - } - - *out = (int)val; - return true; -} - static bool is_config_token(const char *value) { const unsigned char *p = (const unsigned char *)value; @@ -60,16 +43,16 @@ static int set_env_option(const char *name, const char *value) { return 0; } -static int set_numeric_env_option(const char *env_name, const char *opt_name, - const char *value, int min_val, - int max_val, ui_lang_t lang) { +static int set_numeric_env_option(const tnt_int_config_spec_t *spec, + const char *opt_name, const char *value, + ui_lang_t lang) { int parsed; - if (!parse_int_arg(value, min_val, max_val, &parsed)) { + if (!tnt_config_parse_int(value, spec, &parsed)) { fprintf(stderr, cli_text_invalid_value_format(lang), opt_name, value); return TNT_EXIT_USAGE; } - if (set_env_option(env_name, value) != 0) { + if (set_env_option(spec->env_name, value) != 0) { return TNT_EXIT_ERROR; } return TNT_EXIT_OK; @@ -86,21 +69,11 @@ static bool require_option_arg(int argc, char **argv, int index, } int main(int argc, char **argv) { - int port = DEFAULT_PORT; + int port = tnt_config_env_int(&TNT_CONFIG_PORT); ui_lang_t lang = i18n_default_ui_lang(); const char *log_check_path = NULL; const char *log_recover_path = NULL; - /* Environment provides defaults; command-line flags override it. */ - const char *port_env = getenv("PORT"); - if (port_env && port_env[0] != '\0') { - char *end; - long val = strtol(port_env, &end, 10); - if (*end == '\0' && val > 0 && val <= 65535) { - port = (int)val; - } - } - /* Parse command line arguments */ for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) { @@ -108,7 +81,7 @@ int main(int argc, char **argv) { if (!require_option_arg(argc, argv, i, lang)) { return TNT_EXIT_USAGE; } - if (!parse_int_arg(argv[i + 1], 1, 65535, &val)) { + if (!tnt_config_parse_int(argv[i + 1], &TNT_CONFIG_PORT, &val)) { fprintf(stderr, cli_text_invalid_port_format(lang), argv[i + 1]); return TNT_EXIT_USAGE; @@ -154,9 +127,8 @@ int main(int argc, char **argv) { if (!require_option_arg(argc, argv, i, lang)) { return TNT_EXIT_USAGE; } - int rc = set_numeric_env_option("TNT_MAX_CONNECTIONS", argv[i], - argv[i + 1], 1, - MAX_CONFIGURED_CLIENTS, lang); + int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONNECTIONS, + argv[i], argv[i + 1], lang); if (rc != TNT_EXIT_OK) { return rc; } @@ -165,9 +137,8 @@ int main(int argc, char **argv) { if (!require_option_arg(argc, argv, i, lang)) { return TNT_EXIT_USAGE; } - int rc = set_numeric_env_option("TNT_MAX_CONN_PER_IP", argv[i], - argv[i + 1], 1, - MAX_CONFIGURED_CLIENTS, lang); + int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_PER_IP, + argv[i], argv[i + 1], lang); if (rc != TNT_EXIT_OK) { return rc; } @@ -176,9 +147,8 @@ int main(int argc, char **argv) { if (!require_option_arg(argc, argv, i, lang)) { return TNT_EXIT_USAGE; } - int rc = set_numeric_env_option("TNT_MAX_CONN_RATE_PER_IP", - argv[i], argv[i + 1], 1, - MAX_CONFIGURED_CLIENTS, lang); + int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_RATE_PER_IP, + argv[i], argv[i + 1], lang); if (rc != TNT_EXIT_OK) { return rc; } @@ -187,8 +157,8 @@ int main(int argc, char **argv) { if (!require_option_arg(argc, argv, i, lang)) { return TNT_EXIT_USAGE; } - int rc = set_numeric_env_option("TNT_RATE_LIMIT", argv[i], - argv[i + 1], 0, 1, lang); + int rc = set_numeric_env_option(&TNT_CONFIG_RATE_LIMIT, argv[i], + argv[i + 1], lang); if (rc != TNT_EXIT_OK) { return rc; } @@ -197,8 +167,8 @@ int main(int argc, char **argv) { if (!require_option_arg(argc, argv, i, lang)) { return TNT_EXIT_USAGE; } - int rc = set_numeric_env_option("TNT_IDLE_TIMEOUT", argv[i], - argv[i + 1], 0, 86400, lang); + int rc = set_numeric_env_option(&TNT_CONFIG_IDLE_TIMEOUT, argv[i], + argv[i + 1], lang); if (rc != TNT_EXIT_OK) { return rc; } @@ -207,8 +177,8 @@ int main(int argc, char **argv) { if (!require_option_arg(argc, argv, i, lang)) { return TNT_EXIT_USAGE; } - int rc = set_numeric_env_option("TNT_SSH_LOG_LEVEL", argv[i], - argv[i + 1], 0, 4, lang); + int rc = set_numeric_env_option(&TNT_CONFIG_SSH_LOG_LEVEL, + argv[i], argv[i + 1], lang); if (rc != TNT_EXIT_OK) { return rc; } diff --git a/src/ratelimit.c b/src/ratelimit.c index c898bcb..098d77a 100644 --- a/src/ratelimit.c +++ b/src/ratelimit.c @@ -1,4 +1,5 @@ #include "ratelimit.h" +#include "config_defaults.h" #include "common.h" #include #include @@ -27,16 +28,20 @@ static pthread_mutex_t g_rate_limit_lock = PTHREAD_MUTEX_INITIALIZER; static int g_total_connections = 0; static pthread_mutex_t g_conn_count_lock = PTHREAD_MUTEX_INITIALIZER; -static int g_max_connections = 64; -static int g_max_conn_per_ip = 5; -static int g_max_conn_rate_per_ip = 10; -static int g_rate_limit_enabled = 1; +static int g_max_connections = TNT_DEFAULT_MAX_CONNECTIONS; +static int g_max_conn_per_ip = TNT_DEFAULT_MAX_CONN_PER_IP; +static int g_max_conn_rate_per_ip = TNT_DEFAULT_MAX_CONN_RATE_PER_IP; +static int g_rate_limit_enabled = TNT_DEFAULT_RATE_LIMIT_ENABLED; void ratelimit_init(void) { - g_max_connections = env_int("TNT_MAX_CONNECTIONS", 64, 1, 1024); - g_max_conn_per_ip = env_int("TNT_MAX_CONN_PER_IP", 5, 1, 1024); - g_max_conn_rate_per_ip = env_int("TNT_MAX_CONN_RATE_PER_IP", 10, 1, 1024); - g_rate_limit_enabled = env_int("TNT_RATE_LIMIT", 1, 0, 1); + g_max_connections = + tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS); + g_max_conn_per_ip = + tnt_config_env_int(&TNT_CONFIG_MAX_CONN_PER_IP); + g_max_conn_rate_per_ip = + tnt_config_env_int(&TNT_CONFIG_MAX_CONN_RATE_PER_IP); + g_rate_limit_enabled = + tnt_config_env_int(&TNT_CONFIG_RATE_LIMIT); } /* Caller MUST hold g_rate_limit_lock. */ diff --git a/src/ssh_server.c b/src/ssh_server.c index c64c263..d560b2e 100644 --- a/src/ssh_server.c +++ b/src/ssh_server.c @@ -1,6 +1,7 @@ #include "ssh_server.h" #include "bootstrap.h" #include "commands.h" +#include "config_defaults.h" #include "exec.h" #include "input.h" #include "ratelimit.h" @@ -23,7 +24,7 @@ /* Global SSH bind instance */ static ssh_bind g_sshbind = NULL; -static int g_listen_port = DEFAULT_PORT; +static int g_listen_port = TNT_DEFAULT_PORT; static time_t g_server_start_time = 0; diff --git a/src/tntctl.c b/src/tntctl.c index e9d9672..ea026a9 100644 --- a/src/tntctl.c +++ b/src/tntctl.c @@ -1,4 +1,5 @@ #include "common.h" +#include "config_defaults.h" #include "exec_catalog.h" #include "i18n.h" #include "tntctl_text.h" @@ -145,7 +146,7 @@ static int run_ssh(char **ssh_argv) { } int main(int argc, char **argv) { - const char *port = "2222"; + const char *port = TNT_DEFAULT_PORT_TEXT; const char *login = NULL; const char *host_key_checking = NULL; const char *known_hosts = NULL; diff --git a/src/tntctl_text.c b/src/tntctl_text.c index 3863026..ac2f49e 100644 --- a/src/tntctl_text.c +++ b/src/tntctl_text.c @@ -1,5 +1,6 @@ #include "tntctl_text.h" +#include "config_defaults.h" #include "exec_catalog.h" #include "i18n.h" @@ -61,7 +62,7 @@ void tntctl_text_append_usage(char *buffer, size_t buf_size, size_t *pos, "Usage: tntctl [options] host command [args...]\n" "\n" "Options:\n" - " -p, --port PORT SSH port (default: 2222)\n" + " -p, --port PORT SSH port (default: " TNT_DEFAULT_PORT_TEXT ")\n" " -l, --login USER SSH login name for exec identity\n" " --host-key-checking MODE\n" " OpenSSH host-key mode: yes, accept-new, no\n" @@ -74,7 +75,7 @@ void tntctl_text_append_usage(char *buffer, size_t buf_size, size_t *pos, "用法: tntctl [options] host command [args...]\n" "\n" "选项:\n" - " -p, --port PORT SSH 端口 (默认: 2222)\n" + " -p, --port PORT SSH 端口 (默认: " TNT_DEFAULT_PORT_TEXT ")\n" " -l, --login USER SSH 登录名,用作 exec 身份\n" " --host-key-checking MODE\n" " OpenSSH 主机密钥模式: yes, accept-new, no\n" diff --git a/tests/unit/Makefile b/tests/unit/Makefile index bf78445..1e2d9ec 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -14,6 +14,7 @@ UTF8_SRC = ../../src/utf8.c MESSAGE_SRC = ../../src/message.c MESSAGE_LOG_SRC = ../../src/message_log.c COMMON_SRC = ../../src/common.c +CONFIG_DEFAULTS_SRC = ../../src/config_defaults.c COMMAND_CATALOG_SRC = ../../src/command_catalog.c CLI_TEXT_SRC = ../../src/cli_text.c TNTCTL_TEXT_SRC = ../../src/tntctl_text.c @@ -27,7 +28,7 @@ HELP_TEXT_SRC = ../../src/help_text.c MANUAL_TEXT_SRC = ../../src/manual_text.c RATELIMIT_SRC = ../../src/ratelimit.c -TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit +TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_config_defaults .PHONY: all clean run @@ -39,7 +40,7 @@ test_utf8: test_utf8.c $(UTF8_SRC) test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) +test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CONFIG_DEFAULTS_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) test_history_view: test_history_view.c $(HISTORY_VIEW_SRC) @@ -69,7 +70,10 @@ test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC) test_tntctl_text: test_tntctl_text.c $(TNTCTL_TEXT_SRC) $(EXEC_CATALOG_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -test_ratelimit: test_ratelimit.c $(RATELIMIT_SRC) $(COMMON_SRC) +test_ratelimit: test_ratelimit.c $(RATELIMIT_SRC) $(COMMON_SRC) $(CONFIG_DEFAULTS_SRC) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +test_config_defaults: test_config_defaults.c $(CONFIG_DEFAULTS_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) run: all @@ -111,6 +115,9 @@ run: all @echo "" @echo "=== Running Rate Limit Tests ===" ./test_ratelimit + @echo "" + @echo "=== Running Config Defaults Tests ===" + ./test_config_defaults clean: rm -f $(TESTS) *.o test_messages.log diff --git a/tests/unit/test_config_defaults.c b/tests/unit/test_config_defaults.c new file mode 100644 index 0000000..b4a04ea --- /dev/null +++ b/tests/unit/test_config_defaults.c @@ -0,0 +1,66 @@ +#include "config_defaults.h" + +#include +#include +#include + +#define TEST(name) static void test_##name(void) +#define RUN_TEST(name) do { \ + printf("Running %s... ", #name); \ + test_##name(); \ + printf("ok\n"); \ +} while (0) + +TEST(specs_expose_runtime_defaults) { + assert(TNT_CONFIG_PORT.fallback == TNT_DEFAULT_PORT); + assert(TNT_CONFIG_MAX_CONNECTIONS.fallback == + TNT_DEFAULT_MAX_CONNECTIONS); + assert(TNT_CONFIG_MAX_CONN_PER_IP.fallback == + TNT_DEFAULT_MAX_CONN_PER_IP); + assert(TNT_CONFIG_MAX_CONN_RATE_PER_IP.fallback == + TNT_DEFAULT_MAX_CONN_RATE_PER_IP); + assert(TNT_CONFIG_RATE_LIMIT.fallback == + TNT_DEFAULT_RATE_LIMIT_ENABLED); + assert(TNT_CONFIG_IDLE_TIMEOUT.fallback == TNT_DEFAULT_IDLE_TIMEOUT); + assert(TNT_CONFIG_PORT.min_value == TNT_MIN_PORT); + assert(TNT_CONFIG_PORT.max_value == TNT_MAX_PORT); +} + +TEST(parse_uses_spec_ranges) { + int out = 0; + + assert(tnt_config_parse_int("2222", &TNT_CONFIG_PORT, &out)); + assert(out == 2222); + assert(!tnt_config_parse_int("0", &TNT_CONFIG_PORT, &out)); + assert(!tnt_config_parse_int("65536", &TNT_CONFIG_PORT, &out)); + assert(!tnt_config_parse_int("abc", &TNT_CONFIG_PORT, &out)); + assert(!tnt_config_parse_int("", &TNT_CONFIG_PORT, &out)); + + assert(tnt_config_parse_int("0", &TNT_CONFIG_IDLE_TIMEOUT, &out)); + assert(out == 0); + assert(!tnt_config_parse_int("86401", &TNT_CONFIG_IDLE_TIMEOUT, &out)); +} + +TEST(env_reader_uses_fallback_and_range) { + unsetenv(TNT_CONFIG_MAX_CONNECTIONS.env_name); + assert(tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS) == + TNT_DEFAULT_MAX_CONNECTIONS); + + setenv(TNT_CONFIG_MAX_CONNECTIONS.env_name, "128", 1); + assert(tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS) == 128); + + setenv(TNT_CONFIG_MAX_CONNECTIONS.env_name, "0", 1); + assert(tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS) == + TNT_DEFAULT_MAX_CONNECTIONS); + + unsetenv(TNT_CONFIG_MAX_CONNECTIONS.env_name); +} + +int main(void) { + printf("Running config defaults unit tests...\n\n"); + RUN_TEST(specs_expose_runtime_defaults); + RUN_TEST(parse_uses_spec_ranges); + RUN_TEST(env_reader_uses_fallback_and_range); + printf("\nAll 3 tests passed!\n"); + return 0; +}