From e473b26e0d5451558f3c902c752b9da21c751844 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 10 Mar 2026 18:52:20 +0800 Subject: [PATCH] refactor: stabilize SSH runtime and add exec interface --- README.md | 21 +- docs/ANONYMOUS_ACCESS_SUMMARY.md | 4 +- docs/DEPLOYMENT.md | 23 +- docs/EASY_SETUP.md | 6 +- include/chat_room.h | 4 + include/common.h | 8 + include/ssh_server.h | 10 +- include/utf8.h | 3 + src/chat_room.c | 98 +-- src/common.c | 86 +++ src/main.c | 28 +- src/message.c | 85 ++- src/ssh_server.c | 1155 ++++++++++++++++++++++++------ src/tui.c | 113 ++- src/utf8.c | 40 ++ tests/test_exec_mode.sh | 167 +++++ tests/test_stress.sh | 4 +- tnt.service | 5 +- 18 files changed, 1514 insertions(+), 346 deletions(-) create mode 100644 src/common.c create mode 100755 tests/test_exec_mode.sh diff --git a/README.md b/README.md index 723e1f1..2f8a95d 100644 --- a/README.md +++ b/README.md @@ -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:** @@ -202,6 +209,18 @@ 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_RATE_LIMIT=1 +TNT_SSH_LOG_LEVEL=0 +TNT_PUBLIC_HOST=chat.m1ng.space +EOF ``` ### Docker diff --git a/docs/ANONYMOUS_ACCESS_SUMMARY.md b/docs/ANONYMOUS_ACCESS_SUMMARY.md index 698e634..df736a2 100644 --- a/docs/ANONYMOUS_ACCESS_SUMMARY.md +++ b/docs/ANONYMOUS_ACCESS_SUMMARY.md @@ -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 # 输入任何内容作为密码或直接回车 # 选择显示名称(可留空) # 开始聊天! diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 331f12b..b00ed8e 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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,23 @@ 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_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 +78,9 @@ 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. + ## Firewall ```bash diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index 1108829..538f3aa 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -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**: @@ -213,7 +213,7 @@ TNT_ACCESS_TOKEN="your_secret_password" tnt tnt # 用户连接(从任何机器) -ssh -p 2222 chat.example.com +ssh -p 2222 chat.m1ng.space # 输入任意密码或直接回车 # 输入显示名称或留空 # 开始聊天! diff --git a/include/chat_room.h b/include/chat_room.h index 8e2b370..9b6c7ca 100644 --- a/include/chat_room.h +++ b/include/chat_room.h @@ -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 */ diff --git a/include/common.h b/include/common.h index 9b2b7ea..8859b12 100644 --- a/include/common.h +++ b/include/common.h @@ -7,6 +7,7 @@ #include #include #include +#include #include /* 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 */ diff --git a/include/ssh_server.h b/include/ssh_server.h index 1149934..c4fe565 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -21,11 +21,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 +47,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 */ diff --git a/include/utf8.h b/include/utf8.h index b45daa8..b86cf74 100644 --- a/include/utf8.h +++ b/include/utf8.h @@ -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 */ diff --git a/src/chat_room.c b/src/chat_room.c index b95153f..bc1343c 100644 --- a/src/chat_room.c +++ b/src/chat_room.c @@ -1,11 +1,23 @@ #include "chat_room.h" -#include "ssh_server.h" -#include "tui.h" -#include /* 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; +} diff --git a/src/common.c b/src/common.c new file mode 100644 index 0000000..b466eba --- /dev/null +++ b/src/common.c @@ -0,0 +1,86 @@ +#include "common.h" +#include +#include + +#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; +} diff --git a/src/main.c b/src/main.c index cf38110..1df5df8 100644 --- a/src/main.c +++ b/src/main.c @@ -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 */ diff --git a/src/message.c b/src/message.c index 1262643..7c5fce5 100644 --- a/src/message.c +++ b/src/message.c @@ -3,6 +3,50 @@ #include #include +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 */ diff --git a/src/ssh_server.c b/src/ssh_server.c index 5fab76e..163845a 100644 --- a/src/ssh_server.c +++ b/src/ssh_server.c @@ -11,18 +11,21 @@ #include #include #include +#include #include #include /* Global SSH bind instance */ static ssh_bind g_sshbind = NULL; +static int g_listen_port = DEFAULT_PORT; /* Session context for callback-based API */ typedef struct { char client_ip[INET6_ADDRSTRLEN]; + char requested_user[MAX_USERNAME_LEN]; int pty_width; int pty_height; - char exec_command[256]; + char exec_command[MAX_EXEC_COMMAND_LEN]; bool auth_success; int auth_attempts; bool channel_ready; /* Set when shell/exec request received */ @@ -30,10 +33,13 @@ typedef struct { struct ssh_channel_callbacks_struct *channel_cb; /* Channel callbacks */ } session_context_t; +typedef struct { + ssh_session session; +} accepted_session_t; + /* Rate limiting and connection tracking */ #define MAX_TRACKED_IPS 256 #define RATE_LIMIT_WINDOW 60 /* seconds */ -#define MAX_CONN_PER_WINDOW 10 /* connections per IP per window */ #define MAX_AUTH_FAILURES 5 /* auth failures before block */ #define BLOCK_DURATION 300 /* seconds to block after too many failures */ @@ -50,6 +56,7 @@ static ip_rate_limit_t g_rate_limits[MAX_TRACKED_IPS]; static pthread_mutex_t g_rate_limit_lock = PTHREAD_MUTEX_INITIALIZER; static int g_total_connections = 0; static pthread_mutex_t g_conn_count_lock = PTHREAD_MUTEX_INITIALIZER; +static time_t g_server_start_time = 0; /* Configuration from environment variables */ static int g_max_connections = 64; @@ -57,6 +64,47 @@ static int g_max_conn_per_ip = 5; static int g_rate_limit_enabled = 1; static char g_access_token[256] = ""; +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; + } +} + +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'; +} + /* Initialize rate limit configuration from environment */ static void init_rate_limit_config(void) { const char *env; @@ -160,7 +208,7 @@ static bool check_rate_limit(const char *ip) { /* Check connection rate */ entry->connection_count++; - if (entry->connection_count > MAX_CONN_PER_WINDOW) { + if (entry->connection_count > g_max_conn_per_ip) { entry->is_blocked = true; entry->block_until = now + BLOCK_DURATION; pthread_mutex_unlock(&g_rate_limit_lock); @@ -234,6 +282,30 @@ static void get_client_ip(ssh_session session, char *ip_buf, size_t buf_size) { ip_buf[buf_size - 1] = '\0'; } +static void sanitize_terminal_size(int *width, int *height) { + if (!width || !height) { + return; + } + + if (*width <= 0 || *width > 500) { + *width = 80; + } + if (*height <= 0 || *height > 200) { + *height = 24; + } +} + +static void format_timestamp_utc(time_t ts, char *buffer, size_t buf_size) { + struct tm tm_info; + + if (!buffer || buf_size == 0) { + return; + } + + gmtime_r(&ts, &tm_info); + strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info); +} + /* Validate username to prevent injection attacks */ static bool is_valid_username(const char *username) { if (!username || username[0] == '\0') { @@ -263,13 +335,19 @@ static bool is_valid_username(const char *username) { /* Generate or load SSH host key */ static int setup_host_key(ssh_bind sshbind) { struct stat st; + char host_key_path[PATH_MAX]; + + if (tnt_state_path(host_key_path, sizeof(host_key_path), HOST_KEY_FILE) < 0) { + fprintf(stderr, "State directory path is too long\n"); + return -1; + } /* Check if host key exists */ - if (stat(HOST_KEY_FILE, &st) == 0) { + if (stat(host_key_path, &st) == 0) { /* Validate file size */ if (st.st_size == 0) { fprintf(stderr, "Warning: Empty key file, regenerating...\n"); - unlink(HOST_KEY_FILE); + unlink(host_key_path); /* Fall through to generate new key */ } else if (st.st_size > 10 * 1024 * 1024) { /* Sanity check: key file shouldn't be > 10MB */ @@ -279,11 +357,11 @@ static int setup_host_key(ssh_bind sshbind) { /* Verify and fix permissions */ if ((st.st_mode & 0077) != 0) { fprintf(stderr, "Warning: Fixing insecure key file permissions\n"); - chmod(HOST_KEY_FILE, 0600); + chmod(host_key_path, 0600); } /* Load existing key */ - if (ssh_bind_options_set(sshbind, SSH_BIND_OPTIONS_RSAKEY, HOST_KEY_FILE) < 0) { + if (ssh_bind_options_set(sshbind, SSH_BIND_OPTIONS_RSAKEY, host_key_path) < 0) { fprintf(stderr, "Failed to load host key: %s\n", ssh_get_error(sshbind)); return -1; } @@ -300,8 +378,13 @@ static int setup_host_key(ssh_bind sshbind) { } /* Create temporary file with secure permissions (atomic operation) */ - char temp_key_file[256]; - snprintf(temp_key_file, sizeof(temp_key_file), "%s.tmp.%d", HOST_KEY_FILE, getpid()); + char temp_key_file[PATH_MAX]; + if (snprintf(temp_key_file, sizeof(temp_key_file), "%s.tmp.%d", + host_key_path, getpid()) >= (int)sizeof(temp_key_file)) { + fprintf(stderr, "Temporary key path is too long\n"); + ssh_key_free(key); + return -1; + } /* Set umask to ensure restrictive permissions before file creation */ mode_t old_umask = umask(0077); @@ -323,14 +406,14 @@ static int setup_host_key(ssh_bind sshbind) { chmod(temp_key_file, 0600); /* Atomically replace the old key file (if any) */ - if (rename(temp_key_file, HOST_KEY_FILE) < 0) { + if (rename(temp_key_file, host_key_path) < 0) { fprintf(stderr, "Failed to rename temporary key file\n"); unlink(temp_key_file); return -1; } /* Load the newly created key */ - if (ssh_bind_options_set(sshbind, SSH_BIND_OPTIONS_RSAKEY, HOST_KEY_FILE) < 0) { + if (ssh_bind_options_set(sshbind, SSH_BIND_OPTIONS_RSAKEY, host_key_path) < 0) { fprintf(stderr, "Failed to load host key: %s\n", ssh_get_error(sshbind)); return -1; } @@ -340,23 +423,38 @@ static int setup_host_key(ssh_bind sshbind) { /* Send data to client via SSH channel */ int client_send(client_t *client, const char *data, size_t len) { - if (!client || !client->connected || !client->channel) return -1; + size_t total = 0; - int sent = ssh_channel_write(client->channel, data, len); - return (sent < 0) ? -1 : 0; + if (!client || !data) return -1; + + pthread_mutex_lock(&client->io_lock); + + if (!client->connected || !client->channel) { + pthread_mutex_unlock(&client->io_lock); + return -1; + } + + while (total < len) { + int sent = ssh_channel_write(client->channel, data + total, len - total); + if (sent <= 0) { + pthread_mutex_unlock(&client->io_lock); + return -1; + } + total += (size_t)sent; + } + + pthread_mutex_unlock(&client->io_lock); + return 0; } -/* Increment client reference count - currently unused but kept for future use */ -static void client_addref(client_t *client) __attribute__((unused)); -static void client_addref(client_t *client) { +void client_addref(client_t *client) { if (!client) return; pthread_mutex_lock(&client->ref_lock); client->ref_count++; pthread_mutex_unlock(&client->ref_lock); } -/* Decrement client reference count and free if zero */ -static void client_release(client_t *client) { +void client_release(client_t *client) { if (!client) return; pthread_mutex_lock(&client->ref_lock); @@ -366,6 +464,9 @@ static void client_release(client_t *client) { if (count == 0) { /* Safe to free now */ + if (client->channel && client->channel_cb) { + ssh_remove_channel_callbacks(client->channel, client->channel_cb); + } if (client->channel) { ssh_channel_close(client->channel); ssh_channel_free(client->channel); @@ -374,6 +475,10 @@ static void client_release(client_t *client) { ssh_disconnect(client->session); ssh_free(client->session); } + if (client->channel_cb) { + free(client->channel_cb); + } + pthread_mutex_destroy(&client->io_lock); pthread_mutex_destroy(&client->ref_lock); free(client); } @@ -493,48 +598,469 @@ static int read_username(client_t *client) { return 0; } +static void trim_ascii_whitespace(char *text) { + char *start; + char *end; + + if (!text || text[0] == '\0') { + return; + } + + start = text; + while (*start && isspace((unsigned char)*start)) { + start++; + } + + if (start != text) { + memmove(text, start, strlen(start) + 1); + } + + if (text[0] == '\0') { + return; + } + + end = text + strlen(text) - 1; + while (end >= text && isspace((unsigned char)*end)) { + *end = '\0'; + end--; + } +} + +static void json_append_string(char *buffer, size_t buf_size, size_t *pos, + const char *text) { + const unsigned char *p = (const unsigned char *)(text ? text : ""); + + buffer_append_bytes(buffer, buf_size, pos, "\"", 1); + + while (*p && *pos < buf_size - 1) { + char escaped[7]; + + switch (*p) { + case '\\': + buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2); + break; + case '"': + buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2); + break; + case '\n': + buffer_append_bytes(buffer, buf_size, pos, "\\n", 2); + break; + case '\r': + buffer_append_bytes(buffer, buf_size, pos, "\\r", 2); + break; + case '\t': + buffer_append_bytes(buffer, buf_size, pos, "\\t", 2); + break; + default: + if (*p < 0x20) { + snprintf(escaped, sizeof(escaped), "\\u%04x", *p); + buffer_append_bytes(buffer, buf_size, pos, + escaped, strlen(escaped)); + } else { + buffer_append_bytes(buffer, buf_size, pos, + (const char *)p, 1); + } + break; + } + p++; + } + + buffer_append_bytes(buffer, buf_size, pos, "\"", 1); +} + +static void resolve_exec_username(const client_t *client, char *buffer, + size_t buf_size) { + if (!buffer || buf_size == 0) { + return; + } + + if (client && client->ssh_login[0] != '\0' && + is_valid_username(client->ssh_login)) { + snprintf(buffer, buf_size, "%s", client->ssh_login); + } else { + snprintf(buffer, buf_size, "%s", "anonymous"); + } + + if (utf8_strlen(buffer) > 20) { + utf8_truncate(buffer, 20); + } +} + +static int exec_command_help(client_t *client) { + static const char help_text[] = + "TNT exec interface\n" + "Commands:\n" + " help Show this help\n" + " health Print service health\n" + " users [--json] List online users\n" + " stats [--json] Print room statistics\n" + " tail [N] Print recent messages\n" + " tail -n N Print recent messages\n" + " post MESSAGE Post a message non-interactively\n" + " exit Exit successfully\n"; + + return client_send(client, help_text, sizeof(help_text) - 1) == 0 ? 0 : 1; +} + +static int exec_command_health(client_t *client) { + static const char ok[] = "ok\n"; + return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1; +} + +static int exec_command_users(client_t *client, bool json) { + int count; + char (*usernames)[MAX_USERNAME_LEN] = NULL; + char *output; + size_t output_size; + size_t pos = 0; + int rc; + + pthread_rwlock_rdlock(&g_room->lock); + count = g_room->client_count; + if (count > 0) { + usernames = calloc((size_t)count, sizeof(*usernames)); + if (!usernames) { + pthread_rwlock_unlock(&g_room->lock); + client_printf(client, "users: out of memory\n"); + return 1; + } + + for (int i = 0; i < count; i++) { + snprintf(usernames[i], MAX_USERNAME_LEN, "%s", + g_room->clients[i]->username); + } + } + pthread_rwlock_unlock(&g_room->lock); + + output_size = json ? ((size_t)count * (MAX_USERNAME_LEN * 2 + 8) + 8) + : ((size_t)count * (MAX_USERNAME_LEN + 1) + 1); + if (output_size < 8) { + output_size = 8; + } + + output = calloc(output_size, 1); + if (!output) { + free(usernames); + client_printf(client, "users: out of memory\n"); + return 1; + } + + if (json) { + buffer_append_bytes(output, output_size, &pos, "[", 1); + for (int i = 0; i < count; i++) { + if (i > 0) { + buffer_append_bytes(output, output_size, &pos, ",", 1); + } + json_append_string(output, output_size, &pos, usernames[i]); + } + buffer_append_bytes(output, output_size, &pos, "]\n", 2); + } else { + for (int i = 0; i < count; i++) { + buffer_appendf(output, output_size, &pos, "%s\n", usernames[i]); + } + } + + rc = client_send(client, output, pos) == 0 ? 0 : 1; + free(output); + free(usernames); + return rc; +} + +static int exec_command_stats(client_t *client, bool json) { + int online_users; + int message_count; + int client_capacity; + int active_connections; + time_t now = time(NULL); + long uptime_seconds; + char buffer[512]; + int len; + + pthread_rwlock_rdlock(&g_room->lock); + online_users = g_room->client_count; + message_count = g_room->message_count; + client_capacity = g_room->client_capacity; + pthread_rwlock_unlock(&g_room->lock); + + pthread_mutex_lock(&g_conn_count_lock); + active_connections = g_total_connections; + pthread_mutex_unlock(&g_conn_count_lock); + + uptime_seconds = (g_server_start_time > 0 && now >= g_server_start_time) + ? (long)(now - g_server_start_time) + : 0; + + if (json) { + len = snprintf(buffer, sizeof(buffer), + "{\"status\":\"ok\",\"online_users\":%d," + "\"message_count\":%d,\"client_capacity\":%d," + "\"active_connections\":%d,\"uptime_seconds\":%ld}\n", + online_users, message_count, client_capacity, + active_connections, uptime_seconds); + } else { + len = snprintf(buffer, sizeof(buffer), + "status ok\n" + "online_users %d\n" + "message_count %d\n" + "client_capacity %d\n" + "active_connections %d\n" + "uptime_seconds %ld\n", + online_users, message_count, client_capacity, + active_connections, uptime_seconds); + } + + if (len < 0 || len >= (int)sizeof(buffer)) { + client_printf(client, "stats: output overflow\n"); + return 1; + } + + return client_send(client, buffer, (size_t)len) == 0 ? 0 : 1; +} + +static int parse_tail_count(const char *args, int *count) { + char *end = NULL; + long value; + + if (!count) { + return -1; + } + + *count = 20; + if (!args || args[0] == '\0') { + return 0; + } + + if (strncmp(args, "-n", 2) == 0 && isspace((unsigned char)args[2])) { + args += 2; + while (*args && isspace((unsigned char)*args)) { + args++; + } + } + + value = strtol(args, &end, 10); + if (end == args) { + return -1; + } + while (*end) { + if (!isspace((unsigned char)*end)) { + return -1; + } + end++; + } + + if (value < 1 || value > MAX_MESSAGES) { + return -1; + } + + *count = (int)value; + return 0; +} + +static int exec_command_tail(client_t *client, const char *args) { + int requested = 20; + int total_messages; + int start; + int count; + message_t *snapshot = NULL; + char *output; + size_t output_size; + size_t pos = 0; + int rc; + + if (parse_tail_count(args, &requested) < 0) { + client_printf(client, "tail: usage: tail [N] | tail -n N\n"); + return 64; + } + + pthread_rwlock_rdlock(&g_room->lock); + total_messages = g_room->message_count; + start = total_messages - requested; + if (start < 0) { + start = 0; + } + count = total_messages - start; + + if (count > 0) { + snapshot = calloc((size_t)count, sizeof(message_t)); + if (!snapshot) { + pthread_rwlock_unlock(&g_room->lock); + client_printf(client, "tail: out of memory\n"); + return 1; + } + memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t)); + } + pthread_rwlock_unlock(&g_room->lock); + + output_size = (size_t)(count > 0 ? count : 1) * + (MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48); + output = calloc(output_size, 1); + if (!output) { + free(snapshot); + client_printf(client, "tail: out of memory\n"); + return 1; + } + + for (int i = 0; i < count; i++) { + char timestamp[64]; + format_timestamp_utc(snapshot[i].timestamp, timestamp, sizeof(timestamp)); + buffer_appendf(output, output_size, &pos, "%s\t%s\t%s\n", + timestamp, snapshot[i].username, snapshot[i].content); + } + + rc = client_send(client, output, pos) == 0 ? 0 : 1; + free(output); + free(snapshot); + return rc; +} + +static int exec_command_post(client_t *client, const char *args) { + char content[MAX_MESSAGE_LEN]; + char username[MAX_USERNAME_LEN]; + message_t msg = { + .timestamp = time(NULL), + }; + + if (!args || args[0] == '\0') { + client_printf(client, "post: usage: post MESSAGE\n"); + return 64; + } + + strncpy(content, args, sizeof(content) - 1); + content[sizeof(content) - 1] = '\0'; + trim_ascii_whitespace(content); + + if (content[0] == '\0') { + client_printf(client, "post: message cannot be empty\n"); + return 64; + } + + if (!utf8_is_valid_string(content)) { + client_printf(client, "post: invalid UTF-8 input\n"); + return 1; + } + + resolve_exec_username(client, username, sizeof(username)); + + strncpy(msg.username, username, sizeof(msg.username) - 1); + msg.username[sizeof(msg.username) - 1] = '\0'; + strncpy(msg.content, content, sizeof(msg.content) - 1); + msg.content[sizeof(msg.content) - 1] = '\0'; + + room_broadcast(g_room, &msg); + if (message_save(&msg) < 0) { + client_printf(client, "post: failed to persist message\n"); + return 1; + } + + return client_send(client, "posted\n", 7) == 0 ? 0 : 1; +} + +static int execute_exec_command(client_t *client) { + char command_copy[MAX_EXEC_COMMAND_LEN]; + char *cmd; + char *args; + + strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1); + command_copy[sizeof(command_copy) - 1] = '\0'; + trim_ascii_whitespace(command_copy); + + cmd = command_copy; + if (*cmd == '\0') { + return exec_command_help(client); + } + + args = cmd; + while (*args && !isspace((unsigned char)*args)) { + args++; + } + if (*args) { + *args++ = '\0'; + while (*args && isspace((unsigned char)*args)) { + args++; + } + } else { + args = NULL; + } + + if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0) { + return exec_command_help(client); + } + if (strcmp(cmd, "health") == 0) { + return exec_command_health(client); + } + if (strcmp(cmd, "users") == 0) { + if (args && strcmp(args, "--json") != 0) { + client_printf(client, "users: usage: users [--json]\n"); + return 64; + } + return exec_command_users(client, args != NULL); + } + if (strcmp(cmd, "stats") == 0) { + if (args && strcmp(args, "--json") != 0) { + client_printf(client, "stats: usage: stats [--json]\n"); + return 64; + } + return exec_command_stats(client, args != NULL); + } + if (strcmp(cmd, "tail") == 0) { + return exec_command_tail(client, args); + } + if (strcmp(cmd, "post") == 0) { + return exec_command_post(client, args); + } + if (strcmp(cmd, "exit") == 0) { + return 0; + } + + client_printf(client, "Unknown command: %s\n", cmd); + return 64; +} + /* Execute a command */ static void execute_command(client_t *client) { char *cmd = client->command_input; char output[2048] = {0}; - int pos = 0; + size_t pos = 0; /* Trim whitespace */ while (*cmd == ' ') cmd++; - char *end = cmd + strlen(cmd) - 1; - while (end > cmd && *end == ' ') { - *end = '\0'; - end--; + size_t cmd_len = strlen(cmd); + if (cmd_len > 0) { + char *end = cmd + cmd_len - 1; + while (end > cmd && *end == ' ') { + *end = '\0'; + end--; + } } if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 || strcmp(cmd, "who") == 0) { - pos += snprintf(output + pos, sizeof(output) - pos, + buffer_appendf(output, sizeof(output), &pos, "========================================\n" " Online Users / 在线用户\n" "========================================\n"); pthread_rwlock_rdlock(&g_room->lock); - pos += snprintf(output + pos, sizeof(output) - pos, + buffer_appendf(output, sizeof(output), &pos, "Total / 总数: %d\n" "----------------------------------------\n", g_room->client_count); for (int i = 0; i < g_room->client_count; i++) { char marker = (g_room->clients[i] == client) ? '*' : ' '; - pos += snprintf(output + pos, sizeof(output) - pos, + buffer_appendf(output, sizeof(output), &pos, "%c %d. %s\n", marker, i + 1, g_room->clients[i]->username); } pthread_rwlock_unlock(&g_room->lock); - pos += snprintf(output + pos, sizeof(output) - pos, + buffer_appendf(output, sizeof(output), &pos, "========================================\n" "* = you / 你\n"); } else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "commands") == 0) { - pos += snprintf(output + pos, sizeof(output) - pos, + buffer_appendf(output, sizeof(output), &pos, "========================================\n" " Available Commands\n" "========================================\n" @@ -544,8 +1070,7 @@ static void execute_command(client_t *client) { "========================================\n"); } else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) { - pos += snprintf(output + pos, sizeof(output) - pos, - "Command output cleared\n"); + buffer_appendf(output, sizeof(output), &pos, "Command output cleared\n"); } else if (cmd[0] == '\0') { /* Empty command */ @@ -555,15 +1080,15 @@ static void execute_command(client_t *client) { return; } else { - pos += snprintf(output + pos, sizeof(output) - pos, + buffer_appendf(output, sizeof(output), &pos, "Unknown command: %s\n" "Type 'help' for available commands\n", cmd); } - pos += snprintf(output + pos, sizeof(output) - pos, + buffer_appendf(output, sizeof(output), &pos, "\nPress any key to continue..."); - strncpy(client->command_output, output, sizeof(client->command_output) - 1); + snprintf(client->command_output, sizeof(client->command_output), "%s", output); client->command_input[0] = '\0'; tui_render_command_output(client); } @@ -634,8 +1159,8 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { message_t msg = { .timestamp = time(NULL), }; - strncpy(msg.username, client->username, MAX_USERNAME_LEN - 1); - strncpy(msg.content, input, MAX_MESSAGE_LEN - 1); + snprintf(msg.username, sizeof(msg.username), "%s", client->username); + snprintf(msg.content, sizeof(msg.content), "%s", input); room_broadcast(g_room, &msg); message_save(&msg); input[0] = '\0'; @@ -755,6 +1280,9 @@ void* client_handle_session(void *arg) { client_t *client = (client_t*)arg; char input[MAX_MESSAGE_LEN] = {0}; char buf[4]; + bool joined_room = false; + uint64_t seen_update_seq; + time_t last_keepalive = time(NULL); /* Terminal size already set from PTY request */ client->mode = MODE_INSERT; @@ -763,16 +1291,9 @@ void* client_handle_session(void *arg) { /* Check for exec command */ if (client->exec_command[0] != '\0') { - if (strcmp(client->exec_command, "exit") == 0) { - /* Just exit */ - ssh_channel_request_send_exit_status(client->channel, 0); - goto cleanup; - } else { - /* Unknown command */ - client_printf(client, "Command not supported: %s\r\nOnly 'exit' is supported in non-interactive mode.\r\n", client->exec_command); - ssh_channel_request_send_exit_status(client->channel, 1); - goto cleanup; - } + int exit_status = execute_exec_command(client); + ssh_channel_request_send_exit_status(client->channel, exit_status); + goto cleanup; } /* Read username */ @@ -785,6 +1306,7 @@ void* client_handle_session(void *arg) { client_printf(client, "Room is full\n"); goto cleanup; } + joined_room = true; /* Broadcast join message */ message_t join_msg = { @@ -797,31 +1319,62 @@ void* client_handle_session(void *arg) { /* Render initial screen */ tui_render_screen(client); + seen_update_seq = room_get_update_seq(g_room); /* Main input loop */ while (client->connected && ssh_channel_is_open(client->channel)) { - /* Use non-blocking read with timeout */ - int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 30000); /* 30 sec timeout */ + int ready = ssh_channel_poll_timeout(client->channel, 1000, 0); - if (n == SSH_AGAIN) { - /* Timeout - send keepalive to prevent NAT/firewall timeout */ - if (!ssh_channel_is_open(client->channel) || - ssh_send_keepalive(client->session) != SSH_OK) { + if (ready == SSH_ERROR) { + break; + } + + if (ready == 0) { + bool room_updated = false; + uint64_t current_update_seq = room_get_update_seq(g_room); + + if (!ssh_channel_is_open(client->channel)) { break; } + + if (current_update_seq != seen_update_seq) { + seen_update_seq = current_update_seq; + room_updated = true; + } + + if (client->redraw_pending || + (room_updated && !client->show_help && + client->command_output[0] == '\0')) { + client->redraw_pending = false; + + if (client->show_help) { + tui_render_help(client); + } else if (client->command_output[0] != '\0') { + tui_render_command_output(client); + } else { + tui_render_screen(client); + if (client->mode == MODE_INSERT && input[0] != '\0') { + tui_render_input(client, input); + } + } + } else if (time(NULL) - last_keepalive >= 15) { + if (ssh_send_keepalive(client->session) != SSH_OK) { + break; + } + last_keepalive = time(NULL); + } continue; } - if (n == SSH_ERROR) { - /* Read error - connection likely closed */ - break; - } + int n = ssh_channel_read(client->channel, buf, 1, 0); if (n <= 0) { /* EOF or error */ break; } + last_keepalive = time(NULL); + unsigned char b = buf[0]; /* Handle special keys - returns true if key was consumed */ @@ -881,7 +1434,7 @@ void* client_handle_session(void *arg) { cleanup: /* Broadcast leave message */ - { + if (joined_room) { message_t leave_msg = { .timestamp = time(NULL), }; @@ -908,9 +1461,13 @@ cleanup: /* Password authentication callback */ static int auth_password(ssh_session session, const char *user, const char *password, void *userdata) { - (void)user; /* Unused - we don't validate usernames */ session_context_t *ctx = (session_context_t *)userdata; + if (user && user[0] != '\0') { + strncpy(ctx->requested_user, user, sizeof(ctx->requested_user) - 1); + ctx->requested_user[sizeof(ctx->requested_user) - 1] = '\0'; + } + ctx->auth_attempts++; /* Limit auth attempts */ @@ -943,9 +1500,13 @@ static int auth_password(ssh_session session, const char *user, /* Passwordless (none) authentication callback */ static int auth_none(ssh_session session, const char *user, void *userdata) { (void)session; /* Unused */ - (void)user; /* Unused */ session_context_t *ctx = (session_context_t *)userdata; + if (user && user[0] != '\0') { + strncpy(ctx->requested_user, user, sizeof(ctx->requested_user) - 1); + ctx->requested_user[sizeof(ctx->requested_user) - 1] = '\0'; + } + /* If access token is configured, reject passwordless */ if (g_access_token[0] != '\0') { return SSH_AUTH_DENIED; @@ -956,8 +1517,62 @@ static int auth_none(ssh_session session, const char *user, void *userdata) { } } -/* Forward declaration of channel callbacks setup */ -static void setup_channel_callbacks(ssh_channel channel, session_context_t *ctx); +/* Public key authentication callback */ +static int auth_pubkey(ssh_session session, const char *user, + struct ssh_key_struct *pubkey, char signature_state, + void *userdata) { + (void)session; /* Unused */ + (void)pubkey; /* Unused */ + (void)signature_state; /* Unused in anonymous mode */ + session_context_t *ctx = (session_context_t *)userdata; + + if (user && user[0] != '\0') { + strncpy(ctx->requested_user, user, sizeof(ctx->requested_user) - 1); + ctx->requested_user[sizeof(ctx->requested_user) - 1] = '\0'; + } + + if (g_access_token[0] != '\0') { + return SSH_AUTH_DENIED; + } + + ctx->auth_success = true; + return SSH_AUTH_SUCCESS; +} + +static void destroy_session_context(session_context_t *ctx) { + if (!ctx) { + return; + } + + if (ctx->channel_cb) { + free(ctx->channel_cb); + } + + free(ctx); +} + +static void cleanup_failed_session(ssh_session session, session_context_t *ctx) { + if (ctx && ctx->channel) { + if (ctx->channel_cb) { + ssh_remove_channel_callbacks(ctx->channel, ctx->channel_cb); + } + ssh_channel_close(ctx->channel); + ssh_channel_free(ctx->channel); + ctx->channel = NULL; + } + + if (session) { + ssh_disconnect(session); + ssh_free(session); + } + + destroy_session_context(ctx); + decrement_connections(); +} + +static void setup_session_channel_callbacks(ssh_channel channel, + session_context_t *ctx); +static int install_client_channel_callbacks(client_t *client); /* Channel open callback */ static ssh_channel channel_open_request_session(ssh_session session, void *userdata) { @@ -973,7 +1588,7 @@ static ssh_channel channel_open_request_session(ssh_session session, void *userd ctx->channel = channel; /* Set up channel-specific callbacks (PTY, shell, exec) */ - setup_channel_callbacks(channel, ctx); + setup_session_channel_callbacks(channel, ctx); return channel; } @@ -996,9 +1611,25 @@ static int channel_pty_request(ssh_session session, ssh_channel channel, ctx->pty_width = width; ctx->pty_height = height; - /* Default to 80x24 if invalid */ - if (ctx->pty_width <= 0 || ctx->pty_width > 500) ctx->pty_width = 80; - if (ctx->pty_height <= 0 || ctx->pty_height > 200) ctx->pty_height = 24; + sanitize_terminal_size(&ctx->pty_width, &ctx->pty_height); + + return SSH_OK; +} + +static int channel_pty_window_change(ssh_session session, ssh_channel channel, + int width, int height, + int pxwidth, int pxheight, + void *userdata) { + (void)session; + (void)channel; + (void)pxwidth; + (void)pxheight; + + session_context_t *ctx = (session_context_t *)userdata; + + ctx->pty_width = width; + ctx->pty_height = height; + sanitize_terminal_size(&ctx->pty_width, &ctx->pty_height); return SSH_OK; } @@ -1039,7 +1670,8 @@ static int channel_exec_request(ssh_session session, ssh_channel channel, } /* Set up channel callbacks */ -static void setup_channel_callbacks(ssh_channel channel, session_context_t *ctx) { +static void setup_session_channel_callbacks(ssh_channel channel, + session_context_t *ctx) { /* Allocate channel callbacks on heap to persist */ ctx->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct)); if (!ctx->channel_cb) { @@ -1051,15 +1683,235 @@ static void setup_channel_callbacks(ssh_channel channel, session_context_t *ctx) ctx->channel_cb->userdata = ctx; ctx->channel_cb->channel_pty_request_function = channel_pty_request; ctx->channel_cb->channel_shell_request_function = channel_shell_request; + ctx->channel_cb->channel_pty_window_change_function = channel_pty_window_change; ctx->channel_cb->channel_exec_request_function = channel_exec_request; ssh_set_channel_callbacks(channel, ctx->channel_cb); } +static int client_channel_window_change(ssh_session session, ssh_channel channel, + int width, int height, + int pxwidth, int pxheight, + void *userdata) { + (void)session; + (void)channel; + (void)pxwidth; + (void)pxheight; + + client_t *client = (client_t *)userdata; + if (!client) { + return SSH_ERROR; + } + + client->width = width; + client->height = height; + sanitize_terminal_size(&client->width, &client->height); + client->redraw_pending = true; + return SSH_OK; +} + +static void client_channel_eof(ssh_session session, ssh_channel channel, + void *userdata) { + (void)session; + (void)channel; + + client_t *client = (client_t *)userdata; + if (client) { + client->connected = false; + } +} + +static void client_channel_close(ssh_session session, ssh_channel channel, + void *userdata) { + (void)session; + (void)channel; + + client_t *client = (client_t *)userdata; + if (client) { + client->connected = false; + } +} + +static int install_client_channel_callbacks(client_t *client) { + if (!client || !client->channel) { + return -1; + } + + client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct)); + if (!client->channel_cb) { + return -1; + } + + ssh_callbacks_init(client->channel_cb); + client->channel_cb->userdata = client; + client->channel_cb->channel_eof_function = client_channel_eof; + client->channel_cb->channel_close_function = client_channel_close; + client->channel_cb->channel_pty_window_change_function = + client_channel_window_change; + + if (ssh_set_channel_callbacks(client->channel, client->channel_cb) != SSH_OK) { + free(client->channel_cb); + client->channel_cb = NULL; + return -1; + } + + return 0; +} + +static void *bootstrap_client_session(void *arg) { + accepted_session_t *accepted = (accepted_session_t *)arg; + ssh_session session; + session_context_t *ctx = NULL; + ssh_event event = NULL; + struct ssh_server_callbacks_struct server_cb; + ssh_channel channel; + client_t *client = NULL; + bool timed_out = false; + time_t start_time; + + if (!accepted) { + return NULL; + } + + session = accepted->session; + free(accepted); + + ctx = calloc(1, sizeof(session_context_t)); + if (!ctx) { + ssh_disconnect(session); + ssh_free(session); + decrement_connections(); + return NULL; + } + + get_client_ip(session, ctx->client_ip, sizeof(ctx->client_ip)); + ctx->pty_width = 80; + ctx->pty_height = 24; + ctx->exec_command[0] = '\0'; + ctx->requested_user[0] = '\0'; + ctx->auth_success = false; + ctx->auth_attempts = 0; + ctx->channel_ready = false; + ctx->channel = NULL; + ctx->channel_cb = NULL; + + memset(&server_cb, 0, sizeof(server_cb)); + ssh_callbacks_init(&server_cb); + server_cb.userdata = ctx; + server_cb.auth_password_function = auth_password; + server_cb.auth_none_function = auth_none; + server_cb.auth_pubkey_function = auth_pubkey; + server_cb.channel_open_request_session_function = channel_open_request_session; + ssh_set_server_callbacks(session, &server_cb); + + if (ssh_handle_key_exchange(session) != SSH_OK) { + fprintf(stderr, "Key exchange failed from %s: %s\n", + ctx->client_ip, ssh_get_error(session)); + cleanup_failed_session(session, ctx); + return NULL; + } + + event = ssh_event_new(); + if (!event) { + fprintf(stderr, "Failed to create SSH event for %s\n", ctx->client_ip); + cleanup_failed_session(session, ctx); + return NULL; + } + + if (ssh_event_add_session(event, session) != SSH_OK) { + fprintf(stderr, "Failed to add session to event loop for %s\n", + ctx->client_ip); + ssh_event_free(event); + cleanup_failed_session(session, ctx); + return NULL; + } + + start_time = time(NULL); + while ((!ctx->auth_success || ctx->channel == NULL || !ctx->channel_ready) && + !timed_out) { + int rc = ssh_event_dopoll(event, 1000); + + if (rc == SSH_ERROR) { + fprintf(stderr, "Event poll error from %s: %s\n", + ctx->client_ip, ssh_get_error(session)); + break; + } + + if (time(NULL) - start_time > 30) { + timed_out = true; + } + } + + ssh_event_free(event); + event = NULL; + + if (!ctx->auth_success) { + fprintf(stderr, "Authentication failed or timed out from %s\n", + ctx->client_ip); + cleanup_failed_session(session, ctx); + return NULL; + } + + channel = ctx->channel; + if (!channel || !ctx->channel_ready || timed_out) { + fprintf(stderr, "Failed to open/setup channel from %s\n", + ctx->client_ip); + cleanup_failed_session(session, ctx); + return NULL; + } + + client = calloc(1, sizeof(client_t)); + if (!client) { + cleanup_failed_session(session, ctx); + return NULL; + } + + client->session = session; + client->channel = channel; + client->fd = -1; + client->width = ctx->pty_width; + client->height = ctx->pty_height; + sanitize_terminal_size(&client->width, &client->height); + client->ref_count = 1; + pthread_mutex_init(&client->ref_lock, NULL); + pthread_mutex_init(&client->io_lock, NULL); + + if (ctx->requested_user[0] != '\0') { + strncpy(client->ssh_login, ctx->requested_user, + sizeof(client->ssh_login) - 1); + client->ssh_login[sizeof(client->ssh_login) - 1] = '\0'; + } + if (ctx->exec_command[0] != '\0') { + strncpy(client->exec_command, ctx->exec_command, + sizeof(client->exec_command) - 1); + client->exec_command[sizeof(client->exec_command) - 1] = '\0'; + } + + if (install_client_channel_callbacks(client) < 0) { + pthread_mutex_destroy(&client->io_lock); + pthread_mutex_destroy(&client->ref_lock); + free(client); + cleanup_failed_session(session, ctx); + return NULL; + } + + if (ctx->channel_cb) { + ssh_remove_channel_callbacks(channel, ctx->channel_cb); + free(ctx->channel_cb); + ctx->channel_cb = NULL; + } + destroy_session_context(ctx); + + client_handle_session(client); + return NULL; +} + /* Initialize SSH server */ int ssh_server_init(int port) { /* Initialize rate limiting configuration */ init_rate_limit_config(); + g_listen_port = port; + g_server_start_time = time(NULL); g_sshbind = ssh_bind_new(); if (!g_sshbind) { @@ -1106,12 +1958,25 @@ int ssh_server_init(int port) { /* Start SSH server (blocking) */ int ssh_server_start(int unused) { (void)unused; + const char *public_host = getenv("TNT_PUBLIC_HOST"); + pthread_attr_t attr; + if (!public_host || public_host[0] == '\0') { + public_host = "localhost"; + } - printf("TNT chat server listening on port %d (SSH)\n", DEFAULT_PORT); - printf("Connect with: ssh -p %d localhost\n", DEFAULT_PORT); + printf("TNT chat server listening on port %d (SSH)\n", g_listen_port); + printf("Connect with: ssh -p %d %s\n", g_listen_port, public_host); + fflush(stdout); + + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); while (1) { ssh_session session = ssh_new(); + char client_ip[INET6_ADDRSTRLEN]; + accepted_session_t *accepted; + pthread_t thread; + if (!session) { fprintf(stderr, "Failed to create SSH session\n"); continue; @@ -1124,176 +1989,42 @@ int ssh_server_start(int unused) { continue; } - /* Create session context for callbacks */ - session_context_t *ctx = calloc(1, sizeof(session_context_t)); - if (!ctx) { - ssh_disconnect(session); - ssh_free(session); - continue; - } - - /* Initialize context */ - get_client_ip(session, ctx->client_ip, sizeof(ctx->client_ip)); - ctx->pty_width = 80; /* Default */ - ctx->pty_height = 24; /* Default */ - ctx->exec_command[0] = '\0'; - ctx->auth_success = false; - ctx->auth_attempts = 0; - ctx->channel_ready = false; - ctx->channel = NULL; - ctx->channel_cb = NULL; + get_client_ip(session, client_ip, sizeof(client_ip)); /* Check rate limit */ - if (!check_rate_limit(ctx->client_ip)) { + if (!check_rate_limit(client_ip)) { ssh_disconnect(session); ssh_free(session); - free(ctx); continue; } /* Check total connection limit */ if (!check_and_increment_connections()) { - fprintf(stderr, "Max connections reached, rejecting %s\n", ctx->client_ip); + fprintf(stderr, "Max connections reached, rejecting %s\n", client_ip); ssh_disconnect(session); ssh_free(session); - free(ctx); continue; } - - /* Set up server callbacks (auth and channel) */ - struct ssh_server_callbacks_struct server_cb; - memset(&server_cb, 0, sizeof(server_cb)); - ssh_callbacks_init(&server_cb); - - server_cb.userdata = ctx; - server_cb.auth_password_function = auth_password; - server_cb.auth_none_function = auth_none; - server_cb.channel_open_request_session_function = channel_open_request_session; - - ssh_set_server_callbacks(session, &server_cb); - - /* Perform key exchange */ - if (ssh_handle_key_exchange(session) != SSH_OK) { - fprintf(stderr, "Key exchange failed: %s\n", ssh_get_error(session)); + accepted = calloc(1, sizeof(*accepted)); + if (!accepted) { decrement_connections(); ssh_disconnect(session); ssh_free(session); - free(ctx); continue; } - /* Event loop to handle authentication and channel setup */ - ssh_event event = ssh_event_new(); - if (event == NULL) { - fprintf(stderr, "Failed to create event\n"); - decrement_connections(); - ssh_disconnect(session); - ssh_free(session); - free(ctx); - continue; - } + accepted->session = session; - ssh_event_add_session(event, session); - - /* Wait for: auth success, channel open, AND channel ready (PTY/shell/exec) */ - int timeout_sec = 30; - time_t start_time = time(NULL); - bool timed_out = false; - ssh_channel channel = NULL; - - while ((!ctx->auth_success || ctx->channel == NULL || !ctx->channel_ready) && !timed_out) { - /* Poll with 1 second timeout per iteration */ - int rc = ssh_event_dopoll(event, 1000); - - if (rc == SSH_ERROR) { - fprintf(stderr, "Event poll error: %s\n", ssh_get_error(session)); - break; - } - - /* Check timeout */ - if (time(NULL) - start_time > timeout_sec) { - timed_out = true; - } - } - - ssh_event_free(event); - - /* Check if authentication succeeded */ - if (!ctx->auth_success) { - fprintf(stderr, "Authentication failed or timed out from %s\n", ctx->client_ip); - decrement_connections(); - ssh_disconnect(session); - ssh_free(session); - if (ctx->channel_cb) free(ctx->channel_cb); - free(ctx); - continue; - } - - /* Check if channel opened and is ready */ - channel = ctx->channel; - if (!channel || !ctx->channel_ready || timed_out) { - fprintf(stderr, "Failed to open/setup channel from %s\n", ctx->client_ip); - decrement_connections(); - ssh_disconnect(session); - ssh_free(session); - if (ctx->channel_cb) free(ctx->channel_cb); - free(ctx); - continue; - } - - /* Create client structure */ - client_t *client = calloc(1, sizeof(client_t)); - if (!client) { - ssh_channel_close(channel); - ssh_channel_free(channel); - ssh_disconnect(session); - ssh_free(session); - free(ctx); - continue; - } - - /* Initialize client from context */ - client->session = session; - client->channel = channel; - client->fd = -1; /* Not used with SSH */ - client->width = ctx->pty_width; - client->height = ctx->pty_height; - client->ref_count = 1; /* Initial reference */ - pthread_mutex_init(&client->ref_lock, NULL); - - /* Copy exec command if any */ - if (ctx->exec_command[0] != '\0') { - strncpy(client->exec_command, ctx->exec_command, sizeof(client->exec_command) - 1); - client->exec_command[sizeof(client->exec_command) - 1] = '\0'; - } - - /* Free context and channel callbacks - no longer needed */ - if (ctx->channel_cb) free(ctx->channel_cb); - free(ctx); - - /* Create thread for client */ - pthread_t thread; - pthread_attr_t attr; - - /* Initialize thread attributes for detached thread */ - pthread_attr_init(&attr); - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - - if (pthread_create(&thread, &attr, client_handle_session, client) != 0) { + if (pthread_create(&thread, &attr, bootstrap_client_session, accepted) != 0) { fprintf(stderr, "Thread creation failed: %s\n", strerror(errno)); - pthread_attr_destroy(&attr); - /* Clean up all resources */ - pthread_mutex_destroy(&client->ref_lock); - ssh_channel_close(channel); - ssh_channel_free(channel); + free(accepted); + decrement_connections(); ssh_disconnect(session); ssh_free(session); - free(client); continue; } - - pthread_attr_destroy(&attr); } + pthread_attr_destroy(&attr); return 0; } diff --git a/src/tui.c b/src/tui.c index b9d5198..75fabf9 100644 --- a/src/tui.c +++ b/src/tui.c @@ -5,6 +5,46 @@ #include #include +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); } diff --git a/src/utf8.c b/src/utf8.c index b87c196..d0af230 100644 --- a/src/utf8.c +++ b/src/utf8.c @@ -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; +} diff --git a/tests/test_exec_mode.sh b/tests/test_exec_mode.sh new file mode 100755 index 0000000..d43a046 --- /dev/null +++ b/tests/test_exec_mode.sh @@ -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" <"${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" diff --git a/tests/test_stress.sh b/tests/test_stress.sh index 949896f..2b0f1e5 100755 --- a/tests/test_stress.sh +++ b/tests/test_stress.sh @@ -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 $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." diff --git a/tnt.service b/tnt.service index 1d311e4..2c74027 100644 --- a/tnt.service +++ b/tnt.service @@ -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