A minimalist terminal chat server with Vim-style interface over SSH.
- Zero config - Download and run, auto-generates SSH keys
- SSH-based - Leverage mature SSH protocol for encryption and auth
- Vim-style UI - Modal editing (INSERT/NORMAL/COMMAND)
- UTF-8 native - Full Unicode support
- High performance - Pure C, multi-threaded, sub-100ms startup
- Secure - Rate limiting, auth failure protection, input validation
- Persistent - Auto-saves chat history
- Elegant - Flicker-free TUI rendering
One-liner:
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | shThe installer verifies downloaded release binaries against checksums.txt
before installing them. Older releases may provide only tnt; newer releases
also install tntctl.
From source:
git clone https://github.com/m1ngsama/TNT.git
cd TNT
make
sudo make installBinary releases: https://github.com/m1ngsama/TNT/releases
tnt # default port 2222
tnt -p 3333 # custom port
tnt -d /var/lib/tnt
PORT=3333 tnt # via env varssh -p 2222 localhostFor 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.
INSERT mode (default)
ESC - Enter NORMAL mode
Enter - Send message
Backspace - Delete character
Ctrl+W - Delete last word
Ctrl+U - Delete line
Ctrl+C - Enter NORMAL mode
Paste - Multi-line paste stays in the input buffer
The input line shows remaining bytes near the message limit. Extra input past the limit is ignored with a terminal bell.
NORMAL mode
Opens at latest messages
Stays pinned to latest until you scroll up
i/a/o - Return to INSERT mode
: - Enter COMMAND mode
j/k - Scroll down/up one line
Ctrl+D/U - Scroll half page down/up
Ctrl+F/B - Scroll full page down/up
PgDn/PgUp - Scroll full page down/up
End/Home - Jump to bottom/top
g/G - Jump to top/bottom
? - Show full key reference
Ctrl+C - Exit chat
COMMAND mode
:list, :users - Show online users
:nick <name> - Change nickname
:msg <user> <message> - Send private message
:w <user> <text> - Short alias for :msg
:reply <text> - Reply to latest private message
:r <text> - Short alias for :reply
:inbox - Show private messages
:inbox clear - Clear private messages for this session
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search message history (shows last 15 matches)
:mute-joins - Toggle join/leave system notifications
:lang <en|zh> - Switch UI language for this session
:help - Show concise manual
:clear - Clear command output
:q, :quit, :exit - Disconnect
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
shows incoming and sent private messages newest-first; press r to refresh it
manually, and it refreshes when a new private message arrives while the inbox
is open. :reply text and :r text send to the latest private-message peer.
Unread incoming private messages are marked with * until :inbox renders.
The inbox title shows a transient unread count when new private messages are
present.
:inbox clear removes private messages and the reply target for this session.
Private messages are per-session only and are not written to messages.log.
Special messages (INSERT mode)
/me <action> - Send action (e.g. /me waves)
@username - Mention user (bell + highlight)
Access control:
# Require password
TNT_ACCESS_TOKEN="secret" tnt
# Bind to localhost only
TNT_BIND_ADDR=127.0.0.1 tnt
# Bind to specific IP
TNT_BIND_ADDR=192.168.1.100 tnt
# Store host key and logs in an explicit state directory
TNT_STATE_DIR=/var/lib/tnt tnt
# Show the public SSH endpoint in startup logs
TNT_PUBLIC_HOST=chat.example.com tnt
# Choose interactive UI language (en or zh; defaults from locale)
TNT_LANG=zh tntThe same operational settings can be passed explicitly, which is often clearer in package scripts and one-off test deployments:
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/tntRate limiting:
# Max total connections (default 64)
TNT_MAX_CONNECTIONS=100 tnt
# Max concurrent sessions per IP (default 5)
TNT_MAX_CONN_PER_IP=10 tnt
# Max new connection attempts per IP in 60 seconds (default 10)
TNT_MAX_CONN_RATE_PER_IP=30 tnt
# Disable connection-rate and auth-failure blocking (testing only)
TNT_RATE_LIMIT=0 tnt
# Idle timeout in seconds (default 1800 = 30min, 0 to disable)
TNT_IDLE_TIMEOUT=3600 tntSSH logging:
# 0=none, 1=warning, 2=protocol, 3=packet, 4=functions (default 1)
TNT_SSH_LOG_LEVEL=3 tntProduction example:
TNT_ACCESS_TOKEN="strong-password-123" \
TNT_BIND_ADDR=0.0.0.0 \
TNT_MAX_CONNECTIONS=200 \
TNT_MAX_CONN_PER_IP=30 \
TNT_MAX_CONN_RATE_PER_IP=60 \
TNT_SSH_LOG_LEVEL=1 \
tnt -p 2222TNT also exposes a small non-interactive SSH surface for scripts:
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"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 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:
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"Persisted public history is stored as messages.log in the TNT state
directory. Private messages and local inbox state are intentionally excluded.
For manual maintenance, archive and compact it with:
scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000The 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:
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.
make # standard build
make debug # debug build (with symbols)
make asan # AddressSanitizer build
make release-check # local release/package preflight
make check # static analysis (cppcheck)
make clean # clean build artifactsmake test # run comprehensive test suite and fail on regressions
make test-advisory # run integration tests as advisory checks
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
cd tests
./test_basic.sh # basic functionality
./test_security_features.sh # security features
./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_slow_client.sh # slow-client backpressure
./test_user_lifecycle.sh # two-user TUI lifecycleTest coverage:
- Basic functionality: 3 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
- libssh (>= 0.9.0) - SSH protocol library
- pthread - POSIX threads
- gcc/clang - C11 compiler
Ubuntu/Debian:
sudo apt-get install libssh-devmacOS:
brew install libsshFedora/RHEL:
sudo dnf install libssh-develTNT/
├── src/ # source code
│ ├── main.c # entry point
│ ├── cli_text.c # startup CLI help and option text
│ ├── command_catalog.c # 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
│ ├── 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
│ ├── message.c # message persistence
│ ├── module_protocol.c # external module JSONL protocol helpers
│ ├── module_runtime.c # optional external module supervisor
│ ├── json_text.c # small JSON string helpers
│ ├── input_buffer.c # validated terminal input buffer helpers
│ ├── history_view.c # message viewport and scroll state
│ ├── help_text.c # full-screen key reference content
│ ├── manual.c # concise manual panel rendering
│ ├── manual_text.c # concise manual content
│ ├── i18n.c # UI language and locale selection
│ ├── i18n_text.c # shared UI text catalog
│ ├── ratelimit.c # connection limits and rate limiting
│ ├── tui.c # terminal UI rendering
│ ├── tui_status.c # status/input line rendering
│ └── utf8.c # UTF-8 character handling
├── include/ # header files
├── tests/ # test scripts
├── docs/ # documentation
├── packaging/ # package-manager drafts and release checklist
├── scripts/ # operational scripts
├── Makefile # build configuration
└── README.md # this file
sudo cp tnt.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable tnt
sudo systemctl start tnt
# Optional: override defaults without editing the unit
sudo tee /etc/default/tnt >/dev/null <<'EOF'
PORT=2222
TNT_BIND_ADDR=0.0.0.0
TNT_STATE_DIR=/var/lib/tnt
TNT_MAX_CONNECTIONS=200
TNT_MAX_CONN_PER_IP=30
TNT_MAX_CONN_RATE_PER_IP=60
TNT_RATE_LIMIT=1
TNT_SSH_LOG_LEVEL=0
TNT_PUBLIC_HOST=chat.example.com
EOFFROM alpine:latest
RUN apk add --no-cache libssh
COPY tnt /usr/local/bin/
EXPOSE 2222
CMD ["tnt"]See docs/DEPLOYMENT.md for details.
Package-manager drafts live in packaging/. Current targets are
Arch/AUR (tnt-chat), Homebrew tap formula, and Ubuntu PPA notes.
Before preparing a release locally:
make release-checkLonger local preflight can opt into runtime soak and slow-client coverage:
RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-checkBefore publishing package recipes, download the explicit release source archive, replace placeholder checksums, and run:
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-checkmessages.log - Chat history (RFC3339 format)
host_key - SSH host key (auto-generated, 4096-bit RSA)
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. Experimental community modules
should follow the external-process protocol in
docs/MODULE_PROTOCOL.md. Module-generated content
must always include a plain-text fallback so TNT can keep working on basic
terminal clients and preserve the stable messages.log v1 history contract.
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.
# Example (assuming default state dir)
cat > motd.txt <<'EOF'
Welcome to the chat server!
Be respectful. No spam.
EOFDelete motd.txt to disable the MOTD.
- Development Guide - Complete development manual
- Quick Setup - 5-minute deployment guide
- Roadmap - Long-term Unix/GNU direction and next stages
- Interface Contract - Scriptable commands, exit statuses, and JSON fields
- Module Protocol - External-process module contract
- Security Reference - Security config quick reference
- Contributing - How to contribute
- Changelog - Version history
- CI/CD - Continuous integration setup
- Quick Reference - Command cheat sheet
- Startup: < 100ms (even with 100k+ message history)
- Memory: ~2MB (idle)
- Concurrency: Supports 100+ concurrent connections
- Throughput: 1000+ messages/second
TNT has very little it can say to the SSH client before disconnecting, so any pre-auth rejection just looks like a generic close. Common causes, fastest to slowest fix:
| Likely cause | Why | Fix |
|---|---|---|
| Per-IP concurrent limit | TNT_MAX_CONN_PER_IP (default 5) |
Close other sessions, or raise the env var |
| Per-IP connection rate | More than TNT_MAX_CONN_RATE_PER_IP attempts in 60 s |
Wait 5 min (block window), or raise the limit |
| Auth-failure ban | 5 wrong passwords / failed kex in a row | Wait 5 min |
| Global cap | TNT_MAX_CONNECTIONS (default 64) is full |
Wait for someone to leave |
| Firewall | The host's ufw / iptables doesn't open 2222 | Open the port |
The server admin can confirm which by checking the systemd journal
(sudo journalctl -u tnt -n 50 --no-pager) — the rejection reason is
logged to stderr with the offending IP.
After TNT_IDLE_TIMEOUT seconds (default 1800 = 30 min) of no
keystrokes, TNT prints a localized idle-timeout notice and closes the
channel. Set the env var to 0 to disable.
- Single chat room (no multi-room support yet)
- TUI displays at most 100 messages at once; use
:last Nor:searchto access older history from disk - Ctrl+W only recognizes ASCII space as word boundary
Contributions welcome! See CONTRIBUTING.md
Process:
- Fork the repository
- Create feature branch (
git checkout -b feature/AmazingFeature) - Commit changes (
git commit -m 'Add some AmazingFeature') - Push to branch (
git push origin feature/AmazingFeature) - Open Pull Request
MIT License - see LICENSE
- libssh - SSH protocol implementation
- Linux kernel community - Code style and engineering practices
- Issues: https://github.com/m1ngsama/TNT/issues
- Pull Requests: https://github.com/m1ngsama/TNT/pulls
"Talk is cheap. Show me the code." - Linus Torvalds