Merge pull request #33 from m1ngsama/feat/consolidated-features-manpage-deploy
Some checks are pending
CI / build-and-test (macos-latest) (push) Waiting to run
CI / build-and-test (ubuntu-latest) (push) Waiting to run
Deploy / test (push) Waiting to run
Deploy / deploy (push) Blocked by required conditions

Consolidated: bug fixes, features, manpage, deploy prep
This commit is contained in:
m1ngsama 2026-04-19 17:50:14 +08:00 committed by GitHub
commit c7fa162bff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 393 additions and 63 deletions

View file

@ -45,9 +45,12 @@ clean:
install: $(TARGET)
install -d $(DESTDIR)/usr/local/bin
install -m 755 $(TARGET) $(DESTDIR)/usr/local/bin/
install -d $(DESTDIR)/usr/local/share/man/man1
install -m 644 tnt.1 $(DESTDIR)/usr/local/share/man/man1/
uninstall:
rm -f $(DESTDIR)/usr/local/bin/$(TARGET)
rm -f $(DESTDIR)/usr/local/share/man/man1/tnt.1
# Development targets
debug: CFLAGS += -g -DDEBUG

View file

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

View file

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

View file

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

View file

@ -1083,7 +1083,10 @@ static int execute_exec_command(client_t *client) {
/* Execute a command */
static void execute_command(client_t *client) {
char *cmd = client->command_input;
char cmd_buf[256];
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
cmd_buf[sizeof(cmd_buf) - 1] = '\0';
char *cmd = cmd_buf;
char output[2048] = {0};
size_t pos = 0;
@ -1098,6 +1101,21 @@ static void execute_command(client_t *client) {
}
}
/* Save to command history */
if (cmd[0] != '\0') {
int max_hist = 16;
if (client->command_history_count >= max_hist) {
memmove(&client->command_history[0], &client->command_history[1],
(max_hist - 1) * sizeof(client->command_history[0]));
client->command_history_count = max_hist - 1;
}
strncpy(client->command_history[client->command_history_count],
cmd, sizeof(client->command_history[0]) - 1);
client->command_history[client->command_history_count][sizeof(client->command_history[0]) - 1] = '\0';
client->command_history_count++;
client->command_history_pos = client->command_history_count;
}
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
strcmp(cmd, "who") == 0) {
buffer_appendf(output, sizeof(output), &pos,
@ -1130,10 +1148,58 @@ static void execute_command(client_t *client) {
" Available Commands\n"
"========================================\n"
"list, users, who - Show online users\n"
"msg/w <user> <text> - Whisper to user\n"
"help, commands - Show this help\n"
"clear, cls - Clear command output\n"
"q, quit, exit - Disconnect\n"
"Up/Down arrows - Command history\n"
"========================================\n");
} else if (strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
char *rest = (cmd[0] == 'w') ? cmd + 2 : cmd + 4;
while (*rest == ' ') rest++;
char target_name[MAX_USERNAME_LEN] = {0};
int ti = 0;
while (*rest && *rest != ' ' && ti < MAX_USERNAME_LEN - 1) {
target_name[ti++] = *rest++;
}
while (*rest == ' ') rest++;
if (target_name[0] == '\0' || rest[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos,
"Usage: msg <username> <message>\n"
" w <username> <message>\n");
} else {
bool found = false;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
char whisper[MAX_MESSAGE_LEN];
snprintf(whisper, sizeof(whisper),
"\r\n\033[35m[whisper from %s]: %s\033[0m\r\n",
client->username, rest);
client_send(g_room->clients[i], whisper, strlen(whisper));
g_room->clients[i]->redraw_pending = true;
found = true;
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
if (found) {
buffer_appendf(output, sizeof(output), &pos,
"Whisper sent to %s\n", target_name);
} else {
buffer_appendf(output, sizeof(output), &pos,
"User '%s' not found\n", target_name);
}
}
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
strcmp(cmd, "exit") == 0) {
client->connected = false;
return;
} else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) {
buffer_appendf(output, sizeof(output), &pos, "Command output cleared\n");
@ -1253,62 +1319,111 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
}
break;
case MODE_NORMAL:
case MODE_NORMAL: {
int nm_msg_count = room_get_message_count(g_room);
int nm_msg_height = client->height - 3;
if (nm_msg_height < 1) nm_msg_height = 1;
int nm_max_scroll = nm_msg_count - nm_msg_height;
if (nm_max_scroll < 0) nm_max_scroll = 0;
if (key == 'i') {
client->mode = MODE_INSERT;
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == ':') {
client->mode = MODE_COMMAND;
client->command_input[0] = '\0';
tui_render_screen(client);
return true; /* Key consumed - prevents double colon */
return true;
} else if (key == 'j') {
/* Get message count atomically to prevent TOCTOU */
int max_scroll = room_get_message_count(g_room);
int msg_height = client->height - 3;
if (msg_height < 1) msg_height = 1;
max_scroll = max_scroll - msg_height;
if (max_scroll < 0) max_scroll = 0;
if (client->scroll_pos < max_scroll) {
if (client->scroll_pos < nm_max_scroll) {
client->scroll_pos++;
tui_render_screen(client);
}
return true; /* Key consumed */
return true;
} else if (key == 'k' && client->scroll_pos > 0) {
client->scroll_pos--;
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == 4) { /* Ctrl+D: half page down */
int half = nm_msg_height / 2;
if (half < 1) half = 1;
client->scroll_pos += half;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
tui_render_screen(client);
return true;
} else if (key == 21) { /* Ctrl+U: half page up */
int half = nm_msg_height / 2;
if (half < 1) half = 1;
client->scroll_pos -= half;
if (client->scroll_pos < 0) client->scroll_pos = 0;
tui_render_screen(client);
return true;
} else if (key == 6) { /* Ctrl+F: full page down */
client->scroll_pos += nm_msg_height;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
tui_render_screen(client);
return true;
} else if (key == 2) { /* Ctrl+B: full page up */
client->scroll_pos -= nm_msg_height;
if (client->scroll_pos < 0) client->scroll_pos = 0;
tui_render_screen(client);
return true;
} else if (key == 'g') {
client->scroll_pos = 0;
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == 'G') {
/* Get message count atomically to prevent TOCTOU */
int max_scroll = room_get_message_count(g_room);
int msg_height = client->height - 3;
if (msg_height < 1) msg_height = 1;
max_scroll = max_scroll - msg_height;
if (max_scroll < 0) max_scroll = 0;
client->scroll_pos = max_scroll;
client->scroll_pos = nm_max_scroll;
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == '?') {
client->show_help = true;
client->help_scroll_pos = 0;
tui_render_help(client);
return true; /* Key consumed */
return true;
}
break;
}
case MODE_COMMAND:
if (key == 27) { /* ESC */
if (key == 27) { /* ESC - check for arrow key sequences */
char seq[2];
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
if (n == 1 && seq[0] == '[') {
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
if (n == 1) {
if (seq[1] == 'A') { /* Up arrow */
if (client->command_history_count > 0 &&
client->command_history_pos > 0) {
client->command_history_pos--;
strncpy(client->command_input,
client->command_history[client->command_history_pos],
sizeof(client->command_input) - 1);
client->command_input[sizeof(client->command_input) - 1] = '\0';
tui_render_screen(client);
}
return true;
} else if (seq[1] == 'B') { /* Down arrow */
if (client->command_history_pos < client->command_history_count - 1) {
client->command_history_pos++;
strncpy(client->command_input,
client->command_history[client->command_history_pos],
sizeof(client->command_input) - 1);
client->command_input[sizeof(client->command_input) - 1] = '\0';
} else {
client->command_history_pos = client->command_history_count;
client->command_input[0] = '\0';
}
tui_render_screen(client);
return true;
}
}
}
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return true; /* Key consumed */
return true;
} else if (key == '\r' || key == '\n') {
execute_command(client);
return true; /* Key consumed */
@ -1353,6 +1468,8 @@ void* client_handle_session(void *arg) {
client->mode = MODE_INSERT;
client->help_lang = LANG_ZH;
client->connected = true;
client->command_history_count = 0;
client->command_history_pos = 0;
/* Check for exec command */
if (client->exec_command[0] != '\0') {
@ -1381,6 +1498,7 @@ void* client_handle_session(void *arg) {
join_msg.username[MAX_USERNAME_LEN - 1] = '\0';
snprintf(join_msg.content, MAX_MESSAGE_LEN, "%s 加入了聊天室", client->username);
room_broadcast(g_room, &join_msg);
message_save(&join_msg);
/* Render initial screen */
tui_render_screen(client);
@ -1526,6 +1644,7 @@ cleanup:
client->connected = false;
room_remove_client(g_room, client);
room_broadcast(g_room, &leave_msg);
message_save(&leave_msg);
}
release_ip_connection(client->client_ip);
@ -1798,11 +1917,11 @@ static int client_channel_window_change(ssh_session session, ssh_channel channel
return SSH_ERROR;
}
pthread_mutex_lock(&client->io_lock);
client->width = width;
client->height = height;
sanitize_terminal_size(&client->width, &client->height);
pthread_mutex_unlock(&client->io_lock);
int w = width;
int h = height;
sanitize_terminal_size(&w, &h);
client->width = w;
client->height = h;
client->redraw_pending = true;
return SSH_OK;
}
@ -1974,9 +2093,11 @@ static void *bootstrap_client_session(void *arg) {
client->session = session;
client->channel = channel;
client->width = ctx->pty_width;
client->height = ctx->pty_height;
sanitize_terminal_size(&client->width, &client->height);
int init_w = ctx->pty_width;
int init_h = ctx->pty_height;
sanitize_terminal_size(&init_w, &init_h);
client->width = init_w;
client->height = init_h;
client->ref_count = 1;
pthread_mutex_init(&client->ref_lock, NULL);
pthread_mutex_init(&client->io_lock, NULL);

View file

@ -386,7 +386,7 @@ void tui_render_help(client_t *client) {
/* Help content */
const char *help_text = tui_get_help_text(client->help_lang);
char help_copy[4096];
char help_copy[8192];
strncpy(help_copy, help_text, sizeof(help_copy) - 1);
help_copy[sizeof(help_copy) - 1] = '\0';

View file

@ -45,7 +45,7 @@ TEST(room_add_message_single) {
chat_room_t *room = room_create();
message_t msg = make_msg("alice", "hello");
room_add_message(room, &msg);
room_broadcast(room, &msg);
assert(room->message_count == 1);
assert(strcmp(room->messages[0].username, "alice") == 0);
assert(strcmp(room->messages[0].content, "hello") == 0);
@ -60,7 +60,7 @@ TEST(room_add_message_overflow) {
char content[32];
snprintf(content, sizeof(content), "msg %d", i);
message_t msg = make_msg("user", content);
room_add_message(room, &msg);
room_broadcast(room, &msg);
}
assert(room->message_count == MAX_MESSAGES);
@ -94,7 +94,7 @@ TEST(room_broadcast_increments_seq) {
TEST(room_get_message_valid) {
chat_room_t *room = room_create();
message_t msg = make_msg("carol", "test");
room_add_message(room, &msg);
room_broadcast(room, &msg);
message_t out;
assert(room_get_message(room, 0, &out) == true);

210
tnt.1 Normal file
View file

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