refactor: stabilize SSH runtime and add exec interface

This commit is contained in:
m1ngsama 2026-03-10 18:52:20 +08:00
parent e3e1486187
commit e473b26e0d
18 changed files with 1514 additions and 346 deletions

View file

@ -38,13 +38,14 @@ https://github.com/m1ngsama/TNT/releases
```sh ```sh
tnt # default port 2222 tnt # default port 2222
tnt -p 3333 # custom port tnt -p 3333 # custom port
tnt -d /var/lib/tnt
PORT=3333 tnt # via env var PORT=3333 tnt # via env var
``` ```
### Connecting ### Connecting
```sh ```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. **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 # Bind to specific IP
TNT_BIND_ADDR=192.168.1.100 tnt 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:** **Rate limiting:**
@ -202,6 +209,18 @@ sudo cp tnt.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable tnt sudo systemctl enable tnt
sudo systemctl start 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_RATE_LIMIT=1
TNT_SSH_LOG_LEVEL=0
TNT_PUBLIC_HOST=chat.m1ng.space
EOF
``` ```
### Docker ### Docker

View file

@ -47,7 +47,7 @@
**用户体验:** **用户体验:**
```bash ```bash
# 用户连接(零配置) # 用户连接(零配置)
ssh -p 2222 your.server.ip ssh -p 2222 chat.m1ng.space
# 输入任意内容或直接按回车 # 输入任意内容或直接按回车
# 开始聊天! # 开始聊天!
``` ```
@ -143,7 +143,7 @@ ssh -p 2222 your.server.ip
tnt tnt
# 用户端(任何人) # 用户端(任何人)
ssh -p 2222 server.ip ssh -p 2222 chat.m1ng.space
# 输入任何内容作为密码或直接回车 # 输入任何内容作为密码或直接回车
# 选择显示名称(可留空) # 选择显示名称(可留空)
# 开始聊天! # 开始聊天!

View file

@ -33,8 +33,6 @@ sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
1. Create user and directory: 1. Create user and directory:
```bash ```bash
sudo useradd -r -s /bin/false tnt sudo useradd -r -s /bin/false tnt
sudo mkdir -p /var/lib/tnt
sudo chown tnt:tnt /var/lib/tnt
``` ```
2. Install service file: 2. Install service file:
@ -45,7 +43,23 @@ sudo systemctl enable tnt
sudo systemctl start 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_RATE_LIMIT=1
TNT_SSH_LOG_LEVEL=0
TNT_PUBLIC_HOST=chat.m1ng.space
EOF
sudo systemctl restart tnt
```
4. Check status:
```bash ```bash
sudo systemctl status tnt sudo systemctl status tnt
sudo journalctl -u tnt -f sudo journalctl -u tnt -f
@ -64,6 +78,9 @@ Environment="PORT=3333"
sudo systemctl restart tnt 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.
## Firewall ## Firewall
```bash ```bash

View file

@ -25,7 +25,7 @@ tnt # 监听 2222 端口
用户只需要一个SSH客户端即可无需任何配置 用户只需要一个SSH客户端即可无需任何配置
```bash ```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: Users only need an SSH client, no configuration required:
```bash ```bash
ssh -p 2222 your.server.ip ssh -p 2222 chat.m1ng.space
``` ```
**Important**: **Important**:
@ -213,7 +213,7 @@ TNT_ACCESS_TOKEN="your_secret_password" tnt
tnt tnt
# 用户连接(从任何机器) # 用户连接(从任何机器)
ssh -p 2222 chat.example.com ssh -p 2222 chat.m1ng.space
# 输入任意密码或直接回车 # 输入任意密码或直接回车
# 输入显示名称或留空 # 输入显示名称或留空
# 开始聊天! # 开始聊天!

View file

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

View file

@ -7,6 +7,7 @@
#include <stdint.h> #include <stdint.h>
#include <stdbool.h> #include <stdbool.h>
#include <time.h> #include <time.h>
#include <limits.h>
#include <pthread.h> #include <pthread.h>
/* Project Metadata */ /* Project Metadata */
@ -17,9 +18,11 @@
#define MAX_MESSAGES 100 #define MAX_MESSAGES 100
#define MAX_USERNAME_LEN 64 #define MAX_USERNAME_LEN 64
#define MAX_MESSAGE_LEN 1024 #define MAX_MESSAGE_LEN 1024
#define MAX_EXEC_COMMAND_LEN 1024
#define MAX_CLIENTS 64 #define MAX_CLIENTS 64
#define LOG_FILE "messages.log" #define LOG_FILE "messages.log"
#define HOST_KEY_FILE "host_key" #define HOST_KEY_FILE "host_key"
#define TNT_DEFAULT_STATE_DIR "."
/* ANSI color codes */ /* ANSI color codes */
#define ANSI_RESET "\033[0m" #define ANSI_RESET "\033[0m"
@ -43,4 +46,9 @@ typedef enum {
LANG_ZH LANG_ZH
} help_lang_t; } 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 */ #endif /* COMMON_H */

View file

@ -21,11 +21,15 @@ typedef struct client {
bool show_help; bool show_help;
char command_input[256]; char command_input[256];
char command_output[2048]; 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; pthread_t thread;
bool connected; bool connected;
int ref_count; /* Reference count for safe cleanup */ int ref_count; /* Reference count for safe cleanup */
pthread_mutex_t ref_lock; /* Lock for ref_count */ pthread_mutex_t ref_lock; /* Lock for ref_count */
pthread_mutex_t io_lock; /* Serialize SSH channel writes */
struct ssh_channel_callbacks_struct *channel_cb;
} client_t; } client_t;
/* Initialize SSH server */ /* Initialize SSH server */
@ -43,4 +47,8 @@ int client_send(client_t *client, const char *data, size_t len);
/* Send formatted string to client */ /* Send formatted string to client */
int client_printf(client_t *client, const char *fmt, ...); 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 */ #endif /* SSH_SERVER_H */

View file

@ -30,4 +30,7 @@ void utf8_remove_last_word(char *str);
/* Validate a UTF-8 byte sequence */ /* Validate a UTF-8 byte sequence */
bool utf8_is_valid_sequence(const char *bytes, int len); 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 */ #endif /* UTF8_H */

View file

@ -1,11 +1,23 @@
#include "chat_room.h" #include "chat_room.h"
#include "ssh_server.h"
#include "tui.h"
#include <unistd.h>
/* Global chat room instance */ /* Global chat room instance */
chat_room_t *g_room = NULL; 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 */ /* Initialize chat room */
chat_room_t* room_create(void) { chat_room_t* room_create(void) {
chat_room_t *room = calloc(1, sizeof(chat_room_t)); 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); pthread_rwlock_init(&room->lock, NULL);
room->client_capacity = MAX_CLIENTS; room->client_capacity = room_capacity_from_env();
room->clients = calloc(room->client_capacity, sizeof(client_t*)); room->clients = calloc(room->client_capacity, sizeof(struct client *));
if (!room->clients) { if (!room->clients) {
free(room); free(room);
return NULL; return NULL;
@ -42,7 +54,7 @@ void room_destroy(chat_room_t *room) {
} }
/* Add client to 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); pthread_rwlock_wrlock(&room->lock);
if (room->client_count >= room->client_capacity) { 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 */ /* 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); pthread_rwlock_wrlock(&room->lock);
for (int i = 0; i < room->client_count; i++) { 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 */ /* Add to history */
room_add_message(room, msg); room_add_message(room, msg);
room->update_seq++;
/* 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);
}
pthread_rwlock_unlock(&room->lock); 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 */ /* Add message to room history */
@ -187,3 +139,13 @@ int room_get_client_count(chat_room_t *room) {
pthread_rwlock_unlock(&room->lock); pthread_rwlock_unlock(&room->lock);
return count; 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) { static void signal_handler(int sig) {
(void)sig; (void)sig;
static const char msg[] = "\nShutting down...\n"; 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); _exit(0);
} }
int main(int argc, char **argv) { int main(int argc, char **argv) {
int port = DEFAULT_PORT; 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 */ /* Parse command line arguments */
for (int i = 1; i < argc; i++) { for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) {
port = atoi(argv[i + 1]); port = atoi(argv[i + 1]);
i++; 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) { } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
printf("TNT - Terminal Network Talk\n"); printf("TNT - Terminal Network Talk\n");
printf("Usage: %s [options]\n", argv[0]); printf("Usage: %s [options]\n", argv[0]);
printf("Options:\n"); printf("Options:\n");
printf(" -p PORT Listen on PORT (default: %d)\n", DEFAULT_PORT); 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"); printf(" -h Show this help\n");
return 0; return 0;
} }
} }
/* Check environment variable for port */
const char *port_env = getenv("PORT");
if (port_env) {
port = atoi(port_env);
}
/* Setup signal handlers */ /* Setup signal handlers */
signal(SIGINT, signal_handler); signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler); signal(SIGTERM, signal_handler);
signal(SIGPIPE, SIG_IGN); signal(SIGPIPE, SIG_IGN);
/* Initialize subsystems */ /* Initialize subsystems */
if (tnt_ensure_state_dir() < 0) {
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
return 1;
}
message_init(); message_init();
/* Create chat room */ /* Create chat room */

View file

@ -3,6 +3,50 @@
#include <unistd.h> #include <unistd.h>
#include <fcntl.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 */ /* Initialize message subsystem */
void message_init(void) { void message_init(void) {
/* Nothing to initialize for now */ /* Nothing to initialize for now */
@ -10,13 +54,20 @@ void message_init(void) {
/* Load messages from log file - Optimized for large files */ /* Load messages from log file - Optimized for large files */
int message_load(message_t **messages, int max_messages) { int message_load(message_t **messages, int max_messages) {
char log_path[PATH_MAX];
/* Always allocate the message array */ /* Always allocate the message array */
message_t *msg_array = calloc(max_messages, sizeof(message_t)); message_t *msg_array = calloc(max_messages, sizeof(message_t));
if (!msg_array) { if (!msg_array) {
return 0; 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) { if (!fp) {
/* File doesn't exist yet, no messages */ /* File doesn't exist yet, no messages */
*messages = msg_array; *messages = msg_array;
@ -117,15 +168,17 @@ read_messages:;
continue; continue;
} }
/* Parse ISO 8601 timestamp */ if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) {
struct tm tm = {0}; continue;
char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%S", &tm); }
if (!result) {
/* Parse strict UTC RFC3339 timestamp */
time_t msg_time = parse_rfc3339_utc(timestamp_str);
if (msg_time == (time_t)-1) {
continue; continue;
} }
/* Validate timestamp is reasonable (not in far future or past) */ /* Validate timestamp is reasonable (not in far future or past) */
time_t msg_time = mktime(&tm);
time_t now = time(NULL); time_t now = time(NULL);
if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) { if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) {
continue; continue;
@ -146,8 +199,18 @@ read_messages:;
/* Save a message to log file */ /* Save a message to log file */
int message_save(const message_t *msg) { 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) { if (!fp) {
pthread_mutex_unlock(&g_message_file_lock);
return -1; return -1;
} }
@ -180,10 +243,14 @@ int message_save(const message_t *msg) {
} }
/* Write to file: timestamp|username|content */ /* 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); fclose(fp);
return 0; pthread_mutex_unlock(&g_message_file_lock);
return rc;
} }
/* Format a message for display */ /* 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 <stdarg.h>
#include <unistd.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 */ /* Clear the screen */
void tui_clear_screen(client_t *client) { void tui_clear_screen(client_t *client) {
if (!client || !client->connected) return; if (!client || !client->connected) return;
@ -21,7 +61,8 @@ void tui_render_screen(client_t *client) {
const size_t buf_size = 65536; const size_t buf_size = 65536;
char *buffer = malloc(buf_size); char *buffer = malloc(buf_size);
if (!buffer) return; if (!buffer) return;
int pos = 0; size_t pos = 0;
buffer[0] = '\0';
/* Acquire all data in one lock to prevent TOCTOU */ /* Acquire all data in one lock to prevent TOCTOU */
pthread_rwlock_rdlock(&g_room->lock); pthread_rwlock_rdlock(&g_room->lock);
@ -66,7 +107,7 @@ void tui_render_screen(client_t *client) {
/* Now render using snapshot (no lock held) */ /* Now render using snapshot (no lock held) */
/* Move to top (Home) - Do NOT clear screen to prevent flicker */ /* 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 */ /* Title bar */
const char *mode_str = (client->mode == MODE_INSERT) ? "INSERT" : 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; int padding = client->width - title_width;
if (padding < 0) padding = 0; if (padding < 0) padding = 0;
pos += snprintf(buffer + pos, buf_size - pos, ANSI_REVERSE "%s", title); buffer_appendf(buffer, buf_size, &pos, ANSI_REVERSE "%s", title);
for (int i = 0; i < padding && pos < (int)buf_size - 4; i++) { for (int i = 0; i < padding; i++) {
buffer[pos++] = ' '; 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 */ /* Render messages from snapshot */
if (msg_snapshot) { if (msg_snapshot) {
for (int i = 0; i < snapshot_count; i++) { for (int i = 0; i < snapshot_count; i++) {
char msg_line[1024]; char msg_line[1024];
message_format(&msg_snapshot[i], msg_line, sizeof(msg_line), client->width); 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); free(msg_snapshot);
} }
/* Fill empty lines and clear them */ /* Fill empty lines and clear them */
for (int i = snapshot_count; i < msg_height; i++) { 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 */ /* Separator - use box drawing character */
for (int i = 0; i < client->width && pos < (int)buf_size - 10; i++) { for (int i = 0; i < client->width; i++) {
const char *line_char = ""; /* U+2500 box drawing, 3 bytes */ buffer_append_bytes(buffer, buf_size, &pos, "", strlen(""));
int len = strlen(line_char);
memcpy(buffer + pos, line_char, len);
pos += len;
} }
pos += snprintf(buffer + pos, buf_size - pos, "\033[K\r\n"); buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
/* Status/Input line */ /* Status/Input line */
if (client->mode == MODE_INSERT) { 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) { } else if (client->mode == MODE_NORMAL) {
int total = msg_count; int total = msg_count;
int scroll_pos = client->scroll_pos + 1; int scroll_pos = client->scroll_pos + 1;
if (total == 0) scroll_pos = 0; 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); "-- NORMAL -- (%d/%d)\033[K", scroll_pos, total);
} else if (client->mode == MODE_COMMAND) { } else if (client->mode == MODE_COMMAND) {
pos += snprintf(buffer + pos, buf_size - pos, buffer_appendf(buffer, buf_size, &pos, ":%s\033[K", client->command_input);
":%s\033[K", client->command_input);
} }
client_send(client, buffer, pos); client_send(client, buffer, pos);
@ -170,10 +207,11 @@ void tui_render_command_output(client_t *client) {
if (!client || !client->connected) return; if (!client || !client->connected) return;
char buffer[4096]; char buffer[4096];
int pos = 0; size_t pos = 0;
buffer[0] = '\0';
/* Clear screen */ /* Clear screen */
pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_CLEAR ANSI_HOME); buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */ /* Title */
const char *title = " COMMAND OUTPUT "; const char *title = " COMMAND OUTPUT ";
@ -181,11 +219,11 @@ void tui_render_command_output(client_t *client) {
int padding = client->width - title_width; int padding = client->width - title_width;
if (padding < 0) padding = 0; 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++) { 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 */ /* Command output - use a copy to avoid strtok corruption */
char output_copy[2048]; char output_copy[2048];
@ -205,7 +243,7 @@ void tui_render_command_output(client_t *client) {
utf8_truncate(truncated, client->width); 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 = strtok(NULL, "\n");
line_count++; line_count++;
} }
@ -315,10 +353,11 @@ void tui_render_help(client_t *client) {
if (!client || !client->connected) return; if (!client || !client->connected) return;
char buffer[8192]; char buffer[8192];
int pos = 0; size_t pos = 0;
buffer[0] = '\0';
/* Clear screen */ /* Clear screen */
pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_CLEAR ANSI_HOME); buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */ /* Title */
const char *title = " HELP "; const char *title = " HELP ";
@ -326,11 +365,11 @@ void tui_render_help(client_t *client) {
int padding = client->width - title_width; int padding = client->width - title_width;
if (padding < 0) padding = 0; 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++) { 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 */ /* Help content */
const char *help_text = tui_get_help_text(client->help_lang); 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; 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; int start = client->help_scroll_pos;
if (start > max_scroll) start = max_scroll;
int end = start + content_height - 1; int end = start + content_height - 1;
if (end > line_count) end = line_count; if (end > line_count) end = line_count;
for (int i = start; i < end && (i - start) < content_height - 1; i++) { 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 */ /* Fill remaining lines */
for (int i = end - start; i < content_height - 1; i++) { for (int i = end - start; i < content_height - 1; i++) {
buffer[pos++] = '\r'; buffer_append_bytes(buffer, sizeof(buffer), &pos, "\r\n", 2);
buffer[pos++] = '\n';
} }
/* Status line */ /* Status line */
int max_scroll = line_count - content_height + 1; buffer_appendf(buffer, sizeof(buffer), &pos,
if (max_scroll < 0) max_scroll = 0;
pos += snprintf(buffer + pos, sizeof(buffer) - pos,
"-- HELP -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close", "-- 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); 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; uint32_t codepoint = 0;
int len = utf8_byte_length(s[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; *bytes_read = len;
switch (len) { switch (len) {
@ -207,3 +218,32 @@ bool utf8_is_valid_sequence(const char *bytes, int len) {
return true; 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;
}

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

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

View file

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