diff --git a/include/bootstrap.h b/include/bootstrap.h index a64221e..a163351 100644 --- a/include/bootstrap.h +++ b/include/bootstrap.h @@ -25,14 +25,14 @@ void bootstrap_peer_ip(ssh_session session, char *ip_buf, size_t buf_size); /* pthread entry point for the per-connection bootstrap thread. * - * Steps performed before handing control to client_handle_session(): + * Steps performed before handing control to input_run_session(): * 1. SSH key exchange * 2. auth (password / none / pubkey, with rate-limit feedback) * 3. channel open + PTY/shell-or-exec request * 4. construct a client_t and install its lifetime channel callbacks * * On any failure path the connection is torn down and ratelimit / - * connection counters are released; client_handle_session() is never + * connection counters are released; input_run_session() is never * invoked. Always returns NULL. */ void *bootstrap_run(void *arg); diff --git a/include/input.h b/include/input.h new file mode 100644 index 0000000..4ee8e6c --- /dev/null +++ b/include/input.h @@ -0,0 +1,31 @@ +#ifndef INPUT_H +#define INPUT_H + +#include "ssh_server.h" /* for client_t */ + +/* Read TNT_IDLE_TIMEOUT from the environment. Idempotent. Call once at + * startup before any session can run. */ +void input_init(void); + +/* Run the interactive session for an already-bootstrapped client_t. + * + * Sequence: + * 1. If client->exec_command is set, dispatch it via exec_dispatch and + * return (no chat-room join). + * 2. Read the desired username from the channel. + * 3. Add the client to g_room and broadcast a system join message. + * 4. Optionally show the MOTD if state-dir/motd.txt exists. + * 5. Drive the keyboard / room-update / keepalive / idle-timeout loop + * until the client disconnects. + * 6. Broadcast a system leave message and release all refs / counters. + * + * Owns the client_t after entry: callers must NOT touch it once this + * returns. Always returns regardless of how the session ended. */ +void input_run_session(client_t *client); + +/* Bell-notify any clients whose @username appears in the broadcast + * content, skipping the sender. Used by the INSERT-mode send path + * inside input_run_session and by exec_command_post. */ +void notify_mentions(const char *content, const client_t *sender); + +#endif /* INPUT_H */ diff --git a/include/ssh_server.h b/include/ssh_server.h index 2aa0db9..07b97b1 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -45,9 +45,6 @@ int ssh_server_init(int port); /* Start SSH server (blocking) */ int ssh_server_start(int listen_fd); -/* Handle client session */ -void* client_handle_session(void *arg); - /* Send data to client */ int client_send(client_t *client, const char *data, size_t len); @@ -62,15 +59,10 @@ void client_release(client_t *client); * that target this client_t. Caller MUST have already added one * client_addref() to keep the client alive across in-flight callback * invocations; the matching client_release() happens during cleanup in - * client_handle_session(). Returns 0 on success, -1 on failure (in which + * input_run_session(). Returns 0 on success, -1 on failure (in which * case the caller still owns both refs and must release them). */ int client_install_channel_callbacks(client_t *client); -/* Bell-notify any clients whose @username appears in the broadcast content, - * skipping the sender. Defined in ssh_server.c (will move to a dedicated - * client.c during PR2-M6). */ -void notify_mentions(const char *content, const client_t *sender); - /* Read-only accessor for the server start time (used by exec stats). */ time_t ssh_server_start_time(void); diff --git a/src/bootstrap.c b/src/bootstrap.c index 2754c89..be1eb2c 100644 --- a/src/bootstrap.c +++ b/src/bootstrap.c @@ -1,5 +1,6 @@ #include "bootstrap.h" #include "common.h" +#include "input.h" #include "ratelimit.h" #include #include @@ -487,6 +488,6 @@ void *bootstrap_run(void *arg) { } destroy_session_context(ctx); - client_handle_session(client); + input_run_session(client); return NULL; } diff --git a/src/exec.c b/src/exec.c index f6872ed..c2a0074 100644 --- a/src/exec.c +++ b/src/exec.c @@ -1,6 +1,7 @@ #include "exec.h" #include "chat_room.h" #include "common.h" +#include "input.h" #include "message.h" #include "ratelimit.h" #include "utf8.h" @@ -10,9 +11,8 @@ #include #include -/* `notify_mentions` is shared with the interactive INSERT-mode send path - * (currently still in ssh_server.c, will move to its own home in PR2-M5/M6). - * Declared in ssh_server.h. */ +/* `notify_mentions` is shared with the interactive INSERT-mode send path. + * Declared in input.h. */ static void format_timestamp_utc(time_t ts, char *buffer, size_t buf_size) { struct tm tm_info; diff --git a/src/input.c b/src/input.c new file mode 100644 index 0000000..a185b07 --- /dev/null +++ b/src/input.c @@ -0,0 +1,636 @@ +#include "input.h" +#include "chat_room.h" +#include "commands.h" +#include "common.h" +#include "exec.h" +#include "message.h" +#include "ratelimit.h" +#include "tui.h" +#include "utf8.h" +#include +#include +#include +#include +#include +#include +#include + +static int g_idle_timeout = DEFAULT_IDLE_TIMEOUT; + +void input_init(void) { + g_idle_timeout = env_int("TNT_IDLE_TIMEOUT", DEFAULT_IDLE_TIMEOUT, 0, 86400); +} + +static int read_username(client_t *client) { + char username[MAX_USERNAME_LEN] = {0}; + int pos = 0; + char buf[4]; + + tui_clear_screen(client); + client_printf(client, "================================\r\n"); + client_printf(client, " 欢迎来到 TNT 匿名聊天室\r\n"); + client_printf(client, " Welcome to TNT Anonymous Chat\r\n"); + client_printf(client, "================================\r\n\r\n"); + client_printf(client, "请输入用户名 (留空默认为 anonymous): "); + + while (1) { + int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */ + + if (n == SSH_AGAIN) { + /* Timeout */ + if (!ssh_channel_is_open(client->channel)) { + return -1; + } + continue; + } + + if (n <= 0) return -1; + + unsigned char b = buf[0]; + + if (b == '\r' || b == '\n') { + 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); + (void)old_pos; + for (int j = 0; j < w; j++) + client_printf(client, "\b \b"); + } + } else if (b < 32) { + /* Ignore control characters */ + } else if (b < 128) { + /* ASCII */ + if (pos < MAX_USERNAME_LEN - 1) { + username[pos++] = b; + username[pos] = '\0'; + client_send(client, (char *)&b, 1); + } + } else { + /* UTF-8 multi-byte */ + int len = utf8_byte_length(b); + if (len <= 0 || len > 4) { + /* Invalid UTF-8 start byte */ + continue; + } + buf[0] = b; + if (len > 1) { + int read_bytes = ssh_channel_read_timeout(client->channel, &buf[1], len - 1, 0, 5000); + if (read_bytes != len - 1) { + /* Incomplete or timed-out UTF-8 continuation */ + continue; + } + } + /* Validate the complete UTF-8 sequence */ + if (!utf8_is_valid_sequence(buf, len)) { + continue; + } + if (pos + len < MAX_USERNAME_LEN - 1) { + memcpy(username + pos, buf, len); + pos += len; + username[pos] = '\0'; + client_send(client, buf, len); + } + } + } + + client_printf(client, "\r\n"); + + if (username[0] == '\0') { + strncpy(client->username, "anonymous", MAX_USERNAME_LEN - 1); + client->username[MAX_USERNAME_LEN - 1] = '\0'; + } else { + strncpy(client->username, username, MAX_USERNAME_LEN - 1); + client->username[MAX_USERNAME_LEN - 1] = '\0'; + + /* Validate username for security */ + if (!is_valid_username(client->username)) { + client_printf(client, "Invalid username. Using 'anonymous' instead.\r\n"); + strcpy(client->username, "anonymous"); + } else { + /* Truncate to 20 characters */ + if (utf8_strlen(client->username) > 20) { + utf8_truncate(client->username, 20); + } + } + } + + return 0; +} + +void notify_mentions(const char *content, const client_t *sender) { + pthread_rwlock_rdlock(&g_room->lock); + int count = g_room->client_count; + client_t *targets[MAX_CLIENTS]; + int target_count = 0; + + for (int i = 0; i < count; i++) { + client_t *c = g_room->clients[i]; + if (c == sender) continue; + char mention[MAX_USERNAME_LEN + 2]; + snprintf(mention, sizeof(mention), "@%s", c->username); + if (strstr(content, mention) != NULL) { + client_addref(c); + targets[target_count++] = c; + } + } + pthread_rwlock_unlock(&g_room->lock); + + for (int i = 0; i < target_count; i++) { + client_send(targets[i], "\a", 1); + targets[i]->redraw_pending = true; + client_release(targets[i]); + } +} + +/* Handle a single key press. Returns true if the key was fully consumed + * (no further character buffering needed). */ +static bool handle_key(client_t *client, unsigned char key, char *input) { + /* Handle Ctrl+C (Exit or switch to NORMAL) */ + if (key == 3) { + if (client->mode != MODE_NORMAL) { + client->mode = MODE_NORMAL; + client->command_input[0] = '\0'; + client->show_help = false; + tui_render_screen(client); + } else { + /* In NORMAL mode, Ctrl+C exits */ + client->connected = false; + } + return true; + } + + /* Handle help screen */ + if (client->show_help) { + if (key == 'q' || key == 27) { + client->show_help = false; + tui_render_screen(client); + } else if (key == 'e' || key == 'E') { + client->help_lang = LANG_EN; + client->help_scroll_pos = 0; + tui_render_help(client); + } else if (key == 'z' || key == 'Z') { + client->help_lang = LANG_ZH; + client->help_scroll_pos = 0; + tui_render_help(client); + } else if (key == 'j') { + client->help_scroll_pos++; + tui_render_help(client); + } else if (key == 'k' && client->help_scroll_pos > 0) { + client->help_scroll_pos--; + tui_render_help(client); + } else if (key == 'g') { + client->help_scroll_pos = 0; + tui_render_help(client); + } else if (key == 'G') { + client->help_scroll_pos = 999; /* Large number */ + tui_render_help(client); + } + return true; /* Key consumed */ + } + + /* Handle command output display */ + if (client->command_output[0] != '\0') { + client->command_output[0] = '\0'; + client->mode = MODE_NORMAL; + tui_render_screen(client); + return true; /* Key consumed */ + } + + /* Mode-specific handling */ + switch (client->mode) { + case MODE_INSERT: + if (key == 27) { /* ESC */ + client->mode = MODE_NORMAL; + client->scroll_pos = 0; + tui_render_screen(client); + return true; /* Key consumed */ + } else if (key == '\r' || key == '\n') { /* Enter */ + if (input[0] != '\0') { + message_t msg = { + .timestamp = time(NULL), + }; + if (strncmp(input, "/me ", 4) == 0 && input[4] != '\0') { + msg.username[0] = '*'; + msg.username[1] = '\0'; + int n = snprintf(msg.content, sizeof(msg.content), "%s %s", + client->username, input + 4); + if (n >= (int)sizeof(msg.content)) { + msg.content[sizeof(msg.content) - 1] = '\0'; + } + } else { + snprintf(msg.username, sizeof(msg.username), "%s", client->username); + snprintf(msg.content, sizeof(msg.content), "%s", input); + } + room_broadcast(g_room, &msg); + notify_mentions(msg.content, client); + message_save(&msg); + input[0] = '\0'; + } + tui_render_screen(client); + return true; /* Key consumed */ + } else if (key == 127 || key == 8) { /* Backspace */ + if (input[0] != '\0') { + utf8_remove_last_char(input); + tui_render_input(client, input); + } + return true; /* Key consumed */ + } else if (key == 23) { /* Ctrl+W (Delete Word) */ + if (input[0] != '\0') { + utf8_remove_last_word(input); + tui_render_input(client, input); + } + return true; + } else if (key == 21) { /* Ctrl+U (Delete Line) */ + if (input[0] != '\0') { + input[0] = '\0'; + tui_render_input(client, input); + } + return true; + } + break; + + 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; + } else if (key == ':') { + client->mode = MODE_COMMAND; + client->command_input[0] = '\0'; + tui_render_screen(client); + return true; + } else if (key == 'j') { + if (client->scroll_pos < nm_max_scroll) { + client->scroll_pos++; + tui_render_screen(client); + } + return true; + } else if (key == 'k' && client->scroll_pos > 0) { + client->scroll_pos--; + tui_render_screen(client); + 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; + } else if (key == 'G') { + client->scroll_pos = nm_max_scroll; + tui_render_screen(client); + return true; + } else if (key == '?') { + client->show_help = true; + client->help_scroll_pos = 0; + tui_render_help(client); + return true; + } + break; + } + + case MODE_COMMAND: + 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; + } else if (key == '\r' || key == '\n') { + commands_dispatch(client); + return true; /* Key consumed */ + } else if (key == 127 || key == 8) { /* Backspace */ + if (client->command_input[0] != '\0') { + utf8_remove_last_char(client->command_input); + tui_render_screen(client); + } + return true; /* Key consumed */ + } else if (key == 23) { /* Ctrl+W (Delete Word) */ + if (client->command_input[0] != '\0') { + utf8_remove_last_word(client->command_input); + tui_render_screen(client); + } + return true; + } else if (key == 21) { /* Ctrl+U (Delete Line) */ + if (client->command_input[0] != '\0') { + client->command_input[0] = '\0'; + tui_render_screen(client); + } + return true; + } + break; + + default: + break; + } + + return false; /* Key not consumed */ +} + +void input_run_session(client_t *client) { + 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; + client->help_lang = LANG_ZH; + client->connected = true; + client->command_history_count = 0; + client->command_history_pos = 0; + client->connect_time = time(NULL); + client->last_active = time(NULL); + + /* Check for exec command */ + if (client->exec_command[0] != '\0') { + int exit_status = exec_dispatch(client); + ssh_channel_request_send_exit_status(client->channel, exit_status); + goto cleanup; + } + + /* Read username */ + if (read_username(client) < 0) { + goto cleanup; + } + + /* Add to room */ + if (room_add_client(g_room, client) < 0) { + client_printf(client, "Room is full\n"); + goto cleanup; + } + joined_room = true; + + /* Broadcast join message */ + message_t join_msg = { + .timestamp = time(NULL), + }; + strncpy(join_msg.username, "系统", MAX_USERNAME_LEN - 1); + 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); + + /* Show MOTD if motd.txt exists in state directory */ + { + char motd_path[PATH_MAX]; + if (tnt_state_path(motd_path, sizeof(motd_path), "motd.txt") == 0) { + FILE *motd_fp = fopen(motd_path, "r"); + if (motd_fp) { + char motd_buf[sizeof(client->command_output) - 64]; + size_t motd_len = fread(motd_buf, 1, sizeof(motd_buf) - 1, motd_fp); + fclose(motd_fp); + if (motd_len > 0) { + motd_buf[motd_len] = '\0'; + snprintf(client->command_output, sizeof(client->command_output), + "=== 公告 / MOTD ===\n%s", motd_buf); + tui_render_command_output(client); + seen_update_seq = room_get_update_seq(g_room); + goto main_loop; + } + } + } + } + + /* Render initial screen */ + tui_render_screen(client); + seen_update_seq = room_get_update_seq(g_room); + +main_loop: + + /* Main input loop */ + while (client->connected && ssh_channel_is_open(client->channel)) { + int ready = ssh_channel_poll_timeout(client->channel, 1000, 0); + + 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); + } + + if (g_idle_timeout > 0 && joined_room && + time(NULL) - client->last_active >= g_idle_timeout) { + client_printf(client, "\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n", + g_idle_timeout / 60); + break; + } + continue; + } + + int n = ssh_channel_read(client->channel, buf, 1, 0); + + if (n <= 0) { + /* EOF or error */ + break; + } + + last_keepalive = time(NULL); + client->last_active = last_keepalive; + + unsigned char b = buf[0]; + + /* Handle special keys - returns true if key was consumed */ + bool key_consumed = handle_key(client, b, input); + + /* Only add character to input if not consumed by handle_key */ + if (!key_consumed) { + /* Add character to input (INSERT mode only) */ + if (client->mode == MODE_INSERT && !client->show_help && + client->command_output[0] == '\0') { + if (b >= 32 && b < 127) { /* ASCII printable */ + int len = strlen(input); + if (len < MAX_MESSAGE_LEN - 1) { + input[len] = b; + input[len + 1] = '\0'; + tui_render_input(client, input); + } + } else if (b >= 128) { /* UTF-8 multi-byte */ + int char_len = utf8_byte_length(b); + if (char_len <= 0 || char_len > 4) { + /* Invalid UTF-8 start byte */ + 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) { + /* Incomplete or timed-out UTF-8 continuation */ + continue; + } + } + /* Validate the complete UTF-8 sequence */ + if (!utf8_is_valid_sequence(buf, char_len)) { + /* Invalid UTF-8 sequence */ + continue; + } + int len = strlen(input); + if (len + char_len < MAX_MESSAGE_LEN - 1) { + memcpy(input + len, buf, char_len); + input[len + char_len] = '\0'; + tui_render_input(client, input); + } + } + } else if (client->mode == MODE_COMMAND && !client->show_help && + client->command_output[0] == '\0') { + if (b >= 32 && b < 127) { /* ASCII printable */ + size_t len = strlen(client->command_input); + if (len < sizeof(client->command_input) - 1) { + client->command_input[len] = b; + 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); + } + } + } + } + } + +cleanup: + /* Broadcast leave message */ + if (joined_room) { + message_t leave_msg = { + .timestamp = time(NULL), + }; + strncpy(leave_msg.username, "系统", MAX_USERNAME_LEN - 1); + leave_msg.username[MAX_USERNAME_LEN - 1] = '\0'; + snprintf(leave_msg.content, MAX_MESSAGE_LEN, "%s 离开了聊天室", client->username); + + client->connected = false; + room_remove_client(g_room, client); + room_broadcast(g_room, &leave_msg); + message_save(&leave_msg); + } + + ratelimit_release_ip(client->client_ip); + + /* Remove channel callbacks before releasing refs to prevent use-after-free + * if a callback fires between the two releases. */ + if (client->channel && client->channel_cb) { + ssh_remove_channel_callbacks(client->channel, client->channel_cb); + } + + /* Release the callback reference (paired with addref before client_install_channel_callbacks) */ + client_release(client); + + /* Release the main reference - client will be freed when all refs are gone */ + client_release(client); + + /* Decrement connection count */ + ratelimit_decrement_total(); +} diff --git a/src/ssh_server.c b/src/ssh_server.c index de2c782..6f4617d 100644 --- a/src/ssh_server.c +++ b/src/ssh_server.c @@ -2,6 +2,7 @@ #include "bootstrap.h" #include "commands.h" #include "exec.h" +#include "input.h" #include "ratelimit.h" #include "tui.h" #include "utf8.h" @@ -29,10 +30,8 @@ time_t ssh_server_start_time(void) { return g_server_start_time; } -/* Configuration from environment variables. Rate-limiting moved to ratelimit.{c,h} - * and the access token now lives in bootstrap.{c,h}; the idle timeout stays here - * until input.c is extracted in PR2-M5. */ -static int g_idle_timeout = DEFAULT_IDLE_TIMEOUT; +/* Configuration from environment variables. Rate-limiting moved to ratelimit.{c,h}, + * the access token to bootstrap.{c,h}, and the idle timeout to input.{c,h}. */ /* Generate or load SSH host key */ static int setup_host_key(ssh_bind sshbind) { @@ -204,630 +203,6 @@ int client_printf(client_t *client, const char *fmt, ...) { return client_send(client, buffer, len); } -/* Read username from client */ -static int read_username(client_t *client) { - char username[MAX_USERNAME_LEN] = {0}; - int pos = 0; - char buf[4]; - - tui_clear_screen(client); - client_printf(client, "================================\r\n"); - client_printf(client, " 欢迎来到 TNT 匿名聊天室\r\n"); - client_printf(client, " Welcome to TNT Anonymous Chat\r\n"); - client_printf(client, "================================\r\n\r\n"); - client_printf(client, "请输入用户名 (留空默认为 anonymous): "); - - while (1) { - int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */ - - if (n == SSH_AGAIN) { - /* Timeout */ - if (!ssh_channel_is_open(client->channel)) { - return -1; - } - continue; - } - - if (n <= 0) return -1; - - unsigned char b = buf[0]; - - if (b == '\r' || b == '\n') { - 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); - (void)old_pos; - for (int j = 0; j < w; j++) - client_printf(client, "\b \b"); - } - } else if (b < 32) { - /* Ignore control characters */ - } else if (b < 128) { - /* ASCII */ - if (pos < MAX_USERNAME_LEN - 1) { - username[pos++] = b; - username[pos] = '\0'; - client_send(client, (char *)&b, 1); - } - } else { - /* UTF-8 multi-byte */ - int len = utf8_byte_length(b); - if (len <= 0 || len > 4) { - /* Invalid UTF-8 start byte */ - continue; - } - buf[0] = b; - if (len > 1) { - int read_bytes = ssh_channel_read_timeout(client->channel, &buf[1], len - 1, 0, 5000); - if (read_bytes != len - 1) { - /* Incomplete or timed-out UTF-8 continuation */ - continue; - } - } - /* Validate the complete UTF-8 sequence */ - if (!utf8_is_valid_sequence(buf, len)) { - continue; - } - if (pos + len < MAX_USERNAME_LEN - 1) { - memcpy(username + pos, buf, len); - pos += len; - username[pos] = '\0'; - client_send(client, buf, len); - } - } - } - - client_printf(client, "\r\n"); - - if (username[0] == '\0') { - strncpy(client->username, "anonymous", MAX_USERNAME_LEN - 1); - client->username[MAX_USERNAME_LEN - 1] = '\0'; - } else { - strncpy(client->username, username, MAX_USERNAME_LEN - 1); - client->username[MAX_USERNAME_LEN - 1] = '\0'; - - /* Validate username for security */ - if (!is_valid_username(client->username)) { - client_printf(client, "Invalid username. Using 'anonymous' instead.\r\n"); - strcpy(client->username, "anonymous"); - } else { - /* Truncate to 20 characters */ - if (utf8_strlen(client->username) > 20) { - utf8_truncate(client->username, 20); - } - } - } - - return 0; -} - -/* Notify any clients whose usernames appear as @mentions in `content`. - * Lives here because it bridges chat_room (target lookup) and the client - * I/O API; will move into a proper home when a `client.c` is carved out - * during PR2-M6 server cleanup. */ -void notify_mentions(const char *content, const client_t *sender) { - pthread_rwlock_rdlock(&g_room->lock); - int count = g_room->client_count; - client_t *targets[MAX_CLIENTS]; - int target_count = 0; - - for (int i = 0; i < count; i++) { - client_t *c = g_room->clients[i]; - if (c == sender) continue; - char mention[MAX_USERNAME_LEN + 2]; - snprintf(mention, sizeof(mention), "@%s", c->username); - if (strstr(content, mention) != NULL) { - client_addref(c); - targets[target_count++] = c; - } - } - pthread_rwlock_unlock(&g_room->lock); - - for (int i = 0; i < target_count; i++) { - client_send(targets[i], "\a", 1); - targets[i]->redraw_pending = true; - client_release(targets[i]); - } -} - -/* Execute a command */ - -/* Handle client key press - returns true if key was consumed */ -static bool handle_key(client_t *client, unsigned char key, char *input) { - /* Handle Ctrl+C (Exit or switch to NORMAL) */ - if (key == 3) { - if (client->mode != MODE_NORMAL) { - client->mode = MODE_NORMAL; - client->command_input[0] = '\0'; - client->show_help = false; - tui_render_screen(client); - } else { - /* In NORMAL mode, Ctrl+C exits */ - client->connected = false; - } - return true; - } - - /* Handle help screen */ - if (client->show_help) { - if (key == 'q' || key == 27) { - client->show_help = false; - tui_render_screen(client); - } else if (key == 'e' || key == 'E') { - client->help_lang = LANG_EN; - client->help_scroll_pos = 0; - tui_render_help(client); - } else if (key == 'z' || key == 'Z') { - client->help_lang = LANG_ZH; - client->help_scroll_pos = 0; - tui_render_help(client); - } else if (key == 'j') { - client->help_scroll_pos++; - tui_render_help(client); - } else if (key == 'k' && client->help_scroll_pos > 0) { - client->help_scroll_pos--; - tui_render_help(client); - } else if (key == 'g') { - client->help_scroll_pos = 0; - tui_render_help(client); - } else if (key == 'G') { - client->help_scroll_pos = 999; /* Large number */ - tui_render_help(client); - } - return true; /* Key consumed */ - } - - /* Handle command output display */ - if (client->command_output[0] != '\0') { - client->command_output[0] = '\0'; - client->mode = MODE_NORMAL; - tui_render_screen(client); - return true; /* Key consumed */ - } - - /* Mode-specific handling */ - switch (client->mode) { - case MODE_INSERT: - if (key == 27) { /* ESC */ - client->mode = MODE_NORMAL; - client->scroll_pos = 0; - tui_render_screen(client); - return true; /* Key consumed */ - } else if (key == '\r' || key == '\n') { /* Enter */ - if (input[0] != '\0') { - message_t msg = { - .timestamp = time(NULL), - }; - if (strncmp(input, "/me ", 4) == 0 && input[4] != '\0') { - msg.username[0] = '*'; - msg.username[1] = '\0'; - int n = snprintf(msg.content, sizeof(msg.content), "%s %s", - client->username, input + 4); - if (n >= (int)sizeof(msg.content)) { - msg.content[sizeof(msg.content) - 1] = '\0'; - } - } else { - snprintf(msg.username, sizeof(msg.username), "%s", client->username); - snprintf(msg.content, sizeof(msg.content), "%s", input); - } - room_broadcast(g_room, &msg); - notify_mentions(msg.content, client); - message_save(&msg); - input[0] = '\0'; - } - tui_render_screen(client); - return true; /* Key consumed */ - } else if (key == 127 || key == 8) { /* Backspace */ - if (input[0] != '\0') { - utf8_remove_last_char(input); - tui_render_input(client, input); - } - return true; /* Key consumed */ - } else if (key == 23) { /* Ctrl+W (Delete Word) */ - if (input[0] != '\0') { - utf8_remove_last_word(input); - tui_render_input(client, input); - } - return true; - } else if (key == 21) { /* Ctrl+U (Delete Line) */ - if (input[0] != '\0') { - input[0] = '\0'; - tui_render_input(client, input); - } - return true; - } - break; - - 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; - } else if (key == ':') { - client->mode = MODE_COMMAND; - client->command_input[0] = '\0'; - tui_render_screen(client); - return true; - } else if (key == 'j') { - if (client->scroll_pos < nm_max_scroll) { - client->scroll_pos++; - tui_render_screen(client); - } - return true; - } else if (key == 'k' && client->scroll_pos > 0) { - client->scroll_pos--; - tui_render_screen(client); - 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; - } else if (key == 'G') { - client->scroll_pos = nm_max_scroll; - tui_render_screen(client); - return true; - } else if (key == '?') { - client->show_help = true; - client->help_scroll_pos = 0; - tui_render_help(client); - return true; - } - break; - } - - case MODE_COMMAND: - 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; - } else if (key == '\r' || key == '\n') { - commands_dispatch(client); - return true; /* Key consumed */ - } else if (key == 127 || key == 8) { /* Backspace */ - if (client->command_input[0] != '\0') { - utf8_remove_last_char(client->command_input); - tui_render_screen(client); - } - return true; /* Key consumed */ - } else if (key == 23) { /* Ctrl+W (Delete Word) */ - if (client->command_input[0] != '\0') { - utf8_remove_last_word(client->command_input); - tui_render_screen(client); - } - return true; - } else if (key == 21) { /* Ctrl+U (Delete Line) */ - if (client->command_input[0] != '\0') { - client->command_input[0] = '\0'; - tui_render_screen(client); - } - return true; - } - break; - - default: - break; - } - - return false; /* Key not consumed */ -} - -/* Handle client session */ -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; - client->help_lang = LANG_ZH; - client->connected = true; - client->command_history_count = 0; - client->command_history_pos = 0; - client->connect_time = time(NULL); - client->last_active = time(NULL); - - /* Check for exec command */ - if (client->exec_command[0] != '\0') { - int exit_status = exec_dispatch(client); - ssh_channel_request_send_exit_status(client->channel, exit_status); - goto cleanup; - } - - /* Read username */ - if (read_username(client) < 0) { - goto cleanup; - } - - /* Add to room */ - if (room_add_client(g_room, client) < 0) { - client_printf(client, "Room is full\n"); - goto cleanup; - } - joined_room = true; - - /* Broadcast join message */ - message_t join_msg = { - .timestamp = time(NULL), - }; - strncpy(join_msg.username, "系统", MAX_USERNAME_LEN - 1); - 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); - - /* Show MOTD if motd.txt exists in state directory */ - { - char motd_path[PATH_MAX]; - if (tnt_state_path(motd_path, sizeof(motd_path), "motd.txt") == 0) { - FILE *motd_fp = fopen(motd_path, "r"); - if (motd_fp) { - char motd_buf[sizeof(client->command_output) - 64]; - size_t motd_len = fread(motd_buf, 1, sizeof(motd_buf) - 1, motd_fp); - fclose(motd_fp); - if (motd_len > 0) { - motd_buf[motd_len] = '\0'; - snprintf(client->command_output, sizeof(client->command_output), - "=== 公告 / MOTD ===\n%s", motd_buf); - tui_render_command_output(client); - seen_update_seq = room_get_update_seq(g_room); - goto main_loop; - } - } - } - } - - /* Render initial screen */ - tui_render_screen(client); - seen_update_seq = room_get_update_seq(g_room); - -main_loop: - - /* Main input loop */ - while (client->connected && ssh_channel_is_open(client->channel)) { - int ready = ssh_channel_poll_timeout(client->channel, 1000, 0); - - 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); - } - - if (g_idle_timeout > 0 && joined_room && - time(NULL) - client->last_active >= g_idle_timeout) { - client_printf(client, "\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n", - g_idle_timeout / 60); - break; - } - continue; - } - - int n = ssh_channel_read(client->channel, buf, 1, 0); - - if (n <= 0) { - /* EOF or error */ - break; - } - - last_keepalive = time(NULL); - client->last_active = last_keepalive; - - unsigned char b = buf[0]; - - /* Handle special keys - returns true if key was consumed */ - bool key_consumed = handle_key(client, b, input); - - /* Only add character to input if not consumed by handle_key */ - if (!key_consumed) { - /* Add character to input (INSERT mode only) */ - if (client->mode == MODE_INSERT && !client->show_help && - client->command_output[0] == '\0') { - if (b >= 32 && b < 127) { /* ASCII printable */ - int len = strlen(input); - if (len < MAX_MESSAGE_LEN - 1) { - input[len] = b; - input[len + 1] = '\0'; - tui_render_input(client, input); - } - } else if (b >= 128) { /* UTF-8 multi-byte */ - int char_len = utf8_byte_length(b); - if (char_len <= 0 || char_len > 4) { - /* Invalid UTF-8 start byte */ - 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) { - /* Incomplete or timed-out UTF-8 continuation */ - continue; - } - } - /* Validate the complete UTF-8 sequence */ - if (!utf8_is_valid_sequence(buf, char_len)) { - /* Invalid UTF-8 sequence */ - continue; - } - int len = strlen(input); - if (len + char_len < MAX_MESSAGE_LEN - 1) { - memcpy(input + len, buf, char_len); - input[len + char_len] = '\0'; - tui_render_input(client, input); - } - } - } else if (client->mode == MODE_COMMAND && !client->show_help && - client->command_output[0] == '\0') { - if (b >= 32 && b < 127) { /* ASCII printable */ - size_t len = strlen(client->command_input); - if (len < sizeof(client->command_input) - 1) { - client->command_input[len] = b; - 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); - } - } - } - } - } - -cleanup: - /* Broadcast leave message */ - if (joined_room) { - message_t leave_msg = { - .timestamp = time(NULL), - }; - strncpy(leave_msg.username, "系统", MAX_USERNAME_LEN - 1); - leave_msg.username[MAX_USERNAME_LEN - 1] = '\0'; - snprintf(leave_msg.content, MAX_MESSAGE_LEN, "%s 离开了聊天室", client->username); - - client->connected = false; - room_remove_client(g_room, client); - room_broadcast(g_room, &leave_msg); - message_save(&leave_msg); - } - - ratelimit_release_ip(client->client_ip); - - /* Remove channel callbacks before releasing refs to prevent use-after-free - * if a callback fires between the two releases. */ - if (client->channel && client->channel_cb) { - ssh_remove_channel_callbacks(client->channel, client->channel_cb); - } - - /* Release the callback reference (paired with addref before client_install_channel_callbacks) */ - client_release(client); - - /* Release the main reference - client will be freed when all refs are gone */ - client_release(client); - - /* Decrement connection count */ - ratelimit_decrement_total(); - - return NULL; -} - static int client_channel_window_change(ssh_session session, ssh_channel channel, int width, int height, int pxwidth, int pxheight, @@ -909,7 +284,8 @@ int ssh_server_init(int port) { bootstrap_init(); /* Idle timeout stays here until input.c is extracted in PR2-M5 */ - g_idle_timeout = env_int("TNT_IDLE_TIMEOUT", DEFAULT_IDLE_TIMEOUT, 0, 86400); + /* Initialize idle-timeout subsystem */ + input_init(); g_listen_port = port; g_server_start_time = time(NULL);