Compare commits

...

5 commits

Author SHA1 Message Date
49674b75e8 docs: add project roadmap
Some checks failed
CI / build-and-test (macos-latest) (push) Has been cancelled
CI / build-and-test (ubuntu-latest) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
2026-03-10 19:45:51 +08:00
6dcb7cad2e
Merge pull request #14 from m1ngsama/refactor/stabilize-ssh-runtime
refactor: stabilize SSH runtime and add exec interface
2026-03-10 19:21:14 +08:00
301adbd0d4 docs: align limit semantics and exec support 2026-03-10 19:19:13 +08:00
cb106de31b fix: separate per-ip concurrency from connection rate 2026-03-10 19:08:28 +08:00
e473b26e0d refactor: stabilize SSH runtime and add exec interface 2026-03-10 18:52:20 +08:00
25 changed files with 1931 additions and 394 deletions

View file

@ -38,13 +38,14 @@ https://github.com/m1ngsama/TNT/releases
```sh
tnt # default port 2222
tnt -p 3333 # custom port
tnt -d /var/lib/tnt
PORT=3333 tnt # via env var
```
### Connecting
```sh
ssh -p 2222 localhost
ssh -p 2222 chat.m1ng.space
```
**Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
@ -92,6 +93,12 @@ 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.m1ng.space tnt
```
**Rate limiting:**
@ -99,10 +106,13 @@ TNT_BIND_ADDR=192.168.1.100 tnt
# Max total connections (default 64)
TNT_MAX_CONNECTIONS=100 tnt
# Max connections per IP (default 5)
# Max concurrent sessions per IP (default 5)
TNT_MAX_CONN_PER_IP=10 tnt
# Disable rate limiting (testing only)
# 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
```
@ -117,11 +127,24 @@ TNT_SSH_LOG_LEVEL=3 tnt
TNT_ACCESS_TOKEN="strong-password-123" \
TNT_BIND_ADDR=0.0.0.0 \
TNT_MAX_CONNECTIONS=200 \
TNT_MAX_CONN_PER_IP=3 \
TNT_MAX_CONN_PER_IP=30 \
TNT_MAX_CONN_RATE_PER_IP=60 \
TNT_SSH_LOG_LEVEL=1 \
tnt -p 2222
```
### SSH Exec Interface
TNT also exposes a small non-interactive SSH surface for scripts:
```sh
ssh -p 2222 chat.m1ng.space health
ssh -p 2222 chat.m1ng.space stats --json
ssh -p 2222 chat.m1ng.space users
ssh -p 2222 chat.m1ng.space "tail -n 20"
ssh -p 2222 operator@chat.m1ng.space post "service notice"
```
## Development
### Building
@ -144,6 +167,7 @@ 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
```
@ -202,6 +226,19 @@ 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.m1ng.space
EOF
```
### Docker
@ -228,6 +265,7 @@ tnt.service - systemd service unit
- [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
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
- [Changelog](docs/CHANGELOG.md) - Version history

View file

@ -47,7 +47,7 @@
**用户体验:**
```bash
# 用户连接(零配置)
ssh -p 2222 your.server.ip
ssh -p 2222 chat.m1ng.space
# 输入任意内容或直接按回车
# 开始聊天!
```
@ -143,7 +143,7 @@ ssh -p 2222 your.server.ip
tnt
# 用户端(任何人)
ssh -p 2222 server.ip
ssh -p 2222 chat.m1ng.space
# 输入任何内容作为密码或直接回车
# 选择显示名称(可留空)
# 开始聊天!
@ -164,9 +164,12 @@ TNT_ACCESS_TOKEN="secret" tnt
# 限制连接数
TNT_MAX_CONNECTIONS=100 tnt
# 限制每IP连接数
# Limit concurrent sessions per IP
TNT_MAX_CONN_PER_IP=10 tnt
# Limit new connections per IP per 60 seconds
TNT_MAX_CONN_RATE_PER_IP=30 tnt
# 只允许本地访问
TNT_BIND_ADDR=127.0.0.1 tnt
```

View file

@ -1,5 +1,24 @@
# Changelog
## 2026-03-10 - SSH Runtime & Unix Interface Update
### Fixed
- moved SSH handshake/auth/channel setup out of the main accept loop
- replaced synchronous room-wide fan-out with room update sequencing and per-client refresh
- switched idle session handling to `ssh_channel_poll_timeout()` plus blocking reads so quiet sessions are not dropped incorrectly
- made `-d/--state-dir` create the runtime state directory automatically
### Added
- SSH exec commands: `help`, `health`, `users`, `stats --json`, `tail`, `post`
- PTY window-change handling for terminal resize
- `TNT_MAX_CONN_RATE_PER_IP` for per-IP connection-rate control
- `tests/test_exec_mode.sh`
- `tests/test_connection_limits.sh`
### Changed
- `TNT_MAX_CONN_PER_IP` now means concurrent sessions per IP
- stress tests now disable rate-based blocking so they exercise concurrency instead of self-throttling
## 2026-01-22 - Security Audit Fixes
Comprehensive security hardening addressing 23 identified vulnerabilities across 6 categories.

View file

@ -33,8 +33,6 @@ sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
1. Create user and directory:
```bash
sudo useradd -r -s /bin/false tnt
sudo mkdir -p /var/lib/tnt
sudo chown tnt:tnt /var/lib/tnt
```
2. Install service file:
@ -45,7 +43,24 @@ sudo systemctl enable tnt
sudo systemctl start tnt
```
3. Check status:
3. Optional runtime overrides:
```bash
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.m1ng.space
EOF
sudo systemctl restart tnt
```
4. Check status:
```bash
sudo systemctl status tnt
sudo journalctl -u tnt -f
@ -64,6 +79,16 @@ Environment="PORT=3333"
sudo systemctl restart tnt
```
The service uses `StateDirectory=tnt`, so systemd creates `/var/lib/tnt` automatically.
Use `TNT_STATE_DIR` or `tnt -d DIR` when running outside systemd to avoid depending on the current working directory.
Recommended interpretation:
- `TNT_MAX_CONNECTIONS`: global connection ceiling
- `TNT_MAX_CONN_PER_IP`: concurrent sessions allowed from one IP
- `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds
- `TNT_RATE_LIMIT=0`: disables rate-based blocking and auth-failure IP blocking, but not the explicit capacity limits
## Firewall
```bash

View file

@ -25,7 +25,7 @@ tnt # 监听 2222 端口
用户只需要一个SSH客户端即可无需任何配置
```bash
ssh -p 2222 your.server.ip
ssh -p 2222 chat.m1ng.space
```
**重要提示**
@ -125,7 +125,7 @@ That's it! Your server is now running.
Users only need an SSH client, no configuration required:
```bash
ssh -p 2222 your.server.ip
ssh -p 2222 chat.m1ng.space
```
**Important**:
@ -181,9 +181,12 @@ PORT=3333 tnt
# Limit max connections
TNT_MAX_CONNECTIONS=100 tnt
# Limit connections per IP
# Limit concurrent sessions per IP
TNT_MAX_CONN_PER_IP=10 tnt
# Limit new connections per IP per 60 seconds
TNT_MAX_CONN_RATE_PER_IP=30 tnt
# Bind to localhost only
TNT_BIND_ADDR=127.0.0.1 tnt
@ -213,7 +216,7 @@ TNT_ACCESS_TOKEN="your_secret_password" tnt
tnt
# 用户连接(从任何机器)
ssh -p 2222 chat.example.com
ssh -p 2222 chat.m1ng.space
# 输入任意密码或直接回车
# 输入显示名称或留空
# 开始聊天!

View file

@ -59,10 +59,10 @@ Branch 4: fix/resource-management (Medium Priority)
Branch 5: fix/auth-protection (Critical Priority)
--------------------------------------------------
✅ Add optional access token (TNT_ACCESS_TOKEN)
✅ IP-based rate limiting (10 conn/IP/60s)
✅ IP-based connection-rate limiting (10 new conn/IP/60s)
✅ Auth failure tracking (5 failures → 5 min block)
✅ Connection counting (total and per-IP)
✅ Configurable limits (TNT_MAX_CONNECTIONS, TNT_MAX_CONN_PER_IP)
✅ Connection counting (total, per-IP active sessions, per-IP recent attempts)
✅ Configurable limits (TNT_MAX_CONNECTIONS, TNT_MAX_CONN_PER_IP, TNT_MAX_CONN_RATE_PER_IP)
✅ Rate limit toggle (TNT_RATE_LIMIT)
Branch 6: fix/concurrency-safety (High Priority)
@ -84,7 +84,8 @@ TNT_BIND_ADDR - Configurable bind address (default: 0.0.0.0)
TNT_SSH_LOG_LEVEL - SSH logging verbosity 0-4 (default: 1)
TNT_RATE_LIMIT - Enable/disable rate limiting (default: 1)
TNT_MAX_CONNECTIONS - Global connection limit (default: 64)
TNT_MAX_CONN_PER_IP - Per-IP connection limit (default: 5)
TNT_MAX_CONN_PER_IP - Concurrent sessions allowed per IP (default: 5)
TNT_MAX_CONN_RATE_PER_IP - New connections allowed per IP per 60s (default: 10)
Security Enhancements:
---------------------

99
docs/ROADMAP.md Normal file
View file

@ -0,0 +1,99 @@
# Roadmap
TNT is moving toward a durable Unix-style utility: a small, predictable tool with a stable interface, explicit configuration, scriptable output, and operationally simple deployment.
This roadmap is intentionally strict. Each stage should leave the project easier to reason about, easier to automate, and safer to operate.
## Design Principles
- Keep the default path simple: install, run, connect.
- Treat non-interactive interfaces as first-class, not as an afterthought to the TUI.
- Prefer explicit flags, stable exit codes, and machine-readable output over implicit behavior.
- Keep daemon concerns separate from control-plane concerns.
- Make failure modes observable and testable.
- Preserve the Vim-style interactive experience without coupling it to core server semantics.
## Stage 1: Interface Contract
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:
- `health`
- `stats`
- `users`
- `tail`
- `post`
- support text and JSON output modes where machine use is likely
- normalize command parsing, help text, and error reporting
- add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently
- add a man page for `tntd` 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
- 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
## Stage 3: Data and Persistence
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
- add log rotation and compaction tooling
- provide an offline inspection/export command
- define recovery behavior for truncated or partially corrupted logs
## Stage 4: Interactive UX
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
- add useful chat commands with clear semantics:
- `/nick`
- `/me`
- `/last N`
- `/search`
- `/mute-joins`
- 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
- 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
- tighten CI around authentication, limits, and restart behavior
## Stage 6: Release Quality
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
- 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
## Immediate Next Tasks
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.

View file

@ -26,7 +26,8 @@ Connect: `sshpass -p "YourSecretPassword" ssh -p 2222 localhost`
| `TNT_SSH_LOG_LEVEL` | `1` | SSH logging (0-4) | `TNT_SSH_LOG_LEVEL=3` |
| `TNT_RATE_LIMIT` | `1` | Rate limiting on/off | `TNT_RATE_LIMIT=0` |
| `TNT_MAX_CONNECTIONS` | `64` | Total connection limit | `TNT_MAX_CONNECTIONS=100` |
| `TNT_MAX_CONN_PER_IP` | `5` | Per-IP limit | `TNT_MAX_CONN_PER_IP=3` |
| `TNT_MAX_CONN_PER_IP` | `5` | Concurrent sessions per IP | `TNT_MAX_CONN_PER_IP=3` |
| `TNT_MAX_CONN_RATE_PER_IP` | `10` | New connections per IP per 60s | `TNT_MAX_CONN_RATE_PER_IP=20` |
---
@ -75,7 +76,8 @@ TNT_MAX_CONN_PER_IP=2 \
## Rate Limiting
### Defaults
- **Connection Rate:** 10 connections per IP per 60 seconds
- **Concurrent Sessions:** 5 per IP
- **Connection Rate:** 10 new connections per IP per 60 seconds
- **Auth Failures:** 5 failures → 5 minute IP block
- **Window:** 60 second rolling window
@ -110,6 +112,12 @@ TNT_MAX_CONN_PER_IP=3 ./tnt
```
Each IP can have max 3 concurrent connections.
### Per-IP Rate Limit
```bash
TNT_MAX_CONN_RATE_PER_IP=20 ./tnt
```
Each IP can open at most 20 new connections per 60 seconds before being temporarily blocked.
### Combined Example
```bash
TNT_MAX_CONNECTIONS=100 TNT_MAX_CONN_PER_IP=10 ./tnt

View file

@ -75,8 +75,8 @@
| **Crypto** | RSA Key Size | 4096-bit (upgraded from 2048) | ✅ |
| **Crypto** | Key Permissions | Atomic generation with 0600 perms | ✅ |
| **Auth** | Access Token | Optional password protection | ✅ |
| **Auth** | Rate Limiting | IP-based connection throttling | ✅ |
| **Auth** | Connection Limits | Global and per-IP limits | ✅ |
| **Auth** | Rate Limiting | Per-IP connection-rate throttling | ✅ |
| **Auth** | Connection Limits | Global and per-IP concurrent session limits | ✅ |
| **Input** | Username Validation | Shell metacharacter rejection | ✅ |
| **Input** | Log Sanitization | Pipe/newline replacement | ✅ |
| **Input** | UTF-8 Validation | Overlong encoding prevention | ✅ |
@ -114,9 +114,10 @@ TNT_BIND_ADDR=127.0.0.1 ./tnt
### Strict Limits
```bash
TNT_MAX_CONNECTIONS=10 TNT_MAX_CONN_PER_IP=2 ./tnt
TNT_MAX_CONNECTIONS=10 TNT_MAX_CONN_PER_IP=2 TNT_MAX_CONN_RATE_PER_IP=10 ./tnt
# Max 10 total connections
# Max 2 connections per IP address
# Max 2 concurrent sessions per IP address
# Max 10 new connections per IP per 60 seconds
```
### Disabled Rate Limiting (Testing)
@ -155,7 +156,7 @@ gcc -fsanitize=thread -g -O1 -c src/chat_room.c
## Known Limitations
1. **Interactive Only:** Server requires PTY sessions (no command execution via SSH)
1. **Exec Surface Is Minimal:** The SSH exec interface is intentionally small and currently focused on operational commands
2. **libssh Deprecations:** Uses deprecated PTY width/height functions (4 warnings)
3. **UTF-8 Unit Test:** Skipped in automated tests (requires manual compilation)
@ -165,7 +166,7 @@ gcc -fsanitize=thread -g -O1 -c src/chat_room.c
✅ **All 23 security vulnerabilities fixed and verified**
**100% test pass rate** (10/10 tests)
**100% security-suite pass rate** (12/12 tests)
**Backward compatible** - server remains open by default

View file

@ -15,6 +15,7 @@ typedef struct {
int client_capacity;
message_t *messages;
int message_count;
uint64_t update_seq;
} chat_room_t;
/* Global chat room instance */
@ -47,4 +48,7 @@ int room_get_message_count(chat_room_t *room);
/* Get online client count */
int room_get_client_count(chat_room_t *room);
/* Get room update sequence */
uint64_t room_get_update_seq(chat_room_t *room);
#endif /* CHAT_ROOM_H */

View file

@ -7,6 +7,7 @@
#include <stdint.h>
#include <stdbool.h>
#include <time.h>
#include <limits.h>
#include <pthread.h>
/* Project Metadata */
@ -17,9 +18,11 @@
#define MAX_MESSAGES 100
#define MAX_USERNAME_LEN 64
#define MAX_MESSAGE_LEN 1024
#define MAX_EXEC_COMMAND_LEN 1024
#define MAX_CLIENTS 64
#define LOG_FILE "messages.log"
#define HOST_KEY_FILE "host_key"
#define TNT_DEFAULT_STATE_DIR "."
/* ANSI color codes */
#define ANSI_RESET "\033[0m"
@ -43,4 +46,9 @@ typedef enum {
LANG_ZH
} help_lang_t;
/* Runtime helpers */
const char* tnt_state_dir(void);
int tnt_ensure_state_dir(void);
int tnt_state_path(char *buffer, size_t buf_size, const char *filename);
#endif /* COMMON_H */

View file

@ -3,6 +3,7 @@
#include "common.h"
#include "chat_room.h"
#include <arpa/inet.h>
#include <libssh/libssh.h>
#include <libssh/server.h>
@ -12,6 +13,7 @@ typedef struct client {
ssh_session session; /* SSH session */
ssh_channel channel; /* SSH channel */
char username[MAX_USERNAME_LEN];
char client_ip[INET6_ADDRSTRLEN];
int width;
int height;
client_mode_t mode;
@ -21,11 +23,15 @@ typedef struct client {
bool show_help;
char command_input[256];
char command_output[2048];
char exec_command[256];
char exec_command[MAX_EXEC_COMMAND_LEN];
char ssh_login[MAX_USERNAME_LEN];
bool redraw_pending;
pthread_t thread;
bool connected;
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 */
struct ssh_channel_callbacks_struct *channel_cb;
} client_t;
/* Initialize SSH server */
@ -43,4 +49,8 @@ int client_send(client_t *client, const char *data, size_t len);
/* Send formatted string to client */
int client_printf(client_t *client, const char *fmt, ...);
/* Reference counting helpers */
void client_addref(client_t *client);
void client_release(client_t *client);
#endif /* SSH_SERVER_H */

View file

@ -30,4 +30,7 @@ void utf8_remove_last_word(char *str);
/* Validate a UTF-8 byte sequence */
bool utf8_is_valid_sequence(const char *bytes, int len);
/* Validate an entire NUL-terminated UTF-8 string */
bool utf8_is_valid_string(const char *str);
#endif /* UTF8_H */

View file

@ -1,11 +1,23 @@
#include "chat_room.h"
#include "ssh_server.h"
#include "tui.h"
#include <unistd.h>
/* Global chat room instance */
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;
}
int capacity = atoi(env);
if (capacity < 1 || capacity > 1024) {
return MAX_CLIENTS;
}
return capacity;
}
/* Initialize chat room */
chat_room_t* room_create(void) {
chat_room_t *room = calloc(1, sizeof(chat_room_t));
@ -13,8 +25,8 @@ chat_room_t* room_create(void) {
pthread_rwlock_init(&room->lock, NULL);
room->client_capacity = MAX_CLIENTS;
room->clients = calloc(room->client_capacity, sizeof(client_t*));
room->client_capacity = room_capacity_from_env();
room->clients = calloc(room->client_capacity, sizeof(struct client *));
if (!room->clients) {
free(room);
return NULL;
@ -42,7 +54,7 @@ void room_destroy(chat_room_t *room) {
}
/* Add client to room */
int room_add_client(chat_room_t *room, client_t *client) {
int room_add_client(chat_room_t *room, struct client *client) {
pthread_rwlock_wrlock(&room->lock);
if (room->client_count >= room->client_capacity) {
@ -57,7 +69,7 @@ int room_add_client(chat_room_t *room, client_t *client) {
}
/* Remove client from room */
void room_remove_client(chat_room_t *room, client_t *client) {
void room_remove_client(chat_room_t *room, struct client *client) {
pthread_rwlock_wrlock(&room->lock);
for (int i = 0; i < room->client_count; i++) {
@ -80,69 +92,9 @@ void room_broadcast(chat_room_t *room, const message_t *msg) {
/* Add to history */
room_add_message(room, msg);
/* Get copy of client list and increment ref counts */
int count = room->client_count;
if (count == 0) {
pthread_rwlock_unlock(&room->lock);
return;
}
client_t **clients_copy = calloc(count, sizeof(client_t*));
if (!clients_copy) {
pthread_rwlock_unlock(&room->lock);
return;
}
memcpy(clients_copy, room->clients, count * sizeof(client_t*));
/* Increment reference count for each client */
for (int i = 0; i < count; i++) {
pthread_mutex_lock(&clients_copy[i]->ref_lock);
clients_copy[i]->ref_count++;
pthread_mutex_unlock(&clients_copy[i]->ref_lock);
}
room->update_seq++;
pthread_rwlock_unlock(&room->lock);
/* Render to each client (outside of lock) */
for (int i = 0; i < count; i++) {
client_t *client = clients_copy[i];
/* Check client state before rendering (while holding ref) */
bool should_render = false;
pthread_mutex_lock(&client->ref_lock);
if (client->ref_count > 0) {
should_render = client->connected &&
!client->show_help &&
client->command_output[0] == '\0';
}
pthread_mutex_unlock(&client->ref_lock);
if (should_render) {
tui_render_screen(client);
}
/* Decrement reference count and free if needed */
pthread_mutex_lock(&client->ref_lock);
client->ref_count--;
int ref = client->ref_count;
pthread_mutex_unlock(&client->ref_lock);
if (ref == 0) {
/* Safe to free now */
if (client->channel) {
ssh_channel_close(client->channel);
ssh_channel_free(client->channel);
}
if (client->session) {
ssh_disconnect(client->session);
ssh_free(client->session);
}
pthread_mutex_destroy(&client->ref_lock);
free(client);
}
}
free(clients_copy);
}
/* Add message to room history */
@ -187,3 +139,13 @@ int room_get_client_count(chat_room_t *room) {
pthread_rwlock_unlock(&room->lock);
return count;
}
uint64_t room_get_update_seq(chat_room_t *room) {
uint64_t seq;
pthread_rwlock_rdlock(&room->lock);
seq = room->update_seq;
pthread_rwlock_unlock(&room->lock);
return seq;
}

86
src/common.c Normal file
View file

@ -0,0 +1,86 @@
#include "common.h"
#include <errno.h>
#include <sys/stat.h>
#ifndef PATH_MAX
#define PATH_MAX 4096
#endif
const char* tnt_state_dir(void) {
const char *dir = getenv("TNT_STATE_DIR");
if (!dir || dir[0] == '\0') {
return TNT_DEFAULT_STATE_DIR;
}
return dir;
}
int tnt_ensure_state_dir(void) {
const char *dir = tnt_state_dir();
char path[PATH_MAX];
struct stat st;
size_t len;
if (!dir || dir[0] == '\0') {
return -1;
}
if (strcmp(dir, ".") == 0 || strcmp(dir, "/") == 0) {
return 0;
}
if (snprintf(path, sizeof(path), "%s", dir) >= (int)sizeof(path)) {
return -1;
}
len = strlen(path);
while (len > 1 && path[len - 1] == '/') {
path[len - 1] = '\0';
len--;
}
for (char *p = path + 1; *p; p++) {
if (*p != '/') {
continue;
}
*p = '\0';
if (mkdir(path, 0700) < 0 && errno != EEXIST) {
return -1;
}
*p = '/';
}
if (mkdir(path, 0700) < 0 && errno != EEXIST) {
return -1;
}
if (stat(path, &st) != 0 || !S_ISDIR(st.st_mode)) {
return -1;
}
return 0;
}
int tnt_state_path(char *buffer, size_t buf_size, const char *filename) {
const char *dir;
int written;
if (!buffer || buf_size == 0 || !filename || filename[0] == '\0') {
return -1;
}
dir = tnt_state_dir();
if (strcmp(dir, "/") == 0) {
written = snprintf(buffer, buf_size, "/%s", filename);
} else {
written = snprintf(buffer, buf_size, "%s/%s", dir, filename);
}
if (written < 0 || (size_t)written >= buf_size) {
return -1;
}
return 0;
}

View file

@ -11,40 +11,54 @@
static void signal_handler(int sig) {
(void)sig;
static const char msg[] = "\nShutting down...\n";
(void)write(STDERR_FILENO, msg, sizeof(msg) - 1);
ssize_t ignored = write(STDERR_FILENO, msg, sizeof(msg) - 1);
(void)ignored;
_exit(0);
}
int main(int argc, char **argv) {
int port = DEFAULT_PORT;
/* Environment provides defaults; command-line flags override it. */
const char *port_env = getenv("PORT");
if (port_env) {
port = atoi(port_env);
}
/* Parse command line arguments */
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) {
port = atoi(argv[i + 1]);
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");
return 1;
}
i++;
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
printf("TNT - Terminal Network Talk\n");
printf("Usage: %s [options]\n", argv[0]);
printf("Options:\n");
printf(" -p PORT Listen on PORT (default: %d)\n", DEFAULT_PORT);
printf(" -d DIR Store host key and logs in DIR\n");
printf(" -h Show this help\n");
return 0;
}
}
/* Check environment variable for port */
const char *port_env = getenv("PORT");
if (port_env) {
port = atoi(port_env);
}
/* Setup signal handlers */
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
signal(SIGPIPE, SIG_IGN);
/* Initialize subsystems */
if (tnt_ensure_state_dir() < 0) {
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
return 1;
}
message_init();
/* Create chat room */

View file

@ -3,6 +3,50 @@
#include <unistd.h>
#include <fcntl.h>
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};
char *result;
char *old_tz = NULL;
time_t parsed;
if (!timestamp_str) {
return (time_t)-1;
}
result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm);
if (!result || *result != '\0') {
return (time_t)-1;
}
const char *tz = getenv("TZ");
if (tz) {
old_tz = strdup(tz);
if (!old_tz) {
return (time_t)-1;
}
}
if (setenv("TZ", "UTC0", 1) != 0) {
free(old_tz);
return (time_t)-1;
}
tzset();
parsed = mktime(&tm);
if (old_tz) {
setenv("TZ", old_tz, 1);
free(old_tz);
} else {
unsetenv("TZ");
}
tzset();
return parsed;
}
/* Initialize message subsystem */
void message_init(void) {
/* Nothing to initialize for now */
@ -10,13 +54,20 @@ void message_init(void) {
/* Load messages from log file - Optimized for large files */
int message_load(message_t **messages, int max_messages) {
char log_path[PATH_MAX];
/* Always allocate the message array */
message_t *msg_array = calloc(max_messages, sizeof(message_t));
if (!msg_array) {
return 0;
}
FILE *fp = fopen(LOG_FILE, "r");
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
*messages = msg_array;
return 0;
}
FILE *fp = fopen(log_path, "r");
if (!fp) {
/* File doesn't exist yet, no messages */
*messages = msg_array;
@ -117,15 +168,17 @@ read_messages:;
continue;
}
/* Parse ISO 8601 timestamp */
struct tm tm = {0};
char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%S", &tm);
if (!result) {
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 msg_time = mktime(&tm);
time_t now = time(NULL);
if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) {
continue;
@ -146,8 +199,18 @@ read_messages:;
/* Save a message to log file */
int message_save(const message_t *msg) {
FILE *fp = fopen(LOG_FILE, "a");
char log_path[PATH_MAX];
int rc = 0;
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
return -1;
}
pthread_mutex_lock(&g_message_file_lock);
FILE *fp = fopen(log_path, "a");
if (!fp) {
pthread_mutex_unlock(&g_message_file_lock);
return -1;
}
@ -180,10 +243,14 @@ int message_save(const message_t *msg) {
}
/* Write to file: timestamp|username|content */
fprintf(fp, "%s|%s|%s\n", timestamp, safe_username, safe_content);
if (fprintf(fp, "%s|%s|%s\n", timestamp, safe_username, safe_content) < 0 ||
fflush(fp) != 0) {
rc = -1;
}
fclose(fp);
return 0;
pthread_mutex_unlock(&g_message_file_lock);
return rc;
}
/* Format a message for display */

File diff suppressed because it is too large Load diff

113
src/tui.c
View file

@ -5,6 +5,46 @@
#include <stdarg.h>
#include <unistd.h>
static void buffer_append_bytes(char *buffer, size_t buf_size, size_t *pos,
const char *data, size_t len) {
size_t available;
size_t to_copy;
if (!buffer || !pos || !data || len == 0 || buf_size == 0 || *pos >= buf_size - 1) {
return;
}
available = (buf_size - 1) - *pos;
to_copy = (len < available) ? len : available;
memcpy(buffer + *pos, data, to_copy);
*pos += to_copy;
buffer[*pos] = '\0';
}
static void buffer_appendf(char *buffer, size_t buf_size, size_t *pos,
const char *fmt, ...) {
va_list args;
int written;
if (!buffer || !pos || !fmt || buf_size == 0 || *pos >= buf_size - 1) {
return;
}
va_start(args, fmt);
written = vsnprintf(buffer + *pos, buf_size - *pos, fmt, args);
va_end(args);
if (written < 0) {
return;
}
if ((size_t)written >= buf_size - *pos) {
*pos = buf_size - 1;
} else {
*pos += (size_t)written;
}
}
/* Clear the screen */
void tui_clear_screen(client_t *client) {
if (!client || !client->connected) return;
@ -21,7 +61,8 @@ void tui_render_screen(client_t *client) {
const size_t buf_size = 65536;
char *buffer = malloc(buf_size);
if (!buffer) return;
int pos = 0;
size_t pos = 0;
buffer[0] = '\0';
/* Acquire all data in one lock to prevent TOCTOU */
pthread_rwlock_rdlock(&g_room->lock);
@ -66,7 +107,7 @@ void tui_render_screen(client_t *client) {
/* Now render using snapshot (no lock held) */
/* Move to top (Home) - Do NOT clear screen to prevent flicker */
pos += snprintf(buffer + pos, buf_size - pos, ANSI_HOME);
buffer_appendf(buffer, buf_size, &pos, ANSI_HOME);
/* Title bar */
const char *mode_str = (client->mode == MODE_INSERT) ? "INSERT" :
@ -82,48 +123,44 @@ void tui_render_screen(client_t *client) {
int padding = client->width - title_width;
if (padding < 0) padding = 0;
pos += snprintf(buffer + pos, buf_size - pos, ANSI_REVERSE "%s", title);
for (int i = 0; i < padding && pos < (int)buf_size - 4; i++) {
buffer[pos++] = ' ';
buffer_appendf(buffer, buf_size, &pos, ANSI_REVERSE "%s", title);
for (int i = 0; i < padding; i++) {
buffer_append_bytes(buffer, buf_size, &pos, " ", 1);
}
pos += snprintf(buffer + pos, buf_size - pos, ANSI_RESET "\033[K\r\n");
buffer_appendf(buffer, buf_size, &pos, ANSI_RESET "\033[K\r\n");
/* Render messages from snapshot */
if (msg_snapshot) {
for (int i = 0; i < snapshot_count; i++) {
char msg_line[1024];
message_format(&msg_snapshot[i], msg_line, sizeof(msg_line), client->width);
pos += snprintf(buffer + pos, buf_size - pos, "%s\033[K\r\n", msg_line);
buffer_appendf(buffer, buf_size, &pos, "%s\033[K\r\n", msg_line);
}
free(msg_snapshot);
}
/* Fill empty lines and clear them */
for (int i = snapshot_count; i < msg_height; i++) {
pos += snprintf(buffer + pos, buf_size - pos, "\033[K\r\n");
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
}
/* Separator - use box drawing character */
for (int i = 0; i < client->width && pos < (int)buf_size - 10; i++) {
const char *line_char = ""; /* U+2500 box drawing, 3 bytes */
int len = strlen(line_char);
memcpy(buffer + pos, line_char, len);
pos += len;
for (int i = 0; i < client->width; i++) {
buffer_append_bytes(buffer, buf_size, &pos, "", strlen(""));
}
pos += snprintf(buffer + pos, buf_size - pos, "\033[K\r\n");
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
/* Status/Input line */
if (client->mode == MODE_INSERT) {
pos += snprintf(buffer + pos, buf_size - pos, "> \033[K");
buffer_appendf(buffer, buf_size, &pos, "> \033[K");
} else if (client->mode == MODE_NORMAL) {
int total = msg_count;
int scroll_pos = client->scroll_pos + 1;
if (total == 0) scroll_pos = 0;
pos += snprintf(buffer + pos, buf_size - pos,
buffer_appendf(buffer, buf_size, &pos,
"-- NORMAL -- (%d/%d)\033[K", scroll_pos, total);
} else if (client->mode == MODE_COMMAND) {
pos += snprintf(buffer + pos, buf_size - pos,
":%s\033[K", client->command_input);
buffer_appendf(buffer, buf_size, &pos, ":%s\033[K", client->command_input);
}
client_send(client, buffer, pos);
@ -170,10 +207,11 @@ void tui_render_command_output(client_t *client) {
if (!client || !client->connected) return;
char buffer[4096];
int pos = 0;
size_t pos = 0;
buffer[0] = '\0';
/* Clear screen */
pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_CLEAR ANSI_HOME);
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */
const char *title = " COMMAND OUTPUT ";
@ -181,11 +219,11 @@ void tui_render_command_output(client_t *client) {
int padding = client->width - title_width;
if (padding < 0) padding = 0;
pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_REVERSE "%s", title);
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s", title);
for (int i = 0; i < padding; i++) {
buffer[pos++] = ' ';
buffer_append_bytes(buffer, sizeof(buffer), &pos, " ", 1);
}
pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_RESET "\r\n");
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
/* Command output - use a copy to avoid strtok corruption */
char output_copy[2048];
@ -205,7 +243,7 @@ void tui_render_command_output(client_t *client) {
utf8_truncate(truncated, client->width);
}
pos += snprintf(buffer + pos, sizeof(buffer) - pos, "%s\r\n", truncated);
buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", truncated);
line = strtok(NULL, "\n");
line_count++;
}
@ -315,10 +353,11 @@ void tui_render_help(client_t *client) {
if (!client || !client->connected) return;
char buffer[8192];
int pos = 0;
size_t pos = 0;
buffer[0] = '\0';
/* Clear screen */
pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_CLEAR ANSI_HOME);
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */
const char *title = " HELP ";
@ -326,11 +365,11 @@ void tui_render_help(client_t *client) {
int padding = client->width - title_width;
if (padding < 0) padding = 0;
pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_REVERSE "%s", title);
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s", title);
for (int i = 0; i < padding; i++) {
buffer[pos++] = ' ';
buffer_append_bytes(buffer, sizeof(buffer), &pos, " ", 1);
}
pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_RESET "\r\n");
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
/* Help content */
const char *help_text = tui_get_help_text(client->help_lang);
@ -348,27 +387,27 @@ void tui_render_help(client_t *client) {
}
int content_height = client->height - 2;
if (content_height < 1) content_height = 1;
int max_scroll = line_count - content_height + 1;
if (max_scroll < 0) max_scroll = 0;
int start = client->help_scroll_pos;
if (start > max_scroll) start = max_scroll;
int end = start + content_height - 1;
if (end > line_count) end = line_count;
for (int i = start; i < end && (i - start) < content_height - 1; i++) {
pos += snprintf(buffer + pos, sizeof(buffer) - pos, "%s\r\n", lines[i]);
buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", lines[i]);
}
/* Fill remaining lines */
for (int i = end - start; i < content_height - 1; i++) {
buffer[pos++] = '\r';
buffer[pos++] = '\n';
buffer_append_bytes(buffer, sizeof(buffer), &pos, "\r\n", 2);
}
/* Status line */
int max_scroll = line_count - content_height + 1;
if (max_scroll < 0) max_scroll = 0;
pos += snprintf(buffer + pos, sizeof(buffer) - pos,
buffer_appendf(buffer, sizeof(buffer), &pos,
"-- HELP -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close",
client->help_scroll_pos + 1, max_scroll + 1);
start + 1, max_scroll + 1);
client_send(client, buffer, pos);
}

View file

@ -15,6 +15,17 @@ uint32_t utf8_decode(const char *str, int *bytes_read) {
uint32_t codepoint = 0;
int len = utf8_byte_length(s[0]);
if (len < 1 || len > 4) {
len = 1;
}
for (int i = 1; i < len; i++) {
if (s[i] == '\0') {
*bytes_read = 1;
return s[0];
}
}
*bytes_read = len;
switch (len) {
@ -207,3 +218,32 @@ bool utf8_is_valid_sequence(const char *bytes, int len) {
return true;
}
bool utf8_is_valid_string(const char *str) {
const unsigned char *p = (const unsigned char *)str;
if (!str) {
return false;
}
while (*p != '\0') {
int len = utf8_byte_length(*p);
if (len < 1 || len > 4) {
return false;
}
for (int i = 1; i < len; i++) {
if (p[i] == '\0') {
return false;
}
}
if (!utf8_is_valid_sequence((const char *)p, len)) {
return false;
}
p += len;
}
return true;
}

135
tests/test_connection_limits.sh Executable file
View file

@ -0,0 +1,135 @@
#!/bin/sh
# Connection limit regression tests for TNT
PORT=${PORT:-2222}
BIN="../tnt"
PASS=0
FAIL=0
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-limit-test.XXXXXX")
SERVER_PID=""
WATCHER_PID=""
cleanup() {
if [ -n "$WATCHER_PID" ]; then
kill "$WATCHER_PID" 2>/dev/null || true
wait "$WATCHER_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 [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
wait_for_health() {
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 Connection Limit Tests ==="
TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=1 "$BIN" -p "$PORT" -d "$STATE_DIR" \
>"$STATE_DIR/concurrent.log" 2>&1 &
SERVER_PID=$!
if wait_for_health; then
echo "✓ server started for concurrent limit test"
PASS=$((PASS + 1))
else
echo "✗ server failed to start for concurrent limit test"
exit 1
fi
WATCHER_READY="$STATE_DIR/watcher.ready"
cat >"$STATE_DIR/watcher.expect" <<EOF
set timeout 10
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT watcher@localhost
expect "请输入用户名"
send "watcher\r"
exec touch "$WATCHER_READY"
sleep 8
send "\003"
expect eof
EOF
expect "$STATE_DIR/watcher.expect" >"$STATE_DIR/watcher.log" 2>&1 &
WATCHER_PID=$!
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -f "$WATCHER_READY" ] && break
sleep 1
done
if [ ! -f "$WATCHER_READY" ]; then
echo "✗ watcher session did not become ready"
sed -n '1,120p' "$STATE_DIR/watcher.log"
exit 1
fi
if ssh $SSH_OPTS localhost health >/dev/null 2>&1; then
echo "✗ concurrent per-IP limit was not enforced"
FAIL=$((FAIL + 1))
else
echo "✓ concurrent per-IP limit rejects a second session"
PASS=$((PASS + 1))
fi
wait "$WATCHER_PID" 2>/dev/null || true
WATCHER_PID=""
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
RATE_PORT=$((PORT + 1))
SSH_RATE_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $RATE_PORT"
TNT_MAX_CONN_PER_IP=10 TNT_MAX_CONN_RATE_PER_IP=2 "$BIN" -p "$RATE_PORT" -d "$STATE_DIR" \
>"$STATE_DIR/rate.log" 2>&1 &
SERVER_PID=$!
sleep 2
if kill -0 "$SERVER_PID" 2>/dev/null; then
echo "✓ server started for rate limit test"
PASS=$((PASS + 1))
else
echo "✗ server failed to start for rate limit test"
sed -n '1,120p' "$STATE_DIR/rate.log"
exit 1
fi
R1=$(ssh $SSH_RATE_OPTS localhost health 2>/dev/null || true)
R2=$(ssh $SSH_RATE_OPTS localhost health 2>/dev/null || true)
if ssh $SSH_RATE_OPTS localhost health >/dev/null 2>&1; then
echo "✗ per-IP connection-rate limit was not enforced"
FAIL=$((FAIL + 1))
else
if [ "$R1" = "ok" ] && [ "$R2" = "ok" ]; then
echo "✓ per-IP connection-rate limit blocks after the configured burst"
PASS=$((PASS + 1))
else
echo "✗ per-IP connection-rate limit setup failed unexpectedly"
FAIL=$((FAIL + 1))
fi
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

167
tests/test_exec_mode.sh Executable file
View file

@ -0,0 +1,167 @@
#!/bin/sh
# Exec-mode regression tests for TNT
PORT=${PORT:-2222}
PASS=0
FAIL=0
BIN="../tnt"
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-exec-test.XXXXXX")
INTERACTIVE_PID=""
cleanup() {
if [ -n "${INTERACTIVE_PID}" ]; then
kill "${INTERACTIVE_PID}" 2>/dev/null || true
wait "${INTERACTIVE_PID}" 2>/dev/null || true
fi
kill "${SERVER_PID}" 2>/dev/null || true
wait "${SERVER_PID}" 2>/dev/null || true
rm -rf "${STATE_DIR}"
}
trap cleanup EXIT
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
echo "=== TNT Exec Mode Tests ==="
TNT_RATE_LIMIT=0 $BIN -p "$PORT" -d "$STATE_DIR" >"${STATE_DIR}/server.log" 2>&1 &
SERVER_PID=$!
HEALTH_OUTPUT=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "✗ Server failed to start"
exit 1
fi
HEALTH_OUTPUT=$(ssh $SSH_OPTS localhost health 2>/dev/null || true)
[ "$HEALTH_OUTPUT" = "ok" ] && break
sleep 1
done
if [ "$HEALTH_OUTPUT" = "ok" ]; then
echo "✓ health returns ok"
PASS=$((PASS + 1))
else
echo "✗ health failed: $HEALTH_OUTPUT"
FAIL=$((FAIL + 1))
fi
STATS_OUTPUT=$(ssh $SSH_OPTS localhost stats 2>/dev/null || true)
printf '%s\n' "$STATS_OUTPUT" | grep -q '^status ok$' &&
printf '%s\n' "$STATS_OUTPUT" | grep -q '^online_users 0$'
if [ $? -eq 0 ]; then
echo "✓ stats returns key/value output"
PASS=$((PASS + 1))
else
echo "✗ stats output unexpected"
printf '%s\n' "$STATS_OUTPUT"
FAIL=$((FAIL + 1))
fi
STATS_JSON=$(ssh $SSH_OPTS localhost stats --json 2>/dev/null || true)
printf '%s\n' "$STATS_JSON" | grep -q '"status":"ok"' &&
printf '%s\n' "$STATS_JSON" | grep -q '"online_users":0'
if [ $? -eq 0 ]; then
echo "✓ stats --json returns JSON"
PASS=$((PASS + 1))
else
echo "✗ stats --json output unexpected"
printf '%s\n' "$STATS_JSON"
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"
PASS=$((PASS + 1))
else
echo "✗ post failed: $POST_OUTPUT"
FAIL=$((FAIL + 1))
fi
TAIL_OUTPUT=$(ssh $SSH_OPTS localhost "tail -n 1" 2>/dev/null || true)
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'execposter' &&
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'hello from exec'
if [ $? -eq 0 ]; then
echo "✓ tail returns recent messages"
PASS=$((PASS + 1))
else
echo "✗ tail output unexpected"
printf '%s\n' "$TAIL_OUTPUT"
FAIL=$((FAIL + 1))
fi
EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
WATCHER_READY="${STATE_DIR}/watcher.ready"
cat >"$EXPECT_SCRIPT" <<EOF
set timeout 10
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT watcher@localhost
expect "请输入用户名"
send "watcher\r"
exec touch "$WATCHER_READY"
sleep 8
send "\003"
expect eof
EOF
expect "$EXPECT_SCRIPT" >"${STATE_DIR}/expect.log" 2>&1 &
INTERACTIVE_PID=$!
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -f "$WATCHER_READY" ] && break
sleep 1
done
USERS_OUTPUT=""
for _ in 1 2 3 4 5; do
USERS_OUTPUT=$(ssh $SSH_OPTS localhost users 2>/dev/null || true)
printf '%s\n' "$USERS_OUTPUT" | grep -q '^watcher$' && break
sleep 1
done
printf '%s\n' "$USERS_OUTPUT" | grep -q '^watcher$'
if [ $? -eq 0 ]; then
echo "✓ users lists active interactive clients"
PASS=$((PASS + 1))
else
echo "✗ users output unexpected"
printf '%s\n' "$USERS_OUTPUT"
[ -f "$WATCHER_READY" ] || echo "watcher readiness marker was not created"
[ -f "${STATE_DIR}/expect.log" ] && sed -n '1,120p' "${STATE_DIR}/expect.log"
sed -n '1,120p' "${STATE_DIR}/server.log"
FAIL=$((FAIL + 1))
fi
USERS_JSON=""
for _ in 1 2 3 4 5; do
USERS_JSON=$(ssh $SSH_OPTS localhost users --json 2>/dev/null || true)
printf '%s\n' "$USERS_JSON" | grep -q '"watcher"' && break
sleep 1
done
printf '%s\n' "$USERS_JSON" | grep -q '"watcher"'
if [ $? -eq 0 ]; then
echo "✓ users --json returns JSON array"
PASS=$((PASS + 1))
else
echo "✗ users --json output unexpected"
printf '%s\n' "$USERS_JSON"
[ -f "$WATCHER_READY" ] || echo "watcher readiness marker was not created"
[ -f "${STATE_DIR}/expect.log" ] && sed -n '1,120p' "${STATE_DIR}/expect.log"
sed -n '1,120p' "${STATE_DIR}/server.log"
FAIL=$((FAIL + 1))
fi
wait "${INTERACTIVE_PID}" 2>/dev/null || true
INTERACTIVE_PID=""
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

View file

@ -97,6 +97,10 @@ TNT_ACCESS_TOKEN="test123" $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server"
TNT_MAX_CONNECTIONS=10 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
pass "TNT_MAX_CONNECTIONS configuration accepted" || fail "TNT_MAX_CONNECTIONS not working"
# Test per-IP connection rate configuration
TNT_MAX_CONN_RATE_PER_IP=20 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
pass "TNT_MAX_CONN_RATE_PER_IP configuration accepted" || fail "TNT_MAX_CONN_RATE_PER_IP not working"
# Test rate limit toggle
TNT_RATE_LIMIT=0 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
pass "TNT_RATE_LIMIT configuration accepted" || fail "TNT_RATE_LIMIT not working"

View file

@ -19,7 +19,7 @@ if command -v gtimeout >/dev/null 2>&1; then
fi
echo "Starting TNT server on port $PORT..."
$BIN -p $PORT &
TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=$CLIENTS $BIN -p $PORT &
SERVER_PID=$!
sleep 2
@ -47,7 +47,7 @@ kill $SERVER_PID 2>/dev/null
wait
echo "Stress test complete"
if ps aux | grep tnt | grep -v grep > /dev/null; then
if kill -0 $SERVER_PID 2>/dev/null; then
echo "WARNING: tnt process still running"
else
echo "Server shutdown confirmed."

View file

@ -7,8 +7,9 @@ After=network.target
Type=simple
User=tnt
Group=tnt
WorkingDirectory=/var/lib/tnt
ExecStart=/usr/local/bin/tnt
StateDirectory=tnt
StateDirectoryMode=0700
# Automatic restart on failure for long-term stability
Restart=always
@ -39,6 +40,8 @@ TimeoutStopSec=30
# Environment (can be customized via systemctl edit)
Environment="PORT=2222"
Environment="TNT_STATE_DIR=/var/lib/tnt"
EnvironmentFile=-/etc/default/tnt
[Install]
WantedBy=multi-user.target