diff --git a/Makefile b/Makefile index 737fb45..11d06d6 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/include/chat_room.h b/include/chat_room.h index 0b50d5f..ac913fa 100644 --- a/include/chat_room.h +++ b/include/chat_room.h @@ -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); diff --git a/include/ssh_server.h b/include/ssh_server.h index bf77b9e..8464d26 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -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]; diff --git a/src/chat_room.c b/src/chat_room.c index 0cd10f4..ebaea79 100644 --- a/src/chat_room.c +++ b/src/chat_room.c @@ -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; diff --git a/src/ssh_server.c b/src/ssh_server.c index 1c7dad5..9275b91 100644 --- a/src/ssh_server.c +++ b/src/ssh_server.c @@ -1083,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; @@ -1098,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, @@ -1129,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 - 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 \n" + " w \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"); @@ -1253,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 */ @@ -1353,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') { @@ -1381,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); @@ -1526,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); @@ -1798,11 +1917,11 @@ static int client_channel_window_change(ssh_session session, ssh_channel channel return SSH_ERROR; } - pthread_mutex_lock(&client->io_lock); - client->width = width; - client->height = height; - sanitize_terminal_size(&client->width, &client->height); - pthread_mutex_unlock(&client->io_lock); + int w = width; + int h = height; + sanitize_terminal_size(&w, &h); + client->width = w; + client->height = h; client->redraw_pending = true; return SSH_OK; } @@ -1974,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); diff --git a/src/tui.c b/src/tui.c index eb508e5..50a7bea 100644 --- a/src/tui.c +++ b/src/tui.c @@ -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'; diff --git a/tests/unit/test_chat_room.c b/tests/unit/test_chat_room.c index cb14cf3..15187c5 100644 --- a/tests/unit/test_chat_room.c +++ b/tests/unit/test_chat_room.c @@ -45,7 +45,7 @@ TEST(room_add_message_single) { chat_room_t *room = room_create(); message_t msg = make_msg("alice", "hello"); - room_add_message(room, &msg); + 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); @@ -60,7 +60,7 @@ TEST(room_add_message_overflow) { char content[32]; snprintf(content, sizeof(content), "msg %d", i); message_t msg = make_msg("user", content); - room_add_message(room, &msg); + room_broadcast(room, &msg); } assert(room->message_count == MAX_MESSAGES); @@ -94,7 +94,7 @@ TEST(room_broadcast_increments_seq) { TEST(room_get_message_valid) { chat_room_t *room = room_create(); message_t msg = make_msg("carol", "test"); - room_add_message(room, &msg); + room_broadcast(room, &msg); message_t out; assert(room_get_message(room, 0, &out) == true); diff --git a/tnt.1 b/tnt.1 new file mode 100644 index 0000000..d614246 --- /dev/null +++ b/tnt.1 @@ -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 +.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)