refactor: extract input module (PR2-M5)

Move the interactive session — username read, vim-style key dispatcher,
main poll/redraw/keepalive/idle-timeout loop, the @mention bell, and
the tear-down path — out of ssh_server.c into a dedicated module.

New API (include/input.h):
- input_init()                 -- read TNT_IDLE_TIMEOUT once at startup
- input_run_session(client_t*) -- run the interactive session for an
                                  already-bootstrapped client_t,
                                  including exec_dispatch() short-circuit
                                  when client->exec_command is set
- notify_mentions(content, sender) -- moved from ssh_server.h since
                                  the INSERT-mode send path lives here
                                  too

Renamed: client_handle_session(void *arg) -> input_run_session(client_t *).
The old void* signature was a fossil from when this was a pthread entry;
bootstrap.c calls it synchronously inside its own detached thread.
bootstrap_run() now ends with `input_run_session(client); return NULL;`.

g_idle_timeout is now private to input.c; ssh_server_init() calls
input_init() instead of reading the env directly.

ssh_server.c shrinks from 1026 to 402 lines (-624) -- now down to just
the accept loop, ssh_server_init / ssh_server_start, host-key setup,
client_t I/O API (send/printf/addref/release), and the post-bootstrap
client_channel_* callbacks.  M6 will give those a proper home.

Behaviour is preserved: all moved code is byte-for-byte identical.
This commit is contained in:
m1ngsama 2026-05-17 10:01:48 +08:00
parent b5f9a17290
commit 3b8a1d18d8
7 changed files with 680 additions and 644 deletions

View file

@ -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);

31
include/input.h Normal file
View file

@ -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 */

View file

@ -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);

View file

@ -1,5 +1,6 @@
#include "bootstrap.h"
#include "common.h"
#include "input.h"
#include "ratelimit.h"
#include <arpa/inet.h>
#include <errno.h>
@ -487,6 +488,6 @@ void *bootstrap_run(void *arg) {
}
destroy_session_context(ctx);
client_handle_session(client);
input_run_session(client);
return NULL;
}

View file

@ -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 <string.h>
#include <time.h>
/* `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;

636
src/input.c Normal file
View file

@ -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 <libssh/callbacks.h>
#include <libssh/libssh.h>
#include <libssh/server.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
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();
}

View file

@ -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);