mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-05-10 19:00:57 +08:00
Compare commits
10 commits
0de13a6314
...
c7fa162bff
| Author | SHA1 | Date | |
|---|---|---|---|
| c7fa162bff | |||
| e10b43074c | |||
| 200e5a2f28 | |||
| 65cb5d79d7 | |||
| 83e964028a | |||
| ecaff81384 | |||
| 9607d8c2f2 | |||
| ecc45f285c | |||
| 8be6476367 | |||
| 9bbd5acd15 |
10 changed files with 671 additions and 81 deletions
11
Makefile
11
Makefile
|
|
@ -45,9 +45,12 @@ clean:
|
||||||
install: $(TARGET)
|
install: $(TARGET)
|
||||||
install -d $(DESTDIR)/usr/local/bin
|
install -d $(DESTDIR)/usr/local/bin
|
||||||
install -m 755 $(TARGET) $(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:
|
uninstall:
|
||||||
rm -f $(DESTDIR)/usr/local/bin/$(TARGET)
|
rm -f $(DESTDIR)/usr/local/bin/$(TARGET)
|
||||||
|
rm -f $(DESTDIR)/usr/local/share/man/man1/tnt.1
|
||||||
|
|
||||||
# Development targets
|
# Development targets
|
||||||
debug: CFLAGS += -g -DDEBUG
|
debug: CFLAGS += -g -DDEBUG
|
||||||
|
|
@ -71,10 +74,14 @@ check:
|
||||||
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
|
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
test: all
|
test: all unit-test
|
||||||
@echo "Running tests..."
|
@echo "Running integration tests..."
|
||||||
@cd tests && ./test_basic.sh
|
@cd tests && ./test_basic.sh
|
||||||
|
|
||||||
|
unit-test:
|
||||||
|
@echo "Running unit tests..."
|
||||||
|
@$(MAKE) -C tests/unit run
|
||||||
|
|
||||||
# Show build info
|
# Show build info
|
||||||
info:
|
info:
|
||||||
@echo "Compiler: $(CC)"
|
@echo "Compiler: $(CC)"
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ void room_remove_client(chat_room_t *room, struct client *client);
|
||||||
/* Broadcast message to all clients */
|
/* Broadcast message to all clients */
|
||||||
void room_broadcast(chat_room_t *room, const message_t *msg);
|
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) */
|
/* Get message by index (thread-safe value copy) */
|
||||||
bool room_get_message(chat_room_t *room, int index, message_t *out);
|
bool room_get_message(chat_room_t *room, int index, message_t *out);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,17 @@ typedef struct client {
|
||||||
ssh_channel channel; /* SSH channel */
|
ssh_channel channel; /* SSH channel */
|
||||||
char username[MAX_USERNAME_LEN];
|
char username[MAX_USERNAME_LEN];
|
||||||
char client_ip[INET6_ADDRSTRLEN];
|
char client_ip[INET6_ADDRSTRLEN];
|
||||||
int width;
|
_Atomic int width;
|
||||||
int height;
|
_Atomic int height;
|
||||||
client_mode_t mode;
|
client_mode_t mode;
|
||||||
help_lang_t help_lang;
|
help_lang_t help_lang;
|
||||||
int scroll_pos;
|
int scroll_pos;
|
||||||
int help_scroll_pos;
|
int help_scroll_pos;
|
||||||
bool show_help;
|
bool show_help;
|
||||||
char command_input[256];
|
char command_input[256];
|
||||||
|
char command_history[16][256];
|
||||||
|
int command_history_count;
|
||||||
|
int command_history_pos;
|
||||||
char command_output[2048];
|
char command_output[2048];
|
||||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||||
char ssh_login[MAX_USERNAME_LEN];
|
char ssh_login[MAX_USERNAME_LEN];
|
||||||
|
|
|
||||||
|
|
@ -87,23 +87,9 @@ void room_remove_client(chat_room_t *room, struct client *client) {
|
||||||
pthread_rwlock_unlock(&room->lock);
|
pthread_rwlock_unlock(&room->lock);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Broadcast message to all clients */
|
/* Add message to room history (caller must hold write lock) */
|
||||||
void room_broadcast(chat_room_t *room, const message_t *msg) {
|
static void room_add_message(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 */
|
|
||||||
|
|
||||||
if (room->message_count >= MAX_MESSAGES) {
|
if (room->message_count >= MAX_MESSAGES) {
|
||||||
/* Shift messages to make room */
|
|
||||||
memmove(&room->messages[0], &room->messages[1],
|
memmove(&room->messages[0], &room->messages[1],
|
||||||
(MAX_MESSAGES - 1) * sizeof(message_t));
|
(MAX_MESSAGES - 1) * sizeof(message_t));
|
||||||
room->message_count = MAX_MESSAGES - 1;
|
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;
|
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) */
|
/* Get message by index (thread-safe value copy) */
|
||||||
bool room_get_message(chat_room_t *room, int index, message_t *out) {
|
bool room_get_message(chat_room_t *room, int index, message_t *out) {
|
||||||
if (!room || !out) return false;
|
if (!room || !out) return false;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#ifndef _DEFAULT_SOURCE
|
#ifndef _DEFAULT_SOURCE
|
||||||
#define _DEFAULT_SOURCE /* for timegm() on glibc */
|
#define _DEFAULT_SOURCE /* for timegm() on glibc */
|
||||||
#endif
|
#endif
|
||||||
#ifdef __APPLE__
|
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
|
||||||
#define _DARWIN_C_SOURCE /* for timegm() on macOS */
|
#define _DARWIN_C_SOURCE /* for timegm() on macOS */
|
||||||
#endif
|
#endif
|
||||||
#include "message.h"
|
#include "message.h"
|
||||||
|
|
@ -61,7 +61,7 @@ int message_load(message_t **messages, int max_messages) {
|
||||||
}
|
}
|
||||||
|
|
||||||
long file_size = ftell(fp);
|
long file_size = ftell(fp);
|
||||||
if (file_size == 0) {
|
if (file_size <= 0) {
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
*messages = msg_array;
|
*messages = msg_array;
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
257
src/ssh_server.c
257
src/ssh_server.c
|
|
@ -108,16 +108,17 @@ static void buffer_append_bytes(char *buffer, size_t buf_size, size_t *pos,
|
||||||
buffer[*pos] = '\0';
|
buffer[*pos] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Constant-time string comparison to prevent timing side-channel attacks */
|
/* Constant-time string comparison to prevent timing side-channel attacks.
|
||||||
|
* Always iterates over the full length of the secret (b) to avoid leaking
|
||||||
|
* its length. When the input (a) is shorter, compares against zero bytes;
|
||||||
|
* the length mismatch is folded into the result separately. */
|
||||||
static bool constant_time_strcmp(const char *a, const char *b) {
|
static bool constant_time_strcmp(const char *a, const char *b) {
|
||||||
size_t len_a = strlen(a);
|
size_t len_a = strlen(a);
|
||||||
size_t len_b = strlen(b);
|
size_t len_b = strlen(b);
|
||||||
/* Use len_b (the secret) for iteration to avoid leaking its length
|
|
||||||
* through early termination. The XOR of lengths catches mismatches. */
|
|
||||||
volatile unsigned char result = (unsigned char)(len_a ^ len_b);
|
volatile unsigned char result = (unsigned char)(len_a ^ len_b);
|
||||||
size_t len = (len_a < len_b) ? len_a : len_b;
|
for (size_t i = 0; i < len_b; i++) {
|
||||||
for (size_t i = 0; i < len; i++) {
|
unsigned char ca = (i < len_a) ? (unsigned char)a[i] : 0;
|
||||||
result |= (unsigned char)((unsigned char)a[i] ^ (unsigned char)b[i]);
|
result |= ca ^ (unsigned char)b[i];
|
||||||
}
|
}
|
||||||
return result == 0;
|
return result == 0;
|
||||||
}
|
}
|
||||||
|
|
@ -194,8 +195,10 @@ static ip_rate_limit_t* get_rate_limit_entry(const char *ip) {
|
||||||
oldest_idx = i;
|
oldest_idx = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fprintf(stderr, "Warning: rate-limit table full, evicting active IP %s\n",
|
fprintf(stderr, "Warning: rate-limit table full, evicting active IP %s "
|
||||||
g_rate_limits[oldest_idx].ip);
|
"(%d active connections lost)\n",
|
||||||
|
g_rate_limits[oldest_idx].ip,
|
||||||
|
g_rate_limits[oldest_idx].active_connections);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reset and reuse */
|
/* Reset and reuse */
|
||||||
|
|
@ -487,7 +490,9 @@ int client_send(client_t *client, const char *data, size_t len) {
|
||||||
}
|
}
|
||||||
|
|
||||||
while (total < len) {
|
while (total < len) {
|
||||||
int sent = ssh_channel_write(client->channel, data + total, len - total);
|
size_t remaining = len - total;
|
||||||
|
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
|
||||||
|
int sent = ssh_channel_write(client->channel, data + total, chunk);
|
||||||
if (sent <= 0) {
|
if (sent <= 0) {
|
||||||
pthread_mutex_unlock(&client->io_lock);
|
pthread_mutex_unlock(&client->io_lock);
|
||||||
return -1;
|
return -1;
|
||||||
|
|
@ -584,9 +589,18 @@ static int read_username(client_t *client) {
|
||||||
break;
|
break;
|
||||||
} else if (b == 127 || b == 8) { /* Backspace */
|
} else if (b == 127 || b == 8) { /* Backspace */
|
||||||
if (pos > 0) {
|
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);
|
utf8_remove_last_char(username);
|
||||||
pos = strlen(username);
|
pos = strlen(username);
|
||||||
client_printf(client, "\b \b");
|
(void)old_pos;
|
||||||
|
for (int j = 0; j < w; j++)
|
||||||
|
client_printf(client, "\b \b");
|
||||||
}
|
}
|
||||||
} else if (b < 32) {
|
} else if (b < 32) {
|
||||||
/* Ignore control characters */
|
/* Ignore control characters */
|
||||||
|
|
@ -1069,7 +1083,10 @@ static int execute_exec_command(client_t *client) {
|
||||||
|
|
||||||
/* Execute a command */
|
/* Execute a command */
|
||||||
static void execute_command(client_t *client) {
|
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};
|
char output[2048] = {0};
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
|
|
||||||
|
|
@ -1084,6 +1101,21 @@ static void execute_command(client_t *client) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Save to command history */
|
||||||
|
if (cmd[0] != '\0') {
|
||||||
|
int max_hist = 16;
|
||||||
|
if (client->command_history_count >= max_hist) {
|
||||||
|
memmove(&client->command_history[0], &client->command_history[1],
|
||||||
|
(max_hist - 1) * sizeof(client->command_history[0]));
|
||||||
|
client->command_history_count = max_hist - 1;
|
||||||
|
}
|
||||||
|
strncpy(client->command_history[client->command_history_count],
|
||||||
|
cmd, sizeof(client->command_history[0]) - 1);
|
||||||
|
client->command_history[client->command_history_count][sizeof(client->command_history[0]) - 1] = '\0';
|
||||||
|
client->command_history_count++;
|
||||||
|
client->command_history_pos = client->command_history_count;
|
||||||
|
}
|
||||||
|
|
||||||
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
|
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
|
||||||
strcmp(cmd, "who") == 0) {
|
strcmp(cmd, "who") == 0) {
|
||||||
buffer_appendf(output, sizeof(output), &pos,
|
buffer_appendf(output, sizeof(output), &pos,
|
||||||
|
|
@ -1115,11 +1147,59 @@ static void execute_command(client_t *client) {
|
||||||
"========================================\n"
|
"========================================\n"
|
||||||
" Available Commands\n"
|
" Available Commands\n"
|
||||||
"========================================\n"
|
"========================================\n"
|
||||||
"list, users, who - Show online users\n"
|
"list, users, who - Show online users\n"
|
||||||
"help, commands - Show this help\n"
|
"msg/w <user> <text> - Whisper to user\n"
|
||||||
"clear, cls - Clear command output\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");
|
"========================================\n");
|
||||||
|
|
||||||
|
} else if (strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
|
||||||
|
char *rest = (cmd[0] == 'w') ? cmd + 2 : cmd + 4;
|
||||||
|
while (*rest == ' ') rest++;
|
||||||
|
char target_name[MAX_USERNAME_LEN] = {0};
|
||||||
|
int ti = 0;
|
||||||
|
while (*rest && *rest != ' ' && ti < MAX_USERNAME_LEN - 1) {
|
||||||
|
target_name[ti++] = *rest++;
|
||||||
|
}
|
||||||
|
while (*rest == ' ') rest++;
|
||||||
|
|
||||||
|
if (target_name[0] == '\0' || rest[0] == '\0') {
|
||||||
|
buffer_appendf(output, sizeof(output), &pos,
|
||||||
|
"Usage: msg <username> <message>\n"
|
||||||
|
" w <username> <message>\n");
|
||||||
|
} else {
|
||||||
|
bool found = false;
|
||||||
|
pthread_rwlock_rdlock(&g_room->lock);
|
||||||
|
for (int i = 0; i < g_room->client_count; i++) {
|
||||||
|
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
|
||||||
|
char whisper[MAX_MESSAGE_LEN];
|
||||||
|
snprintf(whisper, sizeof(whisper),
|
||||||
|
"\r\n\033[35m[whisper from %s]: %s\033[0m\r\n",
|
||||||
|
client->username, rest);
|
||||||
|
client_send(g_room->clients[i], whisper, strlen(whisper));
|
||||||
|
g_room->clients[i]->redraw_pending = true;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
buffer_appendf(output, sizeof(output), &pos,
|
||||||
|
"Whisper sent to %s\n", target_name);
|
||||||
|
} else {
|
||||||
|
buffer_appendf(output, sizeof(output), &pos,
|
||||||
|
"User '%s' not found\n", target_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
|
||||||
|
strcmp(cmd, "exit") == 0) {
|
||||||
|
client->connected = false;
|
||||||
|
return;
|
||||||
|
|
||||||
} else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) {
|
} else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) {
|
||||||
buffer_appendf(output, sizeof(output), &pos, "Command output cleared\n");
|
buffer_appendf(output, sizeof(output), &pos, "Command output cleared\n");
|
||||||
|
|
||||||
|
|
@ -1239,62 +1319,111 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||||
}
|
}
|
||||||
break;
|
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') {
|
if (key == 'i') {
|
||||||
client->mode = MODE_INSERT;
|
client->mode = MODE_INSERT;
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
return true; /* Key consumed */
|
return true;
|
||||||
} else if (key == ':') {
|
} else if (key == ':') {
|
||||||
client->mode = MODE_COMMAND;
|
client->mode = MODE_COMMAND;
|
||||||
client->command_input[0] = '\0';
|
client->command_input[0] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
return true; /* Key consumed - prevents double colon */
|
return true;
|
||||||
} else if (key == 'j') {
|
} else if (key == 'j') {
|
||||||
/* Get message count atomically to prevent TOCTOU */
|
if (client->scroll_pos < nm_max_scroll) {
|
||||||
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) {
|
|
||||||
client->scroll_pos++;
|
client->scroll_pos++;
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
}
|
}
|
||||||
return true; /* Key consumed */
|
return true;
|
||||||
} else if (key == 'k' && client->scroll_pos > 0) {
|
} else if (key == 'k' && client->scroll_pos > 0) {
|
||||||
client->scroll_pos--;
|
client->scroll_pos--;
|
||||||
tui_render_screen(client);
|
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') {
|
} else if (key == 'g') {
|
||||||
client->scroll_pos = 0;
|
client->scroll_pos = 0;
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
return true; /* Key consumed */
|
return true;
|
||||||
} else if (key == 'G') {
|
} else if (key == 'G') {
|
||||||
/* Get message count atomically to prevent TOCTOU */
|
client->scroll_pos = nm_max_scroll;
|
||||||
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;
|
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
return true; /* Key consumed */
|
return true;
|
||||||
} else if (key == '?') {
|
} else if (key == '?') {
|
||||||
client->show_help = true;
|
client->show_help = true;
|
||||||
client->help_scroll_pos = 0;
|
client->help_scroll_pos = 0;
|
||||||
tui_render_help(client);
|
tui_render_help(client);
|
||||||
return true; /* Key consumed */
|
return true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case MODE_COMMAND:
|
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->mode = MODE_NORMAL;
|
||||||
client->command_input[0] = '\0';
|
client->command_input[0] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
return true; /* Key consumed */
|
return true;
|
||||||
} else if (key == '\r' || key == '\n') {
|
} else if (key == '\r' || key == '\n') {
|
||||||
execute_command(client);
|
execute_command(client);
|
||||||
return true; /* Key consumed */
|
return true; /* Key consumed */
|
||||||
|
|
@ -1339,6 +1468,8 @@ void* client_handle_session(void *arg) {
|
||||||
client->mode = MODE_INSERT;
|
client->mode = MODE_INSERT;
|
||||||
client->help_lang = LANG_ZH;
|
client->help_lang = LANG_ZH;
|
||||||
client->connected = true;
|
client->connected = true;
|
||||||
|
client->command_history_count = 0;
|
||||||
|
client->command_history_pos = 0;
|
||||||
|
|
||||||
/* Check for exec command */
|
/* Check for exec command */
|
||||||
if (client->exec_command[0] != '\0') {
|
if (client->exec_command[0] != '\0') {
|
||||||
|
|
@ -1367,6 +1498,7 @@ void* client_handle_session(void *arg) {
|
||||||
join_msg.username[MAX_USERNAME_LEN - 1] = '\0';
|
join_msg.username[MAX_USERNAME_LEN - 1] = '\0';
|
||||||
snprintf(join_msg.content, MAX_MESSAGE_LEN, "%s 加入了聊天室", client->username);
|
snprintf(join_msg.content, MAX_MESSAGE_LEN, "%s 加入了聊天室", client->username);
|
||||||
room_broadcast(g_room, &join_msg);
|
room_broadcast(g_room, &join_msg);
|
||||||
|
message_save(&join_msg);
|
||||||
|
|
||||||
/* Render initial screen */
|
/* Render initial screen */
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
|
|
@ -1478,6 +1610,22 @@ void* client_handle_session(void *arg) {
|
||||||
client->command_input[len + 1] = '\0';
|
client->command_input[len + 1] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
}
|
}
|
||||||
|
} else if (b >= 128) { /* UTF-8 multi-byte */
|
||||||
|
int char_len = utf8_byte_length(b);
|
||||||
|
if (char_len <= 0 || char_len > 4) continue;
|
||||||
|
buf[0] = b;
|
||||||
|
if (char_len > 1) {
|
||||||
|
int read_bytes = ssh_channel_read_timeout(
|
||||||
|
client->channel, &buf[1], char_len - 1, 0, 5000);
|
||||||
|
if (read_bytes != char_len - 1) continue;
|
||||||
|
}
|
||||||
|
if (!utf8_is_valid_sequence(buf, char_len)) continue;
|
||||||
|
size_t len = strlen(client->command_input);
|
||||||
|
if (len + (size_t)char_len < sizeof(client->command_input) - 1) {
|
||||||
|
memcpy(client->command_input + len, buf, char_len);
|
||||||
|
client->command_input[len + char_len] = '\0';
|
||||||
|
tui_render_screen(client);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1496,6 +1644,7 @@ cleanup:
|
||||||
client->connected = false;
|
client->connected = false;
|
||||||
room_remove_client(g_room, client);
|
room_remove_client(g_room, client);
|
||||||
room_broadcast(g_room, &leave_msg);
|
room_broadcast(g_room, &leave_msg);
|
||||||
|
message_save(&leave_msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
release_ip_connection(client->client_ip);
|
release_ip_connection(client->client_ip);
|
||||||
|
|
@ -1768,9 +1917,11 @@ static int client_channel_window_change(ssh_session session, ssh_channel channel
|
||||||
return SSH_ERROR;
|
return SSH_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
client->width = width;
|
int w = width;
|
||||||
client->height = height;
|
int h = height;
|
||||||
sanitize_terminal_size(&client->width, &client->height);
|
sanitize_terminal_size(&w, &h);
|
||||||
|
client->width = w;
|
||||||
|
client->height = h;
|
||||||
client->redraw_pending = true;
|
client->redraw_pending = true;
|
||||||
return SSH_OK;
|
return SSH_OK;
|
||||||
}
|
}
|
||||||
|
|
@ -1911,7 +2062,7 @@ static void *bootstrap_client_session(void *arg) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time(NULL) - start_time > 30) {
|
if (time(NULL) - start_time > 10) {
|
||||||
timed_out = true;
|
timed_out = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1942,9 +2093,11 @@ static void *bootstrap_client_session(void *arg) {
|
||||||
|
|
||||||
client->session = session;
|
client->session = session;
|
||||||
client->channel = channel;
|
client->channel = channel;
|
||||||
client->width = ctx->pty_width;
|
int init_w = ctx->pty_width;
|
||||||
client->height = ctx->pty_height;
|
int init_h = ctx->pty_height;
|
||||||
sanitize_terminal_size(&client->width, &client->height);
|
sanitize_terminal_size(&init_w, &init_h);
|
||||||
|
client->width = init_w;
|
||||||
|
client->height = init_h;
|
||||||
client->ref_count = 1;
|
client->ref_count = 1;
|
||||||
pthread_mutex_init(&client->ref_lock, NULL);
|
pthread_mutex_init(&client->ref_lock, NULL);
|
||||||
pthread_mutex_init(&client->io_lock, NULL);
|
pthread_mutex_init(&client->io_lock, NULL);
|
||||||
|
|
@ -1969,10 +2122,12 @@ static void *bootstrap_client_session(void *arg) {
|
||||||
client_addref(client);
|
client_addref(client);
|
||||||
|
|
||||||
if (install_client_channel_callbacks(client) < 0) {
|
if (install_client_channel_callbacks(client) < 0) {
|
||||||
client_release(client); /* drop the callback ref */
|
/* Nullify session/channel ownership so client_release won't
|
||||||
pthread_mutex_destroy(&client->io_lock);
|
* double-free what cleanup_failed_session is about to free. */
|
||||||
pthread_mutex_destroy(&client->ref_lock);
|
client->session = NULL;
|
||||||
free(client);
|
client->channel = NULL;
|
||||||
|
client_release(client); /* drop the callback ref (2 → 1) */
|
||||||
|
client_release(client); /* drop the main ref (1 → 0, frees client) */
|
||||||
cleanup_failed_session(session, ctx);
|
cleanup_failed_session(session, ctx);
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ void tui_render_input(client_t *client, const char *input) {
|
||||||
int input_width = utf8_string_width(input);
|
int input_width = utf8_string_width(input);
|
||||||
|
|
||||||
/* Truncate from start if too long */
|
/* Truncate from start if too long */
|
||||||
char display[1024];
|
char display[MAX_MESSAGE_LEN];
|
||||||
strncpy(display, input, sizeof(display) - 1);
|
strncpy(display, input, sizeof(display) - 1);
|
||||||
display[sizeof(display) - 1] = '\0';
|
display[sizeof(display) - 1] = '\0';
|
||||||
|
|
||||||
|
|
@ -386,7 +386,7 @@ void tui_render_help(client_t *client) {
|
||||||
|
|
||||||
/* Help content */
|
/* Help content */
|
||||||
const char *help_text = tui_get_help_text(client->help_lang);
|
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);
|
strncpy(help_copy, help_text, sizeof(help_copy) - 1);
|
||||||
help_copy[sizeof(help_copy) - 1] = '\0';
|
help_copy[sizeof(help_copy) - 1] = '\0';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
# Unit Tests Makefile
|
# Unit Tests Makefile
|
||||||
CC = gcc
|
CC = gcc
|
||||||
CFLAGS = -Wall -Wextra -std=c11 -I../../include
|
CFLAGS = -Wall -Wextra -std=c11 -D_XOPEN_SOURCE=700 -I../../include
|
||||||
LDFLAGS = -pthread
|
LDFLAGS = -pthread
|
||||||
|
|
||||||
|
# Detect macOS for _DARWIN_C_SOURCE (needed for timegm)
|
||||||
|
UNAME_S := $(shell uname -s)
|
||||||
|
ifeq ($(UNAME_S),Darwin)
|
||||||
|
CFLAGS += -D_DARWIN_C_SOURCE
|
||||||
|
endif
|
||||||
|
|
||||||
# Source files
|
# Source files
|
||||||
UTF8_SRC = ../../src/utf8.c
|
UTF8_SRC = ../../src/utf8.c
|
||||||
MESSAGE_SRC = ../../src/message.c
|
MESSAGE_SRC = ../../src/message.c
|
||||||
|
COMMON_SRC = ../../src/common.c
|
||||||
|
CHAT_ROOM_SRC = ../../src/chat_room.c
|
||||||
|
|
||||||
TESTS = test_utf8 test_message
|
TESTS = test_utf8 test_message test_chat_room
|
||||||
|
|
||||||
.PHONY: all clean run
|
.PHONY: all clean run
|
||||||
|
|
||||||
|
|
@ -16,7 +24,10 @@ all: $(TESTS)
|
||||||
test_utf8: test_utf8.c $(UTF8_SRC)
|
test_utf8: test_utf8.c $(UTF8_SRC)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||||
|
|
||||||
test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC)
|
test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
|
||||||
|
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||||
|
|
||||||
|
test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||||
|
|
||||||
run: all
|
run: all
|
||||||
|
|
@ -25,6 +36,9 @@ run: all
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=== Running Message Tests ==="
|
@echo "=== Running Message Tests ==="
|
||||||
./test_message
|
./test_message
|
||||||
|
@echo ""
|
||||||
|
@echo "=== Running Chat Room Tests ==="
|
||||||
|
./test_chat_room
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(TESTS) *.o test_messages.log
|
rm -f $(TESTS) *.o test_messages.log
|
||||||
|
|
|
||||||
208
tests/unit/test_chat_room.c
Normal file
208
tests/unit/test_chat_room.c
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
/* Unit tests for chat_room functions */
|
||||||
|
|
||||||
|
/* Minimal client_t stub — only pointer identity matters for add/remove.
|
||||||
|
* We define `struct client` before including chat_room.h so the forward
|
||||||
|
* declaration resolves without pulling in ssh_server.h / libssh. */
|
||||||
|
#include "../../include/common.h"
|
||||||
|
|
||||||
|
struct client {
|
||||||
|
char username[MAX_USERNAME_LEN];
|
||||||
|
int dummy;
|
||||||
|
};
|
||||||
|
typedef struct client client_t;
|
||||||
|
|
||||||
|
#include "../../include/chat_room.h"
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <assert.h>
|
||||||
|
|
||||||
|
#define TEST(name) static void test_##name()
|
||||||
|
#define RUN_TEST(name) do { \
|
||||||
|
printf("Running %s... ", #name); \
|
||||||
|
test_##name(); \
|
||||||
|
printf("✓\n"); \
|
||||||
|
tests_passed++; \
|
||||||
|
} while(0)
|
||||||
|
|
||||||
|
static int tests_passed = 0;
|
||||||
|
|
||||||
|
static message_t make_msg(const char *user, const char *content) {
|
||||||
|
message_t m = { .timestamp = time(NULL) };
|
||||||
|
strncpy(m.username, user, MAX_USERNAME_LEN - 1);
|
||||||
|
strncpy(m.content, content, MAX_MESSAGE_LEN - 1);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_create_destroy) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
assert(room != NULL);
|
||||||
|
assert(room->client_count == 0);
|
||||||
|
assert(room->client_capacity > 0);
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_add_message_single) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
message_t msg = make_msg("alice", "hello");
|
||||||
|
|
||||||
|
room_broadcast(room, &msg);
|
||||||
|
assert(room->message_count == 1);
|
||||||
|
assert(strcmp(room->messages[0].username, "alice") == 0);
|
||||||
|
assert(strcmp(room->messages[0].content, "hello") == 0);
|
||||||
|
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_add_message_overflow) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
|
||||||
|
for (int i = 0; i < MAX_MESSAGES + 10; i++) {
|
||||||
|
char content[32];
|
||||||
|
snprintf(content, sizeof(content), "msg %d", i);
|
||||||
|
message_t msg = make_msg("user", content);
|
||||||
|
room_broadcast(room, &msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(room->message_count == MAX_MESSAGES);
|
||||||
|
|
||||||
|
char expected[32];
|
||||||
|
snprintf(expected, sizeof(expected), "msg %d", 10);
|
||||||
|
assert(strcmp(room->messages[0].content, expected) == 0);
|
||||||
|
|
||||||
|
snprintf(expected, sizeof(expected), "msg %d", MAX_MESSAGES + 9);
|
||||||
|
assert(strcmp(room->messages[MAX_MESSAGES - 1].content, expected) == 0);
|
||||||
|
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_broadcast_increments_seq) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
g_room = room;
|
||||||
|
|
||||||
|
uint64_t seq1 = room_get_update_seq(room);
|
||||||
|
message_t msg = make_msg("bob", "hi");
|
||||||
|
room_broadcast(room, &msg);
|
||||||
|
uint64_t seq2 = room_get_update_seq(room);
|
||||||
|
|
||||||
|
assert(seq2 > seq1);
|
||||||
|
assert(room_get_message_count(room) == 1);
|
||||||
|
|
||||||
|
g_room = NULL;
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_get_message_valid) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
message_t msg = make_msg("carol", "test");
|
||||||
|
room_broadcast(room, &msg);
|
||||||
|
|
||||||
|
message_t out;
|
||||||
|
assert(room_get_message(room, 0, &out) == true);
|
||||||
|
assert(strcmp(out.username, "carol") == 0);
|
||||||
|
assert(strcmp(out.content, "test") == 0);
|
||||||
|
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_get_message_invalid_index) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
|
||||||
|
message_t out;
|
||||||
|
assert(room_get_message(room, 0, &out) == false);
|
||||||
|
assert(room_get_message(room, -1, &out) == false);
|
||||||
|
assert(room_get_message(room, 999, &out) == false);
|
||||||
|
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_get_message_null_args) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
message_t out;
|
||||||
|
|
||||||
|
assert(room_get_message(NULL, 0, &out) == false);
|
||||||
|
assert(room_get_message(room, 0, NULL) == false);
|
||||||
|
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_client_count) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
assert(room_get_client_count(room) == 0);
|
||||||
|
|
||||||
|
client_t c1 = {0};
|
||||||
|
client_t c2 = {0};
|
||||||
|
assert(room_add_client(room, &c1) == 0);
|
||||||
|
assert(room_get_client_count(room) == 1);
|
||||||
|
assert(room_add_client(room, &c2) == 0);
|
||||||
|
assert(room_get_client_count(room) == 2);
|
||||||
|
|
||||||
|
room_remove_client(room, &c1);
|
||||||
|
assert(room_get_client_count(room) == 1);
|
||||||
|
|
||||||
|
room_remove_client(room, &c2);
|
||||||
|
assert(room_get_client_count(room) == 0);
|
||||||
|
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_remove_nonexistent_client) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
client_t c1 = {0};
|
||||||
|
client_t c2 = {0};
|
||||||
|
|
||||||
|
room_add_client(room, &c1);
|
||||||
|
room_remove_client(room, &c2);
|
||||||
|
assert(room_get_client_count(room) == 1);
|
||||||
|
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_add_client_full) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
client_t clients[MAX_CLIENTS + 1];
|
||||||
|
memset(clients, 0, sizeof(clients));
|
||||||
|
|
||||||
|
for (int i = 0; i < room->client_capacity; i++) {
|
||||||
|
assert(room_add_client(room, &clients[i]) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(room_add_client(room, &clients[room->client_capacity]) == -1);
|
||||||
|
assert(room_get_client_count(room) == room->client_capacity);
|
||||||
|
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(room_message_count_threadsafe) {
|
||||||
|
chat_room_t *room = room_create();
|
||||||
|
|
||||||
|
assert(room_get_message_count(room) == 0);
|
||||||
|
|
||||||
|
message_t msg = make_msg("dave", "msg");
|
||||||
|
room_broadcast(room, &msg);
|
||||||
|
assert(room_get_message_count(room) == 1);
|
||||||
|
|
||||||
|
room_broadcast(room, &msg);
|
||||||
|
room_broadcast(room, &msg);
|
||||||
|
assert(room_get_message_count(room) == 3);
|
||||||
|
|
||||||
|
room_destroy(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
printf("=== Chat Room Unit Tests ===\n");
|
||||||
|
|
||||||
|
RUN_TEST(room_create_destroy);
|
||||||
|
RUN_TEST(room_add_message_single);
|
||||||
|
RUN_TEST(room_add_message_overflow);
|
||||||
|
RUN_TEST(room_broadcast_increments_seq);
|
||||||
|
RUN_TEST(room_get_message_valid);
|
||||||
|
RUN_TEST(room_get_message_invalid_index);
|
||||||
|
RUN_TEST(room_get_message_null_args);
|
||||||
|
RUN_TEST(room_client_count);
|
||||||
|
RUN_TEST(room_remove_nonexistent_client);
|
||||||
|
RUN_TEST(room_add_client_full);
|
||||||
|
RUN_TEST(room_message_count_threadsafe);
|
||||||
|
|
||||||
|
printf("\nAll %d tests passed!\n", tests_passed);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
210
tnt.1
Normal file
210
tnt.1
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
.\" tnt(1) - Terminal Network Talk
|
||||||
|
.TH TNT 1 "April 2026" "TNT 1.0.0" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
tnt \- anonymous SSH chat server with Vim\-style TUI
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B tnt
|
||||||
|
.RB [ \-p
|
||||||
|
.IR port ]
|
||||||
|
.RB [ \-d
|
||||||
|
.IR dir ]
|
||||||
|
.RB [ \-h ]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
.B tnt
|
||||||
|
is a multi\-user anonymous chat server accessed over SSH.
|
||||||
|
It provides a Vim\-style terminal user interface with INSERT, NORMAL, and
|
||||||
|
COMMAND modes.
|
||||||
|
Users connect with any standard SSH client; no account or registration is needed.
|
||||||
|
.PP
|
||||||
|
Messages are persisted to a log file and restored on server restart.
|
||||||
|
The server supports CJK and emoji input, rate limiting, access tokens, and
|
||||||
|
a non\-interactive exec interface for scripting.
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
.BI \-p " port"
|
||||||
|
Listen on
|
||||||
|
.I port
|
||||||
|
instead of the default 2222.
|
||||||
|
Overrides the
|
||||||
|
.B PORT
|
||||||
|
environment variable.
|
||||||
|
.TP
|
||||||
|
.BI \-d " dir"
|
||||||
|
Store the host key and message log in
|
||||||
|
.IR dir .
|
||||||
|
Overrides the
|
||||||
|
.B TNT_STATE_DIR
|
||||||
|
environment variable.
|
||||||
|
Defaults to the current working directory.
|
||||||
|
.TP
|
||||||
|
.B \-h
|
||||||
|
Print a short usage summary and exit.
|
||||||
|
.SH CONNECTING
|
||||||
|
.PP
|
||||||
|
.nf
|
||||||
|
ssh any\-username@hostname \-p 2222
|
||||||
|
.fi
|
||||||
|
.PP
|
||||||
|
If an access token is configured, supply it as the SSH password.
|
||||||
|
The username entered in the SSH handshake is ignored; a chat\-room
|
||||||
|
nickname is chosen interactively after login.
|
||||||
|
.SH MODES
|
||||||
|
.TP
|
||||||
|
.B INSERT
|
||||||
|
Type and send messages.
|
||||||
|
Press
|
||||||
|
.B Enter
|
||||||
|
to send,
|
||||||
|
.B ESC
|
||||||
|
to switch to NORMAL mode.
|
||||||
|
.TP
|
||||||
|
.B NORMAL
|
||||||
|
Scroll through chat history with Vim keybindings.
|
||||||
|
Press
|
||||||
|
.B i
|
||||||
|
to return to INSERT,
|
||||||
|
.B :
|
||||||
|
to enter COMMAND mode,
|
||||||
|
.B ?
|
||||||
|
to open the help screen.
|
||||||
|
.TP
|
||||||
|
.B COMMAND
|
||||||
|
Execute commands prefixed with
|
||||||
|
.BR : .
|
||||||
|
.SH KEYBINDINGS
|
||||||
|
.SS INSERT mode
|
||||||
|
.TS
|
||||||
|
l l.
|
||||||
|
Enter Send message
|
||||||
|
ESC Switch to NORMAL
|
||||||
|
Ctrl+W Delete last word
|
||||||
|
Ctrl+U Clear input line
|
||||||
|
Ctrl+C Switch to NORMAL
|
||||||
|
.TE
|
||||||
|
.SS NORMAL mode
|
||||||
|
.TS
|
||||||
|
l l.
|
||||||
|
j/k Scroll down/up one line
|
||||||
|
Ctrl+D/Ctrl+U Scroll half page down/up
|
||||||
|
Ctrl+F/Ctrl+B Scroll full page down/up
|
||||||
|
g/G Jump to top/bottom
|
||||||
|
i Switch to INSERT
|
||||||
|
: Enter COMMAND mode
|
||||||
|
? Open help screen
|
||||||
|
Ctrl+C Disconnect
|
||||||
|
.TE
|
||||||
|
.SS COMMAND mode
|
||||||
|
.TS
|
||||||
|
l l.
|
||||||
|
:list Show online users
|
||||||
|
:msg \fIuser text\fR Send private whisper
|
||||||
|
:w \fIuser text\fR Short alias for :msg
|
||||||
|
:help Show available commands
|
||||||
|
:clear Clear command output
|
||||||
|
:q, :quit, :exit Disconnect
|
||||||
|
Up/Down Browse command history
|
||||||
|
ESC Cancel and return to NORMAL
|
||||||
|
.TE
|
||||||
|
.SH EXEC INTERFACE
|
||||||
|
Commands can be run non\-interactively for scripting:
|
||||||
|
.PP
|
||||||
|
.nf
|
||||||
|
ssh host \-p 2222 help
|
||||||
|
ssh host \-p 2222 users \-\-json
|
||||||
|
ssh host \-p 2222 stats \-\-json
|
||||||
|
ssh host \-p 2222 tail 20
|
||||||
|
ssh host \-p 2222 post "Hello from a script"
|
||||||
|
ssh host \-p 2222 health
|
||||||
|
.fi
|
||||||
|
.PP
|
||||||
|
Exit codes follow
|
||||||
|
.BR sysexits (3)
|
||||||
|
conventions.
|
||||||
|
.SH ENVIRONMENT
|
||||||
|
.TP
|
||||||
|
.B PORT
|
||||||
|
Default listening port (default: 2222).
|
||||||
|
.TP
|
||||||
|
.B TNT_STATE_DIR
|
||||||
|
Directory for host key and message log (default: current directory).
|
||||||
|
.TP
|
||||||
|
.B TNT_ACCESS_TOKEN
|
||||||
|
If set, clients must supply this string as their SSH password.
|
||||||
|
Compared in constant time.
|
||||||
|
.TP
|
||||||
|
.B TNT_MAX_CONNECTIONS
|
||||||
|
Global connection limit (default: 64, max: 1024).
|
||||||
|
.TP
|
||||||
|
.B TNT_MAX_CONN_PER_IP
|
||||||
|
Max concurrent sessions from one IP (default: 5).
|
||||||
|
.TP
|
||||||
|
.B TNT_MAX_CONN_RATE_PER_IP
|
||||||
|
Max new connections per IP per 60\-second window (default: 10).
|
||||||
|
.TP
|
||||||
|
.B TNT_RATE_LIMIT
|
||||||
|
Set to 0 to disable rate\-based blocking and auth\-failure IP blocking.
|
||||||
|
Explicit capacity limits still apply (default: 1).
|
||||||
|
.SH FILES
|
||||||
|
.TP
|
||||||
|
.I messages.log
|
||||||
|
Chat history in RFC\ 3339 pipe\-delimited format
|
||||||
|
.RI ( timestamp | username | content ).
|
||||||
|
Stored in the state directory.
|
||||||
|
.TP
|
||||||
|
.I host_key
|
||||||
|
RSA 4096\-bit host key, auto\-generated on first run.
|
||||||
|
Stored in the state directory with mode 0600.
|
||||||
|
.SH SYSTEMD
|
||||||
|
A unit file
|
||||||
|
.I tnt.service
|
||||||
|
is provided.
|
||||||
|
Typical setup:
|
||||||
|
.PP
|
||||||
|
.nf
|
||||||
|
sudo useradd \-r \-s /bin/false tnt
|
||||||
|
sudo cp tnt.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon\-reload
|
||||||
|
sudo systemctl enable \-\-now tnt
|
||||||
|
.fi
|
||||||
|
.PP
|
||||||
|
Runtime overrides can be placed in
|
||||||
|
.IR /etc/default/tnt .
|
||||||
|
.SH SECURITY
|
||||||
|
.IP \(bu 2
|
||||||
|
Reference\-counted client lifecycle prevents use\-after\-free.
|
||||||
|
.IP \(bu 2
|
||||||
|
Per\-IP rate limiting with auth\-failure blocking (5 failures = 5\-minute ban).
|
||||||
|
.IP \(bu 2
|
||||||
|
Access\-token comparison uses constant\-time algorithm.
|
||||||
|
.IP \(bu 2
|
||||||
|
Host key created with restrictive permissions (0600).
|
||||||
|
.IP \(bu 2
|
||||||
|
systemd hardening: NoNewPrivileges, PrivateTmp, ProtectSystem=strict.
|
||||||
|
.SH EXAMPLES
|
||||||
|
Start on port 3000 with state in /var/lib/tnt:
|
||||||
|
.PP
|
||||||
|
.nf
|
||||||
|
tnt \-p 3000 \-d /var/lib/tnt
|
||||||
|
.fi
|
||||||
|
.PP
|
||||||
|
Start with an access token:
|
||||||
|
.PP
|
||||||
|
.nf
|
||||||
|
TNT_ACCESS_TOKEN=s3cret tnt
|
||||||
|
.fi
|
||||||
|
.PP
|
||||||
|
Connect from another machine:
|
||||||
|
.PP
|
||||||
|
.nf
|
||||||
|
ssh user@chat.example.com \-p 2222
|
||||||
|
.fi
|
||||||
|
.SH AUTHORS
|
||||||
|
m1ngsama <contact@m1ng.space>
|
||||||
|
.SH BUGS
|
||||||
|
Report bugs at
|
||||||
|
.UR https://github.com/m1ngsama/TNT/issues
|
||||||
|
.UE .
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR ssh (1),
|
||||||
|
.BR sshd (8),
|
||||||
|
.BR systemctl (1)
|
||||||
Loading…
Reference in a new issue