Compare commits

..

10 commits

Author SHA1 Message Date
c7fa162bff
Merge pull request #33 from m1ngsama/feat/consolidated-features-manpage-deploy
Some checks are pending
CI / build-and-test (macos-latest) (push) Waiting to run
CI / build-and-test (ubuntu-latest) (push) Waiting to run
Deploy / test (push) Waiting to run
Deploy / deploy (push) Blocked by required conditions
Consolidated: bug fixes, features, manpage, deploy prep
2026-04-19 17:50:14 +08:00
e10b43074c feat: consolidated improvements, manpage, and deployment prep
Bug fixes:
- Fix data race on client->width/height (now _Atomic int)
- Persist join/leave system messages via message_save()
- Make room_add_message static to enforce lock contract
- Fix execute_command mutating command_input directly
- Increase help_copy buffer from 4096 to 8192 for CJK safety

New features:
- Add :msg/:w whisper command for private messaging
- Add command history with UP/DOWN arrows in command mode
- Add Ctrl+D/U/F/B page scrolling in normal mode
- Add :q/:quit/:exit Vim-style disconnect

Unix community:
- Add tnt.1 manpage (roff format) with full documentation
- Add manpage install/uninstall to Makefile
2026-04-19 17:49:06 +08:00
200e5a2f28
Merge pull request #22 from m1ngsama/feat/expand-unit-tests
Add chat_room unit tests and integrate into CI
2026-04-19 17:39:25 +08:00
65cb5d79d7
Merge pull request #24 from m1ngsama/fix/input-handling-and-auth-hardening
Fix CJK input handling and reduce auth timeout
2026-04-19 17:39:00 +08:00
83e964028a
Merge pull request #20 from m1ngsama/fix/edge-cases-and-robustness
Fix edge cases in message loading and network I/O
2026-04-19 17:38:51 +08:00
ecaff81384
Merge pull request #16 from m1ngsama/fix/memory-safety-and-input-bugs
Fix memory safety bugs and timing side-channel
2026-04-19 17:38:39 +08:00
9607d8c2f2 fix: CJK backspace display, UTF-8 in command mode, auth timeout
- Fix backspace in read_username to erase correct display width for
  CJK/wide characters (was erasing only 1 column for 2-column chars)
- Add UTF-8 multi-byte input support in COMMAND mode (was silently
  dropping non-ASCII bytes, breaking CJK command arguments)
- Reduce SSH auth timeout from 30s to 10s to limit connection-slot
  exhaustion from slow/malicious handshakes
2026-04-19 16:19:43 +08:00
ecc45f285c test: add chat_room unit tests and integrate into build
- Add 11 unit tests for chat_room.c covering: create/destroy, message
  add/overflow, broadcast sequence, get_message bounds, client
  add/remove/capacity, and null argument handling
- Add unit-test target to root Makefile so `make test` runs unit tests
  before integration tests
- Add common.c to unit test link dependencies (needed for tnt_state_path)
- Guard _DARWIN_C_SOURCE define to prevent -Wmacro-redefined warning
2026-04-19 15:22:01 +08:00
8be6476367 fix: harden edge cases in message loading and network I/O
- Check ftell() return for errors (-1) in message_load to prevent
  corrupted backward scan on I/O failures
- Cap ssh_channel_write chunks to 32KB to prevent size_t-to-uint32_t
  narrowing on large buffers
- Log evicted active connection count in rate-limit table overflow
  warning for better diagnostics
2026-04-19 15:18:09 +08:00
9bbd5acd15 fix: resolve memory safety bugs and timing side-channel
- Fix use-after-free/double-free on install_client_channel_callbacks
  failure: nullify session/channel ownership before releasing refs so
  cleanup_failed_session does not double-free resources
- Fix constant_time_strcmp to always iterate over the full secret length,
  preventing timing leak of token length
- Fix data race on client->width/height by protecting window-change
  callback writes with io_lock
- Fix potential UTF-8 mid-sequence truncation in tui_render_input by
  sizing display buffer to MAX_MESSAGE_LEN
2026-04-19 14:08:31 +08:00
10 changed files with 671 additions and 81 deletions

View file

@ -45,9 +45,12 @@ clean:
install: $(TARGET)
install -d $(DESTDIR)/usr/local/bin
install -m 755 $(TARGET) $(DESTDIR)/usr/local/bin/
install -d $(DESTDIR)/usr/local/share/man/man1
install -m 644 tnt.1 $(DESTDIR)/usr/local/share/man/man1/
uninstall:
rm -f $(DESTDIR)/usr/local/bin/$(TARGET)
rm -f $(DESTDIR)/usr/local/share/man/man1/tnt.1
# Development targets
debug: CFLAGS += -g -DDEBUG
@ -71,10 +74,14 @@ check:
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
# Test
test: all
@echo "Running tests..."
test: all unit-test
@echo "Running integration tests..."
@cd tests && ./test_basic.sh
unit-test:
@echo "Running unit tests..."
@$(MAKE) -C tests/unit run
# Show build info
info:
@echo "Compiler: $(CC)"

View file

@ -36,9 +36,6 @@ void room_remove_client(chat_room_t *room, struct client *client);
/* Broadcast message to all clients */
void room_broadcast(chat_room_t *room, const message_t *msg);
/* Add message to room history */
void room_add_message(chat_room_t *room, const message_t *msg);
/* Get message by index (thread-safe value copy) */
bool room_get_message(chat_room_t *room, int index, message_t *out);

View file

@ -13,14 +13,17 @@ typedef struct client {
ssh_channel channel; /* SSH channel */
char username[MAX_USERNAME_LEN];
char client_ip[INET6_ADDRSTRLEN];
int width;
int height;
_Atomic int width;
_Atomic int height;
client_mode_t mode;
help_lang_t help_lang;
int scroll_pos;
int help_scroll_pos;
bool show_help;
char command_input[256];
char command_history[16][256];
int command_history_count;
int command_history_pos;
char command_output[2048];
char exec_command[MAX_EXEC_COMMAND_LEN];
char ssh_login[MAX_USERNAME_LEN];

View file

@ -87,23 +87,9 @@ void room_remove_client(chat_room_t *room, struct client *client) {
pthread_rwlock_unlock(&room->lock);
}
/* Broadcast message to all clients */
void room_broadcast(chat_room_t *room, const message_t *msg) {
pthread_rwlock_wrlock(&room->lock);
/* Add to history */
room_add_message(room, msg);
room->update_seq++;
pthread_rwlock_unlock(&room->lock);
}
/* Add message to room history */
void room_add_message(chat_room_t *room, const message_t *msg) {
/* Caller should hold write lock */
/* Add message to room history (caller must hold write lock) */
static void room_add_message(chat_room_t *room, const message_t *msg) {
if (room->message_count >= MAX_MESSAGES) {
/* Shift messages to make room */
memmove(&room->messages[0], &room->messages[1],
(MAX_MESSAGES - 1) * sizeof(message_t));
room->message_count = MAX_MESSAGES - 1;
@ -112,6 +98,16 @@ void room_add_message(chat_room_t *room, const message_t *msg) {
room->messages[room->message_count++] = *msg;
}
/* Broadcast message to all clients */
void room_broadcast(chat_room_t *room, const message_t *msg) {
pthread_rwlock_wrlock(&room->lock);
room_add_message(room, msg);
room->update_seq++;
pthread_rwlock_unlock(&room->lock);
}
/* Get message by index (thread-safe value copy) */
bool room_get_message(chat_room_t *room, int index, message_t *out) {
if (!room || !out) return false;

View file

@ -1,7 +1,7 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE /* for timegm() on glibc */
#endif
#ifdef __APPLE__
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE /* for timegm() on macOS */
#endif
#include "message.h"
@ -61,7 +61,7 @@ int message_load(message_t **messages, int max_messages) {
}
long file_size = ftell(fp);
if (file_size == 0) {
if (file_size <= 0) {
fclose(fp);
*messages = msg_array;
return 0;

View file

@ -108,16 +108,17 @@ static void buffer_append_bytes(char *buffer, size_t buf_size, size_t *pos,
buffer[*pos] = '\0';
}
/* Constant-time string comparison to prevent timing side-channel attacks */
/* Constant-time string comparison to prevent timing side-channel attacks.
* Always iterates over the full length of the secret (b) to avoid leaking
* its length. When the input (a) is shorter, compares against zero bytes;
* the length mismatch is folded into the result separately. */
static bool constant_time_strcmp(const char *a, const char *b) {
size_t len_a = strlen(a);
size_t len_b = strlen(b);
/* Use len_b (the secret) for iteration to avoid leaking its length
* through early termination. The XOR of lengths catches mismatches. */
volatile unsigned char result = (unsigned char)(len_a ^ len_b);
size_t len = (len_a < len_b) ? len_a : len_b;
for (size_t i = 0; i < len; i++) {
result |= (unsigned char)((unsigned char)a[i] ^ (unsigned char)b[i]);
for (size_t i = 0; i < len_b; i++) {
unsigned char ca = (i < len_a) ? (unsigned char)a[i] : 0;
result |= ca ^ (unsigned char)b[i];
}
return result == 0;
}
@ -194,8 +195,10 @@ static ip_rate_limit_t* get_rate_limit_entry(const char *ip) {
oldest_idx = i;
}
}
fprintf(stderr, "Warning: rate-limit table full, evicting active IP %s\n",
g_rate_limits[oldest_idx].ip);
fprintf(stderr, "Warning: rate-limit table full, evicting active IP %s "
"(%d active connections lost)\n",
g_rate_limits[oldest_idx].ip,
g_rate_limits[oldest_idx].active_connections);
}
/* Reset and reuse */
@ -487,7 +490,9 @@ int client_send(client_t *client, const char *data, size_t len) {
}
while (total < len) {
int sent = ssh_channel_write(client->channel, data + total, len - total);
size_t remaining = len - total;
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
int sent = ssh_channel_write(client->channel, data + total, chunk);
if (sent <= 0) {
pthread_mutex_unlock(&client->io_lock);
return -1;
@ -584,9 +589,18 @@ static int read_username(client_t *client) {
break;
} else if (b == 127 || b == 8) { /* Backspace */
if (pos > 0) {
/* Compute width of the last character before removing it */
int old_pos = pos;
int ci = pos - 1;
while (ci > 0 && (username[ci] & 0xC0) == 0x80) ci--;
int bytes_read;
uint32_t cp = utf8_decode(username + ci, &bytes_read);
int w = utf8_char_width(cp);
utf8_remove_last_char(username);
pos = strlen(username);
client_printf(client, "\b \b");
(void)old_pos;
for (int j = 0; j < w; j++)
client_printf(client, "\b \b");
}
} else if (b < 32) {
/* Ignore control characters */
@ -1069,7 +1083,10 @@ static int execute_exec_command(client_t *client) {
/* Execute a command */
static void execute_command(client_t *client) {
char *cmd = client->command_input;
char cmd_buf[256];
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
cmd_buf[sizeof(cmd_buf) - 1] = '\0';
char *cmd = cmd_buf;
char output[2048] = {0};
size_t pos = 0;
@ -1084,6 +1101,21 @@ static void execute_command(client_t *client) {
}
}
/* Save to command history */
if (cmd[0] != '\0') {
int max_hist = 16;
if (client->command_history_count >= max_hist) {
memmove(&client->command_history[0], &client->command_history[1],
(max_hist - 1) * sizeof(client->command_history[0]));
client->command_history_count = max_hist - 1;
}
strncpy(client->command_history[client->command_history_count],
cmd, sizeof(client->command_history[0]) - 1);
client->command_history[client->command_history_count][sizeof(client->command_history[0]) - 1] = '\0';
client->command_history_count++;
client->command_history_pos = client->command_history_count;
}
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
strcmp(cmd, "who") == 0) {
buffer_appendf(output, sizeof(output), &pos,
@ -1115,11 +1147,59 @@ static void execute_command(client_t *client) {
"========================================\n"
" Available Commands\n"
"========================================\n"
"list, users, who - Show online users\n"
"help, commands - Show this help\n"
"clear, cls - Clear command output\n"
"list, users, who - Show online users\n"
"msg/w <user> <text> - Whisper to user\n"
"help, commands - Show this help\n"
"clear, cls - Clear command output\n"
"q, quit, exit - Disconnect\n"
"Up/Down arrows - Command history\n"
"========================================\n");
} else if (strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
char *rest = (cmd[0] == 'w') ? cmd + 2 : cmd + 4;
while (*rest == ' ') rest++;
char target_name[MAX_USERNAME_LEN] = {0};
int ti = 0;
while (*rest && *rest != ' ' && ti < MAX_USERNAME_LEN - 1) {
target_name[ti++] = *rest++;
}
while (*rest == ' ') rest++;
if (target_name[0] == '\0' || rest[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos,
"Usage: msg <username> <message>\n"
" w <username> <message>\n");
} else {
bool found = false;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
char whisper[MAX_MESSAGE_LEN];
snprintf(whisper, sizeof(whisper),
"\r\n\033[35m[whisper from %s]: %s\033[0m\r\n",
client->username, rest);
client_send(g_room->clients[i], whisper, strlen(whisper));
g_room->clients[i]->redraw_pending = true;
found = true;
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
if (found) {
buffer_appendf(output, sizeof(output), &pos,
"Whisper sent to %s\n", target_name);
} else {
buffer_appendf(output, sizeof(output), &pos,
"User '%s' not found\n", target_name);
}
}
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
strcmp(cmd, "exit") == 0) {
client->connected = false;
return;
} else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) {
buffer_appendf(output, sizeof(output), &pos, "Command output cleared\n");
@ -1239,62 +1319,111 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
}
break;
case MODE_NORMAL:
case MODE_NORMAL: {
int nm_msg_count = room_get_message_count(g_room);
int nm_msg_height = client->height - 3;
if (nm_msg_height < 1) nm_msg_height = 1;
int nm_max_scroll = nm_msg_count - nm_msg_height;
if (nm_max_scroll < 0) nm_max_scroll = 0;
if (key == 'i') {
client->mode = MODE_INSERT;
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == ':') {
client->mode = MODE_COMMAND;
client->command_input[0] = '\0';
tui_render_screen(client);
return true; /* Key consumed - prevents double colon */
return true;
} else if (key == 'j') {
/* Get message count atomically to prevent TOCTOU */
int max_scroll = room_get_message_count(g_room);
int msg_height = client->height - 3;
if (msg_height < 1) msg_height = 1;
max_scroll = max_scroll - msg_height;
if (max_scroll < 0) max_scroll = 0;
if (client->scroll_pos < max_scroll) {
if (client->scroll_pos < nm_max_scroll) {
client->scroll_pos++;
tui_render_screen(client);
}
return true; /* Key consumed */
return true;
} else if (key == 'k' && client->scroll_pos > 0) {
client->scroll_pos--;
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == 4) { /* Ctrl+D: half page down */
int half = nm_msg_height / 2;
if (half < 1) half = 1;
client->scroll_pos += half;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
tui_render_screen(client);
return true;
} else if (key == 21) { /* Ctrl+U: half page up */
int half = nm_msg_height / 2;
if (half < 1) half = 1;
client->scroll_pos -= half;
if (client->scroll_pos < 0) client->scroll_pos = 0;
tui_render_screen(client);
return true;
} else if (key == 6) { /* Ctrl+F: full page down */
client->scroll_pos += nm_msg_height;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
tui_render_screen(client);
return true;
} else if (key == 2) { /* Ctrl+B: full page up */
client->scroll_pos -= nm_msg_height;
if (client->scroll_pos < 0) client->scroll_pos = 0;
tui_render_screen(client);
return true;
} else if (key == 'g') {
client->scroll_pos = 0;
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == 'G') {
/* Get message count atomically to prevent TOCTOU */
int max_scroll = room_get_message_count(g_room);
int msg_height = client->height - 3;
if (msg_height < 1) msg_height = 1;
max_scroll = max_scroll - msg_height;
if (max_scroll < 0) max_scroll = 0;
client->scroll_pos = max_scroll;
client->scroll_pos = nm_max_scroll;
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == '?') {
client->show_help = true;
client->help_scroll_pos = 0;
tui_render_help(client);
return true; /* Key consumed */
return true;
}
break;
}
case MODE_COMMAND:
if (key == 27) { /* ESC */
if (key == 27) { /* ESC - check for arrow key sequences */
char seq[2];
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
if (n == 1 && seq[0] == '[') {
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
if (n == 1) {
if (seq[1] == 'A') { /* Up arrow */
if (client->command_history_count > 0 &&
client->command_history_pos > 0) {
client->command_history_pos--;
strncpy(client->command_input,
client->command_history[client->command_history_pos],
sizeof(client->command_input) - 1);
client->command_input[sizeof(client->command_input) - 1] = '\0';
tui_render_screen(client);
}
return true;
} else if (seq[1] == 'B') { /* Down arrow */
if (client->command_history_pos < client->command_history_count - 1) {
client->command_history_pos++;
strncpy(client->command_input,
client->command_history[client->command_history_pos],
sizeof(client->command_input) - 1);
client->command_input[sizeof(client->command_input) - 1] = '\0';
} else {
client->command_history_pos = client->command_history_count;
client->command_input[0] = '\0';
}
tui_render_screen(client);
return true;
}
}
}
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == '\r' || key == '\n') {
execute_command(client);
return true; /* Key consumed */
@ -1339,6 +1468,8 @@ void* client_handle_session(void *arg) {
client->mode = MODE_INSERT;
client->help_lang = LANG_ZH;
client->connected = true;
client->command_history_count = 0;
client->command_history_pos = 0;
/* Check for exec command */
if (client->exec_command[0] != '\0') {
@ -1367,6 +1498,7 @@ void* client_handle_session(void *arg) {
join_msg.username[MAX_USERNAME_LEN - 1] = '\0';
snprintf(join_msg.content, MAX_MESSAGE_LEN, "%s 加入了聊天室", client->username);
room_broadcast(g_room, &join_msg);
message_save(&join_msg);
/* Render initial screen */
tui_render_screen(client);
@ -1478,6 +1610,22 @@ void* client_handle_session(void *arg) {
client->command_input[len + 1] = '\0';
tui_render_screen(client);
}
} else if (b >= 128) { /* UTF-8 multi-byte */
int char_len = utf8_byte_length(b);
if (char_len <= 0 || char_len > 4) continue;
buf[0] = b;
if (char_len > 1) {
int read_bytes = ssh_channel_read_timeout(
client->channel, &buf[1], char_len - 1, 0, 5000);
if (read_bytes != char_len - 1) continue;
}
if (!utf8_is_valid_sequence(buf, char_len)) continue;
size_t len = strlen(client->command_input);
if (len + (size_t)char_len < sizeof(client->command_input) - 1) {
memcpy(client->command_input + len, buf, char_len);
client->command_input[len + char_len] = '\0';
tui_render_screen(client);
}
}
}
}
@ -1496,6 +1644,7 @@ cleanup:
client->connected = false;
room_remove_client(g_room, client);
room_broadcast(g_room, &leave_msg);
message_save(&leave_msg);
}
release_ip_connection(client->client_ip);
@ -1768,9 +1917,11 @@ static int client_channel_window_change(ssh_session session, ssh_channel channel
return SSH_ERROR;
}
client->width = width;
client->height = height;
sanitize_terminal_size(&client->width, &client->height);
int w = width;
int h = height;
sanitize_terminal_size(&w, &h);
client->width = w;
client->height = h;
client->redraw_pending = true;
return SSH_OK;
}
@ -1911,7 +2062,7 @@ static void *bootstrap_client_session(void *arg) {
break;
}
if (time(NULL) - start_time > 30) {
if (time(NULL) - start_time > 10) {
timed_out = true;
}
}
@ -1942,9 +2093,11 @@ static void *bootstrap_client_session(void *arg) {
client->session = session;
client->channel = channel;
client->width = ctx->pty_width;
client->height = ctx->pty_height;
sanitize_terminal_size(&client->width, &client->height);
int init_w = ctx->pty_width;
int init_h = ctx->pty_height;
sanitize_terminal_size(&init_w, &init_h);
client->width = init_w;
client->height = init_h;
client->ref_count = 1;
pthread_mutex_init(&client->ref_lock, NULL);
pthread_mutex_init(&client->io_lock, NULL);
@ -1969,10 +2122,12 @@ static void *bootstrap_client_session(void *arg) {
client_addref(client);
if (install_client_channel_callbacks(client) < 0) {
client_release(client); /* drop the callback ref */
pthread_mutex_destroy(&client->io_lock);
pthread_mutex_destroy(&client->ref_lock);
free(client);
/* Nullify session/channel ownership so client_release won't
* double-free what cleanup_failed_session is about to free. */
client->session = NULL;
client->channel = NULL;
client_release(client); /* drop the callback ref (2 → 1) */
client_release(client); /* drop the main ref (1 → 0, frees client) */
cleanup_failed_session(session, ctx);
return NULL;
}

View file

@ -188,7 +188,7 @@ void tui_render_input(client_t *client, const char *input) {
int input_width = utf8_string_width(input);
/* Truncate from start if too long */
char display[1024];
char display[MAX_MESSAGE_LEN];
strncpy(display, input, sizeof(display) - 1);
display[sizeof(display) - 1] = '\0';
@ -386,7 +386,7 @@ void tui_render_help(client_t *client) {
/* Help content */
const char *help_text = tui_get_help_text(client->help_lang);
char help_copy[4096];
char help_copy[8192];
strncpy(help_copy, help_text, sizeof(help_copy) - 1);
help_copy[sizeof(help_copy) - 1] = '\0';

View file

@ -1,13 +1,21 @@
# Unit Tests Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c11 -I../../include
CFLAGS = -Wall -Wextra -std=c11 -D_XOPEN_SOURCE=700 -I../../include
LDFLAGS = -pthread
# Detect macOS for _DARWIN_C_SOURCE (needed for timegm)
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
CFLAGS += -D_DARWIN_C_SOURCE
endif
# Source files
UTF8_SRC = ../../src/utf8.c
MESSAGE_SRC = ../../src/message.c
COMMON_SRC = ../../src/common.c
CHAT_ROOM_SRC = ../../src/chat_room.c
TESTS = test_utf8 test_message
TESTS = test_utf8 test_message test_chat_room
.PHONY: all clean run
@ -16,7 +24,10 @@ all: $(TESTS)
test_utf8: test_utf8.c $(UTF8_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC)
test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
run: all
@ -25,6 +36,9 @@ run: all
@echo ""
@echo "=== Running Message Tests ==="
./test_message
@echo ""
@echo "=== Running Chat Room Tests ==="
./test_chat_room
clean:
rm -f $(TESTS) *.o test_messages.log

208
tests/unit/test_chat_room.c Normal file
View file

@ -0,0 +1,208 @@
/* Unit tests for chat_room functions */
/* Minimal client_t stub — only pointer identity matters for add/remove.
* We define `struct client` before including chat_room.h so the forward
* declaration resolves without pulling in ssh_server.h / libssh. */
#include "../../include/common.h"
struct client {
char username[MAX_USERNAME_LEN];
int dummy;
};
typedef struct client client_t;
#include "../../include/chat_room.h"
#include <stdio.h>
#include <string.h>
#include <assert.h>
#define TEST(name) static void test_##name()
#define RUN_TEST(name) do { \
printf("Running %s... ", #name); \
test_##name(); \
printf("\n"); \
tests_passed++; \
} while(0)
static int tests_passed = 0;
static message_t make_msg(const char *user, const char *content) {
message_t m = { .timestamp = time(NULL) };
strncpy(m.username, user, MAX_USERNAME_LEN - 1);
strncpy(m.content, content, MAX_MESSAGE_LEN - 1);
return m;
}
TEST(room_create_destroy) {
chat_room_t *room = room_create();
assert(room != NULL);
assert(room->client_count == 0);
assert(room->client_capacity > 0);
room_destroy(room);
}
TEST(room_add_message_single) {
chat_room_t *room = room_create();
message_t msg = make_msg("alice", "hello");
room_broadcast(room, &msg);
assert(room->message_count == 1);
assert(strcmp(room->messages[0].username, "alice") == 0);
assert(strcmp(room->messages[0].content, "hello") == 0);
room_destroy(room);
}
TEST(room_add_message_overflow) {
chat_room_t *room = room_create();
for (int i = 0; i < MAX_MESSAGES + 10; i++) {
char content[32];
snprintf(content, sizeof(content), "msg %d", i);
message_t msg = make_msg("user", content);
room_broadcast(room, &msg);
}
assert(room->message_count == MAX_MESSAGES);
char expected[32];
snprintf(expected, sizeof(expected), "msg %d", 10);
assert(strcmp(room->messages[0].content, expected) == 0);
snprintf(expected, sizeof(expected), "msg %d", MAX_MESSAGES + 9);
assert(strcmp(room->messages[MAX_MESSAGES - 1].content, expected) == 0);
room_destroy(room);
}
TEST(room_broadcast_increments_seq) {
chat_room_t *room = room_create();
g_room = room;
uint64_t seq1 = room_get_update_seq(room);
message_t msg = make_msg("bob", "hi");
room_broadcast(room, &msg);
uint64_t seq2 = room_get_update_seq(room);
assert(seq2 > seq1);
assert(room_get_message_count(room) == 1);
g_room = NULL;
room_destroy(room);
}
TEST(room_get_message_valid) {
chat_room_t *room = room_create();
message_t msg = make_msg("carol", "test");
room_broadcast(room, &msg);
message_t out;
assert(room_get_message(room, 0, &out) == true);
assert(strcmp(out.username, "carol") == 0);
assert(strcmp(out.content, "test") == 0);
room_destroy(room);
}
TEST(room_get_message_invalid_index) {
chat_room_t *room = room_create();
message_t out;
assert(room_get_message(room, 0, &out) == false);
assert(room_get_message(room, -1, &out) == false);
assert(room_get_message(room, 999, &out) == false);
room_destroy(room);
}
TEST(room_get_message_null_args) {
chat_room_t *room = room_create();
message_t out;
assert(room_get_message(NULL, 0, &out) == false);
assert(room_get_message(room, 0, NULL) == false);
room_destroy(room);
}
TEST(room_client_count) {
chat_room_t *room = room_create();
assert(room_get_client_count(room) == 0);
client_t c1 = {0};
client_t c2 = {0};
assert(room_add_client(room, &c1) == 0);
assert(room_get_client_count(room) == 1);
assert(room_add_client(room, &c2) == 0);
assert(room_get_client_count(room) == 2);
room_remove_client(room, &c1);
assert(room_get_client_count(room) == 1);
room_remove_client(room, &c2);
assert(room_get_client_count(room) == 0);
room_destroy(room);
}
TEST(room_remove_nonexistent_client) {
chat_room_t *room = room_create();
client_t c1 = {0};
client_t c2 = {0};
room_add_client(room, &c1);
room_remove_client(room, &c2);
assert(room_get_client_count(room) == 1);
room_destroy(room);
}
TEST(room_add_client_full) {
chat_room_t *room = room_create();
client_t clients[MAX_CLIENTS + 1];
memset(clients, 0, sizeof(clients));
for (int i = 0; i < room->client_capacity; i++) {
assert(room_add_client(room, &clients[i]) == 0);
}
assert(room_add_client(room, &clients[room->client_capacity]) == -1);
assert(room_get_client_count(room) == room->client_capacity);
room_destroy(room);
}
TEST(room_message_count_threadsafe) {
chat_room_t *room = room_create();
assert(room_get_message_count(room) == 0);
message_t msg = make_msg("dave", "msg");
room_broadcast(room, &msg);
assert(room_get_message_count(room) == 1);
room_broadcast(room, &msg);
room_broadcast(room, &msg);
assert(room_get_message_count(room) == 3);
room_destroy(room);
}
int main(void) {
printf("=== Chat Room Unit Tests ===\n");
RUN_TEST(room_create_destroy);
RUN_TEST(room_add_message_single);
RUN_TEST(room_add_message_overflow);
RUN_TEST(room_broadcast_increments_seq);
RUN_TEST(room_get_message_valid);
RUN_TEST(room_get_message_invalid_index);
RUN_TEST(room_get_message_null_args);
RUN_TEST(room_client_count);
RUN_TEST(room_remove_nonexistent_client);
RUN_TEST(room_add_client_full);
RUN_TEST(room_message_count_threadsafe);
printf("\nAll %d tests passed!\n", tests_passed);
return 0;
}

210
tnt.1 Normal file
View file

@ -0,0 +1,210 @@
.\" tnt(1) - Terminal Network Talk
.TH TNT 1 "April 2026" "TNT 1.0.0" "User Commands"
.SH NAME
tnt \- anonymous SSH chat server with Vim\-style TUI
.SH SYNOPSIS
.B tnt
.RB [ \-p
.IR port ]
.RB [ \-d
.IR dir ]
.RB [ \-h ]
.SH DESCRIPTION
.B tnt
is a multi\-user anonymous chat server accessed over SSH.
It provides a Vim\-style terminal user interface with INSERT, NORMAL, and
COMMAND modes.
Users connect with any standard SSH client; no account or registration is needed.
.PP
Messages are persisted to a log file and restored on server restart.
The server supports CJK and emoji input, rate limiting, access tokens, and
a non\-interactive exec interface for scripting.
.SH OPTIONS
.TP
.BI \-p " port"
Listen on
.I port
instead of the default 2222.
Overrides the
.B PORT
environment variable.
.TP
.BI \-d " dir"
Store the host key and message log in
.IR dir .
Overrides the
.B TNT_STATE_DIR
environment variable.
Defaults to the current working directory.
.TP
.B \-h
Print a short usage summary and exit.
.SH CONNECTING
.PP
.nf
ssh any\-username@hostname \-p 2222
.fi
.PP
If an access token is configured, supply it as the SSH password.
The username entered in the SSH handshake is ignored; a chat\-room
nickname is chosen interactively after login.
.SH MODES
.TP
.B INSERT
Type and send messages.
Press
.B Enter
to send,
.B ESC
to switch to NORMAL mode.
.TP
.B NORMAL
Scroll through chat history with Vim keybindings.
Press
.B i
to return to INSERT,
.B :
to enter COMMAND mode,
.B ?
to open the help screen.
.TP
.B COMMAND
Execute commands prefixed with
.BR : .
.SH KEYBINDINGS
.SS INSERT mode
.TS
l l.
Enter Send message
ESC Switch to NORMAL
Ctrl+W Delete last word
Ctrl+U Clear input line
Ctrl+C Switch to NORMAL
.TE
.SS NORMAL mode
.TS
l l.
j/k Scroll down/up one line
Ctrl+D/Ctrl+U Scroll half page down/up
Ctrl+F/Ctrl+B Scroll full page down/up
g/G Jump to top/bottom
i Switch to INSERT
: Enter COMMAND mode
? Open help screen
Ctrl+C Disconnect
.TE
.SS COMMAND mode
.TS
l l.
:list Show online users
:msg \fIuser text\fR Send private whisper
:w \fIuser text\fR Short alias for :msg
:help Show available commands
:clear Clear command output
:q, :quit, :exit Disconnect
Up/Down Browse command history
ESC Cancel and return to NORMAL
.TE
.SH EXEC INTERFACE
Commands can be run non\-interactively for scripting:
.PP
.nf
ssh host \-p 2222 help
ssh host \-p 2222 users \-\-json
ssh host \-p 2222 stats \-\-json
ssh host \-p 2222 tail 20
ssh host \-p 2222 post "Hello from a script"
ssh host \-p 2222 health
.fi
.PP
Exit codes follow
.BR sysexits (3)
conventions.
.SH ENVIRONMENT
.TP
.B PORT
Default listening port (default: 2222).
.TP
.B TNT_STATE_DIR
Directory for host key and message log (default: current directory).
.TP
.B TNT_ACCESS_TOKEN
If set, clients must supply this string as their SSH password.
Compared in constant time.
.TP
.B TNT_MAX_CONNECTIONS
Global connection limit (default: 64, max: 1024).
.TP
.B TNT_MAX_CONN_PER_IP
Max concurrent sessions from one IP (default: 5).
.TP
.B TNT_MAX_CONN_RATE_PER_IP
Max new connections per IP per 60\-second window (default: 10).
.TP
.B TNT_RATE_LIMIT
Set to 0 to disable rate\-based blocking and auth\-failure IP blocking.
Explicit capacity limits still apply (default: 1).
.SH FILES
.TP
.I messages.log
Chat history in RFC\ 3339 pipe\-delimited format
.RI ( timestamp | username | content ).
Stored in the state directory.
.TP
.I host_key
RSA 4096\-bit host key, auto\-generated on first run.
Stored in the state directory with mode 0600.
.SH SYSTEMD
A unit file
.I tnt.service
is provided.
Typical setup:
.PP
.nf
sudo useradd \-r \-s /bin/false tnt
sudo cp tnt.service /etc/systemd/system/
sudo systemctl daemon\-reload
sudo systemctl enable \-\-now tnt
.fi
.PP
Runtime overrides can be placed in
.IR /etc/default/tnt .
.SH SECURITY
.IP \(bu 2
Reference\-counted client lifecycle prevents use\-after\-free.
.IP \(bu 2
Per\-IP rate limiting with auth\-failure blocking (5 failures = 5\-minute ban).
.IP \(bu 2
Access\-token comparison uses constant\-time algorithm.
.IP \(bu 2
Host key created with restrictive permissions (0600).
.IP \(bu 2
systemd hardening: NoNewPrivileges, PrivateTmp, ProtectSystem=strict.
.SH EXAMPLES
Start on port 3000 with state in /var/lib/tnt:
.PP
.nf
tnt \-p 3000 \-d /var/lib/tnt
.fi
.PP
Start with an access token:
.PP
.nf
TNT_ACCESS_TOKEN=s3cret tnt
.fi
.PP
Connect from another machine:
.PP
.nf
ssh user@chat.example.com \-p 2222
.fi
.SH AUTHORS
m1ngsama <contact@m1ng.space>
.SH BUGS
Report bugs at
.UR https://github.com/m1ngsama/TNT/issues
.UE .
.SH SEE ALSO
.BR ssh (1),
.BR sshd (8),
.BR systemctl (1)