Compare commits

...

12 commits

Author SHA1 Message Date
d3ebe25973 tui: dedicated MOTD renderer (M7-5)
Some checks failed
CI / build-and-test (macos-latest) (push) Has been cancelled
CI / build-and-test (ubuntu-latest) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
The MOTD used to ride on tui_render_command_output's coattails — text
got stuffed into client->command_output prefixed by "=== 公告 / MOTD ==="
and rendered under a reverse-video " COMMAND OUTPUT " title bar.  Two
different things (a transient command result vs. a one-shot service
notice) wearing the same chrome.

Now MOTD has its own renderer with its own aesthetic:

    ╭─ 公告 / MOTD ────────────────────────────────╮

      欢迎来到 TNT 公共聊天室。
      请互相尊重,不要刷屏。
      管理员:m1ng

    ╰─ 按任意键继续 / press any key ────────────────╯

- title chip embedded in the top border (bright cyan)
- footer hint embedded in the bottom border (dim grey)
- 2-column left padding on body lines, blank top/bottom pad rows
- dim cyan borders, no full-line reverse anywhere

Wiring:
- new tui_render_motd() declared in tui.h
- new client_t.show_motd flag selects the renderer; command_output
  remains the text storage (no extra buffer needed)
- input.c MOTD path sets show_motd = true and calls tui_render_motd()
- handle_key's dismiss path clears show_motd alongside command_output
- main-loop redraw dispatch checks show_motd before command_output[0]
2026-05-17 13:16:37 +08:00
2610bba76d tui: dim date divider between messages from different days (M7-4)
When the rendered message snapshot crosses a date boundary, insert a
single dim grey divider line before the first message of each new day:

    ── 2026-05-14 ──────────────────────────────────────────
    16:00  alice: 早!
    16:01  bob: 早呀
    ── 2026-05-15 ──────────────────────────────────────────
    04:30  alice: 晚上一起吃饭?
    ...

The divider also fires for the *first* visible date, so the user always
knows what day the top of their viewport belongs to.

Dividers and messages share the fixed msg_height budget — dividers
don't push other content off the bottom.  In the worst case a viewport
with one message per day shows half the rows as dividers, which is the
intended trade for readability.

Real win for a long-lived public chat where messages span many days
and timestamps alone only show HH:MM.
2026-05-17 13:10:24 +08:00
fdef16ae8e tui: refined status / prompt line (M7-3)
NORMAL mode status line:
  before: "-- NORMAL -- (3/100)  ↓ 5 new"
  after : "[reverse-yellow NORMAL]  3 / 100   ▼ 5 new"
- the mode is now a small reverse-video yellow chip so it visually
  echoes the mode colour in the title bar
- position is dim grey with em-spaced slash, "▼" replaces "↓" so the
  unseen-message marker matches the same downward-triangle vocabulary
  the help screen uses

INSERT mode prompt:
  before: "> "
  after : "› " (U+203A, dim grey)
- single-glyph chevron is quieter than the ASCII ">", aligns with the
  thinner aesthetic of the new title bar
- applied both in tui_render_screen() and tui_render_input() so
  per-keystroke redraws match the initial paint

COMMAND mode prompt:
  before: ":foo"
  after : "[magenta :]foo"
- the leading colon picks up the COMMAND mode colour so it reads as
  "you are in command mode now" rather than as part of the typed text
2026-05-17 12:56:21 +08:00
e2b16cf477 tui: lighter, segmented title bar (M7-2)
Replace the full-width reverse-video title

    ▍ tester | 在线: 3 | 模式: INSERT | ? 帮助 ▎  (reversed)

with a single line of small chips on a normal background:

     tester · 在线 3 · INSERT                  ? 帮助

- username is bold white, online count is plain
- mode name carries its own colour, so glance + colour disambiguates
  it (INSERT cyan, NORMAL yellow, COMMAND magenta, HELP blue) without
  having to read the word
- separators are dim grey middle-dots so the eye groups segments
- mute marker shrinks from "[静音]" to a dim "静音" suffix when active
- hint "? 帮助" is right-aligned in dim grey, far from content

The horizontal rule below the chat already provides the visual divider
that the reverse-video bar used to provide, so the bar can drop the
heaviness without losing structure.
2026-05-17 12:54:27 +08:00
159073ec49 tui: framed welcome banner before username prompt (M7-1)
Replace the four lines of `==` rules + bilingual title with a centred
rounded box so the eye lands on a single visual element instead of two.

The new tui_render_welcome():
- centres horizontally and vertically (about a third of the way down)
- shows TNT + version, then 中文 subtitle, then English caption with
  decreasing visual weight (bold cyan / default / dim grey)
- falls back to a plain "TNT $version — anonymous chat over SSH" line
  when the terminal is too narrow for the frame
- declared in tui.h, called from input.c read_username()

read_username()'s prompt becomes a single short bilingual hint
("请输入用户名 (留空 anonymous): ") that lives below the box.
2026-05-17 10:48:27 +08:00
7897d8820e refactor: extract client module (PR2-M6)
Move client_t I/O and lifecycle out of ssh_server.c into a dedicated
module.  ssh_server.c is now down to the listening socket, host key
setup, ssh_server_init / ssh_server_start, and the accept loop.

Migrated to client.{c,h}:
- client_send, client_printf
- client_addref, client_release (and the ssh_session / ssh_channel /
  channel_cb teardown that fires on the final release)
- client_install_channel_callbacks
- client_channel_window_change / _eof / _close (the post-bootstrap
  callbacks that target the client_t)

The client_t struct definition stays in ssh_server.h so we don't have
to revisit every existing #include chain.  bootstrap, commands, exec,
input, and tui pick up the new client.h alongside their existing
ssh_server.h include.

ssh_server.c shrinks from 402 to 249 lines (-153).

Final per-module size after PR2:
  ssh_server.c   249  accept loop + ssh_server_init/start + host key
  bootstrap.c    493  per-connection SSH handshake / auth / channel
  client.c       162  client_t I/O + lifecycle + channel callbacks
  input.c        637  username read + handle_key + main loop +
                      notify_mentions + idle timeout
  commands.c     269  vim ':' command dispatcher
  exec.c         453  SSH exec subcommand dispatcher
  ratelimit.c    197  IP rate limit + connection counters
  tui.c          614  screen rendering
  chat_room.c    151  room + client list
  message.c      350  message log load/save/search
  utf8.c         250  UTF-8 width / validation
  common.c       133  buffer_*, env_int, is_valid_username,
                      sanitize_terminal_size, tnt_state_*
  main.c         109  process entry

Total: ~4 000 lines of C across 13 files, no file over 650 lines, every
file is single-purpose.  Behaviour is preserved: the migrated code is
byte-for-byte identical.
2026-05-17 10:16:27 +08:00
3b8a1d18d8 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.
2026-05-17 10:01:48 +08:00
b5f9a17290 refactor: extract bootstrap module (PR2-M4)
Move the per-connection SSH bootstrap pipeline -- key exchange, auth,
channel open + PTY/shell-or-exec request, and the hand-off into a
client_t -- out of ssh_server.c into a dedicated module.

Migrated to bootstrap.{c,h}:
- session_context_t (now private to bootstrap.c)
- accepted_session_t (declared in bootstrap.h, the IPC envelope from
  the accept loop into the bootstrap thread)
- TNT_ACCESS_TOKEN handling: g_access_token + bootstrap_init()
- constant_time_strcmp (auth-only utility)
- bootstrap_peer_ip (peer IP read from libssh fd)
- auth_password / auth_none / auth_pubkey
- destroy_session_context, cleanup_failed_session
- channel_open_request_session, channel_pty_request,
  channel_pty_window_change, channel_shell_request, channel_exec_request
- setup_session_channel_callbacks
- bootstrap_run (formerly bootstrap_client_session, the pthread entry)

Stayed in ssh_server.c:
- accept loop in ssh_server_start (now calls bootstrap_peer_ip and
  pthread_create(bootstrap_run))
- ssh_server_init (now calls ratelimit_init() + bootstrap_init() +
  reads only g_idle_timeout / TNT_BIND_ADDR / TNT_SSH_LOG_LEVEL)
- client_send/printf/addref/release, notify_mentions
- client_channel_window_change/eof/close (post-bootstrap, target client_t)
- client_install_channel_callbacks (renamed from
  install_client_channel_callbacks, now non-static and exposed via
  ssh_server.h so bootstrap.c can install them on the new client_t)
- read_username, handle_key, client_handle_session (will move to
  input.c in PR2-M5)
- setup_host_key, ssh_server_start_time

Two helpers also lifted: sanitize_terminal_size moved to common.c (used
by the bootstrap PTY callback and the post-bootstrap window-change
callback), and is_valid_username already lived there from M2.

ssh_server.c shrinks from 1513 to 1026 lines (-487).
Behaviour is preserved: implementations are byte-for-byte the same.
2026-05-17 09:47:28 +08:00
8aa34c75b8 refactor: extract commands module (PR2-M3)
Move the vim-mode `:` command dispatcher (`:list`, `:nick`, `:msg`,
`:last`, `:search`, `:mute-joins`, `:help`, `:clear`, `:q`, …) out of
ssh_server.c into a dedicated module.

New API (include/commands.h):
- commands_dispatch(client_t *)  -- single entry point invoked from
  handle_key when Enter is pressed in MODE_COMMAND.

ssh_server.c shrinks from 1769 to 1513 lines (-256).
Behaviour preserved: implementation is byte-for-byte the same.
2026-05-17 09:26:48 +08:00
7f9babf4f4 refactor: extract exec module (PR2-M2)
Move the SSH exec subcommand interface (help, health, users, stats,
tail, post) and the dispatcher out of ssh_server.c into a dedicated
module.

New API (include/exec.h):
- exec_dispatch(client_t *)  -- single entry point invoked from the
  bootstrap path when client->exec_command[0] != '\0'.

Helpers that travel with the exec subcommands:
- format_timestamp_utc, trim_ascii_whitespace, json_append_string,
  resolve_exec_username, parse_tail_count

Two cross-module bridges:
- is_valid_username() lifted into common.c/h since exec, the input
  read path, and the :nick command all need it.
- ssh_server_start_time() added to ssh_server.h as a read-only
  accessor; exec_command_stats no longer reaches into the global.
- notify_mentions stays in ssh_server.c for now and is exposed via
  ssh_server.h.  Will move to a dedicated client.c during PR2-M6.

ssh_server.c shrinks from 2200 to 1769 lines (-431).
Behaviour is preserved: implementations are byte-for-byte the same.
2026-05-17 08:49:58 +08:00
562ee5296d refactor: extract ratelimit module (PR2-M1)
Move IP rate-limiting, auth-failure tracking, and global connection
counting out of ssh_server.c into a dedicated module.

New API (include/ratelimit.h):
- ratelimit_init()
- ratelimit_check_ip() / ratelimit_release_ip()
- ratelimit_record_auth_failure()
- ratelimit_check_and_increment_total() / ratelimit_decrement_total()
- ratelimit_get_active_total()  (replaces the direct g_total_connections
  read that exec_command_stats was doing under g_conn_count_lock)

env_int() also moves up to common.{c,h} since multiple modules need it.

ssh_server.c drops from 2469 to 2200 lines.  Behaviour is preserved:
the new functions are byte-for-byte the same implementations, only the
file boundary moved.

g_idle_timeout and g_access_token reads stay inline in ssh_server_init()
for now; they will follow the auth.c and input.c extractions later.
2026-05-16 23:06:56 +08:00
d9382882d1 chore: bug fixes and code cleanup
Fixes:
- message_load() now holds g_message_file_lock for the read, so :last [N]
  can no longer observe a half-written line while message_save() is
  flushing.
- constant_time_strcmp() accumulates the length difference in size_t.
  The old code truncated to unsigned char, which collapsed pairs whose
  lengths differed by a multiple of 256 down to 0 and lost the signal.

Refactor:
- buffer_appendf() / buffer_append_bytes() moved to common.c; the two
  identical copies in ssh_server.c and tui.c have been removed.

Docs / cleanup:
- README clarifies that exec 'post' uses the SSH login name as the
  author and that anonymous mode performs no identity check.
- Removed TODO.md (both items completed) and docs/README.old.
- Trimmed the auto-generated 2025 entry block from docs/CHANGELOG.md
  and added a 2026-05-16 entry summarising this change.
2026-05-16 22:44:41 +08:00
23 changed files with 2871 additions and 2505 deletions

View file

@ -166,6 +166,8 @@ ssh -p 2222 operator@chat.m1ng.space post "service notice"
ssh -p 2222 chat.m1ng.space post "/me deploys v2.0"
```
**`post` identity**: the message is attributed to the SSH login name (the `user@` part of the URL, falling back to `anonymous`). In the default anonymous-access configuration there is no identity check, so any client can post as any name. Set `TNT_ACCESS_TOKEN` if you need authenticated posting.
## Development
### Building

12
TODO.md
View file

@ -1,12 +0,0 @@
# TODO
## Maintenance
- [x] Replace deprecated `libssh` functions in `src/ssh_server.c`:
- ~~`ssh_message_auth_password`~~`auth_password_function` callback (✓ completed)
- ~~`ssh_message_channel_request_pty_width/height`~~`channel_pty_request_function` callback (✓ completed)
- Migrated to callback-based server API as of libssh 0.9+
## Future Features
- [x] Implement robust command handling for non-interactive SSH exec requests.
- Basic exec support completed (handles `exit` command)
- All tests passing

View file

@ -1,5 +1,22 @@
# Changelog
## 2026-05-16 - Internal cleanup
### Fixed
- `message_load()` now holds `g_message_file_lock` for the duration of the read.
Previously `:last [N]` could race with `message_save()` and observe a
half-written line.
- `constant_time_strcmp()` accumulates the length difference in `size_t` instead
of `unsigned char`. The old code lost the length-mismatch signal when the
two lengths differed by a multiple of 256.
### Changed
- `buffer_appendf()` and `buffer_append_bytes()` moved to `common.c`; the two
identical copies in `ssh_server.c` and `tui.c` have been removed.
- Removed `TODO.md` (both items completed) and `docs/README.old` (superseded by
the root `README.md`).
- Trimmed the auto-generated 2025 entry block from this changelog.
## 2026-04-23 - Chat UX Commands and MOTD
### Added
@ -108,73 +125,3 @@ All changes maintain backward compatibility. Server remains open by default.
### Changed
- Improved error handling throughout
- Better memory management in message loading
## 2025
- Ongoing development and improvements
- Bug fixes and optimizations
- Feature enhancements
- Optimize performance (2025-01-10)
- Code cleanup (2025-01-15)
- Code cleanup (2025-01-17)
- Add minor improvements (2025-01-22)
- Code cleanup (2025-01-28)
- Fix edge cases (2025-02-03)
- Update documentation (2025-02-06)
- Fix edge cases (2025-02-07)
- Add minor improvements (2025-02-26)
- Update dependencies (2025-02-27)
- Fix edge cases (2025-03-01)
- Fix bugs and improve stability (2025-03-06)
- Fix bugs and improve stability (2025-03-12)
- Minor fixes (2025-03-17)
- Add minor improvements (2025-03-18)
- Refactor code structure (2025-03-24)
- Update dependencies (2025-03-27)
- Improve error handling (2025-03-28)
- Improve error handling (2025-04-03)
- Update documentation (2025-04-07)
- Update documentation (2025-04-13)
- Code cleanup (2025-04-15)
- Fix bugs and improve stability (2025-04-16)
- Add minor improvements (2025-04-17)
- Minor fixes (2025-04-23)
- Code cleanup (2025-04-24)
- Fix edge cases (2025-04-25)
- Refactor code structure (2025-05-13)
- Fix edge cases (2025-05-14)
- Minor fixes (2025-06-03)
- Code cleanup (2025-06-05)
- Add minor improvements (2025-06-10)
- Fix bugs and improve stability (2025-06-18)
- Update dependencies (2025-06-24)
- Optimize performance (2025-06-30)
- Update documentation (2025-07-07)
- Refactor code structure (2025-07-17)
- Fix bugs and improve stability (2025-07-19)
- Refactor code structure (2025-07-21)
- Code cleanup (2025-07-27)
- Code cleanup (2025-08-04)
- Minor fixes (2025-08-28)
- Improve error handling (2025-09-05)
- Update documentation (2025-09-09)
- Code cleanup (2025-09-15)
- Fix bugs and improve stability (2025-09-19)
- Update documentation (2025-09-25)
- Fix bugs and improve stability (2025-10-06)
- Fix bugs and improve stability (2025-10-13)
- Fix bugs and improve stability (2025-10-16)
- Optimize performance (2025-10-17)
- Add minor improvements (2025-10-22)
- Code cleanup (2025-10-26)
- Add minor improvements (2025-10-28)
- Fix edge cases (2025-10-29)
- Fix bugs and improve stability (2025-10-30)
- Optimize performance (2025-11-04)
- Improve error handling (2025-11-07)
- Update documentation (2025-11-12)
- Fix bugs and improve stability (2025-11-14)
- Update documentation (2025-11-17)
- Add minor improvements (2025-11-18)
- Refactor code structure (2025-11-19)
- Fix bugs and improve stability (2025-11-20)
- Minor fixes (2025-11-24)

View file

@ -1,98 +0,0 @@
TNT(1) User Commands TNT(1)
NAME
tnt - terminal chat server with vim-style interface
SYNOPSIS
tnt [-p port] [-h]
DESCRIPTION
TNT (TNT's Not Tunnel) is a lightweight SSH chat server.
Supports vim-style navigation and message history browsing.
INSTALLATION
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
Or download binary from:
https://github.com/m1ngsama/TNT/releases
USAGE
Start server:
tnt # Listen on port 2222
tnt -p 3333 # Listen on port 3333
PORT=3333 tnt # Same as above
Connect as client:
ssh -p 2222 localhost
OPTIONS
-p port Listen on specified port (default: 2222)
-h Display help message
MODES
INSERT Type and send messages (default mode)
ESC Switch to NORMAL mode
Enter Send message
Backspace Delete character
NORMAL Browse message history
i Return to INSERT mode
: Enter COMMAND mode
j/k Scroll down/up
g/G Jump to top/bottom
? Show help
COMMAND Execute commands
:list List online users
:help Show available commands
ESC Return to NORMAL mode
ENVIRONMENT
PORT Server port (default: 2222)
FILES
messages.log Message history (append-only)
host_key SSH host key (auto-generated)
EXAMPLES
Start server on port 3000:
PORT=3000 tnt
Connect and set username:
ssh -p 2222 localhost
# Enter username when prompted
Production deployment with systemd:
sudo cp tnt.service /etc/systemd/system/
sudo systemctl enable --now tnt
DIAGNOSTICS
Build with debug symbols:
make debug
Check for memory leaks:
make asan
ASAN_OPTIONS=detect_leaks=1 ./tnt
Run tests:
./test_basic.sh
./test_stress.sh 50 120
BUGS
Report bugs at: https://github.com/m1ngsama/TNT/issues
SEE ALSO
ssh(1), sshd(8)
Project documentation:
HACKING Developer guide
DEPLOYMENT.md Production setup
CICD.md CI/CD workflow
AUTHOR
Written by m1ng.
LICENSE
MIT License. See LICENSE file for details.
TNT 1.0 December 2025 TNT(1)

39
include/bootstrap.h Normal file
View file

@ -0,0 +1,39 @@
#ifndef BOOTSTRAP_H
#define BOOTSTRAP_H
#include "ssh_server.h" /* for client_t and the libssh / arpa includes */
/* Hand-off envelope between the accept loop and the bootstrap thread.
* The accept loop allocates one of these per accepted session, fills it,
* and pthread_create()s a detached bootstrap_run() with this pointer.
* bootstrap_run() owns the struct and the embedded ssh_session, and frees
* both before returning. */
typedef struct {
ssh_session session;
char client_ip[INET6_ADDRSTRLEN];
} accepted_session_t;
/* Read TNT_ACCESS_TOKEN from the environment. Idempotent. Call once
* during startup, before bootstrap_run() can fire on any accepted
* session. */
void bootstrap_init(void);
/* Read the peer IP off an accepted ssh_session into ip_buf. Sets ip_buf
* to "unknown" when the address family is unrecognised or getpeername()
* fails. ip_buf must be at least INET6_ADDRSTRLEN bytes. */
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 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; input_run_session() is never
* invoked. Always returns NULL. */
void *bootstrap_run(void *arg);
#endif /* BOOTSTRAP_H */

34
include/client.h Normal file
View file

@ -0,0 +1,34 @@
#ifndef CLIENT_H
#define CLIENT_H
#include "ssh_server.h" /* for client_t */
/* Send `len` bytes to the client over its SSH channel. Serialised on
* client->io_lock so concurrent senders don't interleave. Returns 0 on
* success, -1 if the channel is gone or a partial write fails. */
int client_send(client_t *client, const char *data, size_t len);
/* printf-style wrapper around client_send(). The formatted string must
* fit in 2048 bytes; truncation or encoding errors return -1. */
int client_printf(client_t *client, const char *fmt, ...);
/* Reference counting for safe cross-thread cleanup.
*
* Lifecycle: bootstrap_run() creates the client_t with ref_count = 1
* (the "main" ref), then adds a second ref before installing the channel
* callbacks (the "callback" ref) so the client outlives any in-flight
* eof / close / window-change callback invocation. The interactive
* session releases both refs in its cleanup path; the final release
* frees the SSH session, channel, callback struct, and the client_t. */
void client_addref(client_t *client);
void client_release(client_t *client);
/* Install the post-bootstrap channel callbacks (window-change, eof, close)
* 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
* 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);
#endif /* CLIENT_H */

23
include/commands.h Normal file
View file

@ -0,0 +1,23 @@
#ifndef COMMANDS_H
#define COMMANDS_H
#include "ssh_server.h" /* for client_t */
/* Dispatch the COMMAND-mode line currently in client->command_input.
*
* Side effects (visible to the caller):
* - May append to client->command_history
* - Resets client->command_input
* - Writes the rendered output into client->command_output (so the next
* redraw shows it), or returns the client to MODE_NORMAL on `:` then
* Enter on an empty line
* - Sets client->connected = false on `:q` / `:quit` / `:exit`
* - Toggles client->mute_joins on `:mute-joins`
* - May broadcast a system rename message on `:nick`
*
* Reads g_room. Caller must already hold the channel I/O serialisation
* established by handle_key() this function calls back into client_send
* (via tui_render_command_output) which acquires client->io_lock. */
void commands_dispatch(client_t *client);
#endif /* COMMANDS_H */

View file

@ -54,4 +54,27 @@ const char* tnt_state_dir(void);
int tnt_ensure_state_dir(void);
int tnt_state_path(char *buffer, size_t buf_size, const char *filename);
/* Bounded string buffer builders. Both append to `buffer[*pos..]`, advance
* `*pos`, and always keep the buffer NUL-terminated. They never write past
* `buf_size - 1` and become no-ops once the buffer is full. */
void buffer_append_bytes(char *buffer, size_t buf_size, size_t *pos,
const char *data, size_t len);
void buffer_appendf(char *buffer, size_t buf_size, size_t *pos,
const char *fmt, ...);
/* Parse an integer from `getenv(name)`, clamping accepted values to
* [min_val, max_val]. Returns `fallback` when the variable is unset, empty,
* non-numeric, or out of range. */
int env_int(const char *name, int fallback, int min_val, int max_val);
/* Reject usernames containing shell metacharacters, control characters, or
* a leading space/dot/dash. Used by username read, exec post (SSH login as
* author), and the :nick command. */
bool is_valid_username(const char *username);
/* Clamp a terminal size to sensible bounds (1..500 cols, 1..200 rows).
* Replaces zero/negative/oversize values with 80x24. Used by the PTY
* request callback and the window-change callback. */
void sanitize_terminal_size(int *width, int *height);
#endif /* COMMON_H */

17
include/exec.h Normal file
View file

@ -0,0 +1,17 @@
#ifndef EXEC_H
#define EXEC_H
#include "ssh_server.h" /* for client_t */
/* Dispatch the non-interactive SSH exec command stored in
* client->exec_command. Returns the exit status to send back to the
* SSH client:
* 0 = success
* 1 = runtime error (I/O, OOM, persistence failure)
* 64 = usage error (unknown command, bad args)
*
* Reads g_room and shared client state. Safe to call once per
* exec-mode session before the channel is closed. */
int exec_dispatch(client_t *client);
#endif /* EXEC_H */

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

27
include/ratelimit.h Normal file
View file

@ -0,0 +1,27 @@
#ifndef RATELIMIT_H
#define RATELIMIT_H
#include <stdbool.h>
/* Read TNT_MAX_CONNECTIONS / TNT_MAX_CONN_PER_IP / TNT_MAX_CONN_RATE_PER_IP /
* TNT_RATE_LIMIT from the environment. Idempotent, call once at startup. */
void ratelimit_init(void);
/* Per-IP entry point: returns false if the IP has hit any limit (concurrent,
* rate, or block). On success, increments the IP's active counter caller
* MUST pair with ratelimit_release_ip() when the connection ends. */
bool ratelimit_check_ip(const char *ip);
void ratelimit_release_ip(const char *ip);
/* Auth-failure ledger. After enough failures within the window the IP is
* blocked for a fixed duration. */
void ratelimit_record_auth_failure(const char *ip);
/* Global active-connection cap (separate from per-IP). Pair them. */
bool ratelimit_check_and_increment_total(void);
void ratelimit_decrement_total(void);
/* Read-only accessor for stats subcommand. */
int ratelimit_get_active_total(void);
#endif /* RATELIMIT_H */

View file

@ -25,6 +25,7 @@ typedef struct client {
int command_history_count;
int command_history_pos;
char command_output[2048];
bool show_motd; /* command_output holds MOTD text */
char exec_command[MAX_EXEC_COMMAND_LEN];
char ssh_login[MAX_USERNAME_LEN];
time_t connect_time;
@ -45,17 +46,7 @@ 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);
/* Send formatted string to client */
int client_printf(client_t *client, const char *fmt, ...);
/* Reference counting helpers */
void client_addref(client_t *client);
void client_release(client_t *client);
/* Read-only accessor for the server start time (used by exec stats). */
time_t ssh_server_start_time(void);
#endif /* SSH_SERVER_H */

View file

@ -16,12 +16,22 @@ void tui_render_help(struct client *client);
/* Render the command output screen */
void tui_render_command_output(struct client *client);
/* Render the MOTD screen. Reads the message text from
* client->command_output (shared storage); the show_motd flag selects
* this renderer over tui_render_command_output. */
void tui_render_motd(struct client *client);
/* Render the input line */
void tui_render_input(struct client *client, const char *input);
/* Clear the screen */
void tui_clear_screen(struct client *client);
/* Render the pre-login welcome banner. Centered, framed, shown once before
* the username prompt. Caller is responsible for printing the prompt
* itself afterwards. */
void tui_render_welcome(struct client *client);
/* Get help text based on language */
const char* tui_get_help_text(help_lang_t lang);

494
src/bootstrap.c Normal file
View file

@ -0,0 +1,494 @@
#include "bootstrap.h"
#include "client.h"
#include "common.h"
#include "input.h"
#include "ratelimit.h"
#include <arpa/inet.h>
#include <errno.h>
#include <libssh/callbacks.h>
#include <libssh/libssh.h>
#include <libssh/server.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
/* Per-connection bootstrap state. Kept private to this translation unit:
* its lifetime ends inside bootstrap_run() once a client_t takes over. */
typedef struct {
char client_ip[INET6_ADDRSTRLEN];
char requested_user[MAX_USERNAME_LEN];
int pty_width;
int pty_height;
char exec_command[MAX_EXEC_COMMAND_LEN];
bool auth_success;
int auth_attempts;
bool channel_ready; /* Set when shell/exec request received */
ssh_channel channel; /* Channel created in callback */
struct ssh_channel_callbacks_struct *channel_cb; /* Channel callbacks */
} session_context_t;
/* Configured access token; empty string means "no auth required". */
static char g_access_token[256] = "";
void bootstrap_init(void) {
const char *token_env = getenv("TNT_ACCESS_TOKEN");
if (token_env != NULL) {
strncpy(g_access_token, token_env, sizeof(g_access_token) - 1);
g_access_token[sizeof(g_access_token) - 1] = '\0';
} else {
g_access_token[0] = '\0';
}
}
void bootstrap_peer_ip(ssh_session session, char *ip_buf, size_t buf_size) {
int fd = ssh_get_fd(session);
struct sockaddr_storage addr;
socklen_t addr_len = sizeof(addr);
if (getpeername(fd, (struct sockaddr *)&addr, &addr_len) == 0) {
if (addr.ss_family == AF_INET) {
struct sockaddr_in *s = (struct sockaddr_in *)&addr;
inet_ntop(AF_INET, &s->sin_addr, ip_buf, buf_size);
} else if (addr.ss_family == AF_INET6) {
struct sockaddr_in6 *s = (struct sockaddr_in6 *)&addr;
inet_ntop(AF_INET6, &s->sin6_addr, ip_buf, buf_size);
} else {
strncpy(ip_buf, "unknown", buf_size - 1);
}
} else {
strncpy(ip_buf, "unknown", buf_size - 1);
}
ip_buf[buf_size - 1] = '\0';
}
/* 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.
*
* Note: the length-diff is accumulated in size_t to avoid the bug where a
* narrower type (e.g. unsigned char) would collapse pairs like (300, 44) to
* 0 because 300 ^ 44 == 256 ^ (44 ^ 44) == 256 which truncates to 0. */
static bool constant_time_strcmp(const char *a, const char *b) {
size_t len_a = strlen(a);
size_t len_b = strlen(b);
volatile size_t length_diff = len_a ^ len_b;
volatile unsigned char byte_diff = 0;
for (size_t i = 0; i < len_b; i++) {
unsigned char ca = (i < len_a) ? (unsigned char)a[i] : 0;
byte_diff |= ca ^ (unsigned char)b[i];
}
return length_diff == 0 && byte_diff == 0;
}
/* Password authentication callback */
static int auth_password(ssh_session session, const char *user,
const char *password, void *userdata) {
session_context_t *ctx = (session_context_t *)userdata;
if (user && user[0] != '\0') {
strncpy(ctx->requested_user, user, sizeof(ctx->requested_user) - 1);
ctx->requested_user[sizeof(ctx->requested_user) - 1] = '\0';
}
ctx->auth_attempts++;
/* Limit auth attempts */
if (ctx->auth_attempts > 3) {
ratelimit_record_auth_failure(ctx->client_ip);
fprintf(stderr, "Too many auth attempts from %s\n", ctx->client_ip);
ssh_disconnect(session);
return SSH_AUTH_DENIED;
}
/* If access token is configured, require it */
if (g_access_token[0] != '\0') {
if (password && constant_time_strcmp(password, g_access_token)) {
/* Token matches */
ctx->auth_success = true;
return SSH_AUTH_SUCCESS;
} else {
/* Wrong token — IP blocking handles brute force, no sleep needed here
* (sleeping in a libssh callback blocks the entire accept loop). */
ratelimit_record_auth_failure(ctx->client_ip);
return SSH_AUTH_DENIED;
}
} else {
/* No token configured, accept any password */
ctx->auth_success = true;
return SSH_AUTH_SUCCESS;
}
}
/* Passwordless (none) authentication callback */
static int auth_none(ssh_session session, const char *user, void *userdata) {
(void)session; /* Unused */
session_context_t *ctx = (session_context_t *)userdata;
if (user && user[0] != '\0') {
strncpy(ctx->requested_user, user, sizeof(ctx->requested_user) - 1);
ctx->requested_user[sizeof(ctx->requested_user) - 1] = '\0';
}
/* If access token is configured, reject passwordless */
if (g_access_token[0] != '\0') {
return SSH_AUTH_DENIED;
} else {
/* No token configured, allow passwordless */
ctx->auth_success = true;
return SSH_AUTH_SUCCESS;
}
}
/* Public key authentication callback */
static int auth_pubkey(ssh_session session, const char *user,
struct ssh_key_struct *pubkey, char signature_state,
void *userdata) {
(void)session;
(void)pubkey;
session_context_t *ctx = (session_context_t *)userdata;
if (user && user[0] != '\0') {
strncpy(ctx->requested_user, user, sizeof(ctx->requested_user) - 1);
ctx->requested_user[sizeof(ctx->requested_user) - 1] = '\0';
}
/* Reject if access token is required (pubkey auth not supported with tokens) */
if (g_access_token[0] != '\0') {
return SSH_AUTH_DENIED;
}
/* SSH_PUBLICKEY_STATE_NONE = key offer (no signature yet).
* Return SUCCESS to tell libssh "I accept this key, verify the signature."
* SSH_PUBLICKEY_STATE_VALID = signature verified by libssh. */
if (signature_state != SSH_PUBLICKEY_STATE_VALID) {
return SSH_AUTH_SUCCESS;
}
ctx->auth_success = true;
return SSH_AUTH_SUCCESS;
}
static void destroy_session_context(session_context_t *ctx) {
if (!ctx) {
return;
}
if (ctx->channel_cb) {
free(ctx->channel_cb);
}
free(ctx);
}
static void cleanup_failed_session(ssh_session session, session_context_t *ctx) {
if (ctx && ctx->channel) {
if (ctx->channel_cb) {
ssh_remove_channel_callbacks(ctx->channel, ctx->channel_cb);
}
ssh_channel_close(ctx->channel);
ssh_channel_free(ctx->channel);
ctx->channel = NULL;
}
if (session) {
ssh_disconnect(session);
ssh_free(session);
}
if (ctx) {
ratelimit_release_ip(ctx->client_ip);
}
destroy_session_context(ctx);
ratelimit_decrement_total();
}
static void setup_session_channel_callbacks(ssh_channel channel,
session_context_t *ctx);
/* Channel open callback */
static ssh_channel channel_open_request_session(ssh_session session, void *userdata) {
session_context_t *ctx = (session_context_t *)userdata;
ssh_channel channel;
channel = ssh_channel_new(session);
if (channel == NULL) {
return NULL;
}
/* Store channel in context for main loop */
ctx->channel = channel;
/* Set up channel-specific callbacks (PTY, shell, exec) */
setup_session_channel_callbacks(channel, ctx);
return channel;
}
/* PTY request callback */
static int channel_pty_request(ssh_session session, ssh_channel channel,
const char *term, int width, int height,
int pxwidth, int pxheight, void *userdata) {
(void)session; /* Unused */
(void)channel; /* Unused */
(void)term; /* Unused */
(void)pxwidth; /* Unused */
(void)pxheight; /* Unused */
session_context_t *ctx = (session_context_t *)userdata;
/* Store terminal dimensions */
ctx->pty_width = width;
ctx->pty_height = height;
sanitize_terminal_size(&ctx->pty_width, &ctx->pty_height);
return SSH_OK;
}
static int channel_pty_window_change(ssh_session session, ssh_channel channel,
int width, int height,
int pxwidth, int pxheight,
void *userdata) {
(void)session;
(void)channel;
(void)pxwidth;
(void)pxheight;
session_context_t *ctx = (session_context_t *)userdata;
ctx->pty_width = width;
ctx->pty_height = height;
sanitize_terminal_size(&ctx->pty_width, &ctx->pty_height);
return SSH_OK;
}
/* Shell request callback */
static int channel_shell_request(ssh_session session, ssh_channel channel,
void *userdata) {
(void)session; /* Unused */
(void)channel; /* Unused */
session_context_t *ctx = (session_context_t *)userdata;
/* Mark channel as ready */
ctx->channel_ready = true;
/* Accept shell request */
return SSH_OK;
}
/* Exec request callback */
static int channel_exec_request(ssh_session session, ssh_channel channel,
const char *command, void *userdata) {
(void)session; /* Unused */
(void)channel; /* Unused */
session_context_t *ctx = (session_context_t *)userdata;
/* Store exec command */
if (command) {
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
}
/* Mark channel as ready */
ctx->channel_ready = true;
return SSH_OK;
}
/* Set up channel callbacks */
static void setup_session_channel_callbacks(ssh_channel channel,
session_context_t *ctx) {
/* Allocate channel callbacks on heap to persist */
ctx->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct));
if (!ctx->channel_cb) {
return;
}
ssh_callbacks_init(ctx->channel_cb);
ctx->channel_cb->userdata = ctx;
ctx->channel_cb->channel_pty_request_function = channel_pty_request;
ctx->channel_cb->channel_shell_request_function = channel_shell_request;
ctx->channel_cb->channel_pty_window_change_function = channel_pty_window_change;
ctx->channel_cb->channel_exec_request_function = channel_exec_request;
ssh_set_channel_callbacks(channel, ctx->channel_cb);
}
void *bootstrap_run(void *arg) {
accepted_session_t *accepted = (accepted_session_t *)arg;
ssh_session session;
session_context_t *ctx = NULL;
ssh_event event = NULL;
struct ssh_server_callbacks_struct server_cb;
ssh_channel channel;
client_t *client = NULL;
bool timed_out = false;
time_t start_time;
char accepted_ip[INET6_ADDRSTRLEN] = "";
if (!accepted) {
return NULL;
}
session = accepted->session;
if (accepted->client_ip[0] != '\0') {
snprintf(accepted_ip, sizeof(accepted_ip), "%s", accepted->client_ip);
}
free(accepted);
ctx = calloc(1, sizeof(session_context_t));
if (!ctx) {
ratelimit_release_ip(accepted_ip);
ssh_disconnect(session);
ssh_free(session);
ratelimit_decrement_total();
return NULL;
}
if (accepted_ip[0] != '\0') {
snprintf(ctx->client_ip, sizeof(ctx->client_ip), "%s", accepted_ip);
} else {
bootstrap_peer_ip(session, ctx->client_ip, sizeof(ctx->client_ip));
}
ctx->pty_width = 80;
ctx->pty_height = 24;
ctx->exec_command[0] = '\0';
ctx->requested_user[0] = '\0';
ctx->auth_success = false;
ctx->auth_attempts = 0;
ctx->channel_ready = false;
ctx->channel = NULL;
ctx->channel_cb = NULL;
memset(&server_cb, 0, sizeof(server_cb));
ssh_callbacks_init(&server_cb);
server_cb.userdata = ctx;
server_cb.auth_password_function = auth_password;
server_cb.auth_none_function = auth_none;
server_cb.auth_pubkey_function = auth_pubkey;
server_cb.channel_open_request_session_function = channel_open_request_session;
ssh_set_server_callbacks(session, &server_cb);
if (ssh_handle_key_exchange(session) != SSH_OK) {
fprintf(stderr, "Key exchange failed from %s: %s\n",
ctx->client_ip, ssh_get_error(session));
cleanup_failed_session(session, ctx);
return NULL;
}
event = ssh_event_new();
if (!event) {
fprintf(stderr, "Failed to create SSH event for %s\n", ctx->client_ip);
cleanup_failed_session(session, ctx);
return NULL;
}
if (ssh_event_add_session(event, session) != SSH_OK) {
fprintf(stderr, "Failed to add session to event loop for %s\n",
ctx->client_ip);
ssh_event_free(event);
cleanup_failed_session(session, ctx);
return NULL;
}
start_time = time(NULL);
while ((!ctx->auth_success || ctx->channel == NULL || !ctx->channel_ready) &&
!timed_out) {
int rc = ssh_event_dopoll(event, 1000);
if (rc == SSH_ERROR) {
fprintf(stderr, "Event poll error from %s: %s\n",
ctx->client_ip, ssh_get_error(session));
break;
}
if (time(NULL) - start_time > 10) {
timed_out = true;
}
}
ssh_event_free(event);
event = NULL;
if (!ctx->auth_success) {
fprintf(stderr, "Authentication failed or timed out from %s\n",
ctx->client_ip);
cleanup_failed_session(session, ctx);
return NULL;
}
channel = ctx->channel;
if (!channel || !ctx->channel_ready || timed_out) {
fprintf(stderr, "Failed to open/setup channel from %s\n",
ctx->client_ip);
cleanup_failed_session(session, ctx);
return NULL;
}
client = calloc(1, sizeof(client_t));
if (!client) {
cleanup_failed_session(session, ctx);
return NULL;
}
client->session = session;
client->channel = channel;
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);
if (ctx->requested_user[0] != '\0') {
strncpy(client->ssh_login, ctx->requested_user,
sizeof(client->ssh_login) - 1);
client->ssh_login[sizeof(client->ssh_login) - 1] = '\0';
}
if (ctx->client_ip[0] != '\0') {
snprintf(client->client_ip, sizeof(client->client_ip), "%s",
ctx->client_ip);
}
if (ctx->exec_command[0] != '\0') {
strncpy(client->exec_command, ctx->exec_command,
sizeof(client->exec_command) - 1);
client->exec_command[sizeof(client->exec_command) - 1] = '\0';
}
/* Add a ref for the channel callbacks (eof/close/window_change) so the
* client_t outlives any in-flight callback invocation. */
client_addref(client);
if (client_install_channel_callbacks(client) < 0) {
/* Nullify session/channel ownership so client_release won't
* double-free what cleanup_failed_session is about to free. */
client->session = NULL;
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);
return NULL;
}
if (ctx->channel_cb) {
ssh_remove_channel_callbacks(channel, ctx->channel_cb);
free(ctx->channel_cb);
ctx->channel_cb = NULL;
}
destroy_session_context(ctx);
input_run_session(client);
return NULL;
}

162
src/client.c Normal file
View file

@ -0,0 +1,162 @@
#include "client.h"
#include "common.h"
#include <libssh/callbacks.h>
#include <libssh/libssh.h>
#include <libssh/server.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
/* Send data to client via SSH channel */
int client_send(client_t *client, const char *data, size_t len) {
size_t total = 0;
if (!client || !data) return -1;
pthread_mutex_lock(&client->io_lock);
if (!client->connected || !client->channel) {
pthread_mutex_unlock(&client->io_lock);
return -1;
}
while (total < len) {
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) {
pthread_mutex_unlock(&client->io_lock);
return -1;
}
total += (size_t)sent;
}
pthread_mutex_unlock(&client->io_lock);
return 0;
}
void client_addref(client_t *client) {
if (!client) return;
pthread_mutex_lock(&client->ref_lock);
client->ref_count++;
pthread_mutex_unlock(&client->ref_lock);
}
void client_release(client_t *client) {
if (!client) return;
pthread_mutex_lock(&client->ref_lock);
client->ref_count--;
int count = client->ref_count;
pthread_mutex_unlock(&client->ref_lock);
if (count == 0) {
/* Safe to free now */
if (client->channel && client->channel_cb) {
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
}
if (client->channel) {
ssh_channel_close(client->channel);
ssh_channel_free(client->channel);
}
if (client->session) {
ssh_disconnect(client->session);
ssh_free(client->session);
}
if (client->channel_cb) {
free(client->channel_cb);
}
pthread_mutex_destroy(&client->io_lock);
pthread_mutex_destroy(&client->ref_lock);
free(client);
}
}
/* Send formatted string to client */
int client_printf(client_t *client, const char *fmt, ...) {
char buffer[2048];
va_list args;
va_start(args, fmt);
int len = vsnprintf(buffer, sizeof(buffer), fmt, args);
va_end(args);
/* Check for buffer overflow or encoding error */
if (len < 0 || len >= (int)sizeof(buffer)) {
return -1;
}
return client_send(client, buffer, len);
}
static int client_channel_window_change(ssh_session session, ssh_channel channel,
int width, int height,
int pxwidth, int pxheight,
void *userdata) {
(void)session;
(void)channel;
(void)pxwidth;
(void)pxheight;
client_t *client = (client_t *)userdata;
if (!client) {
return SSH_ERROR;
}
int w = width;
int h = height;
sanitize_terminal_size(&w, &h);
client->width = w;
client->height = h;
client->redraw_pending = true;
return SSH_OK;
}
static void client_channel_eof(ssh_session session, ssh_channel channel,
void *userdata) {
(void)session;
(void)channel;
client_t *client = (client_t *)userdata;
if (client) {
client->connected = false;
}
}
static void client_channel_close(ssh_session session, ssh_channel channel,
void *userdata) {
(void)session;
(void)channel;
client_t *client = (client_t *)userdata;
if (client) {
client->connected = false;
}
}
int client_install_channel_callbacks(client_t *client) {
if (!client || !client->channel) {
return -1;
}
client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct));
if (!client->channel_cb) {
return -1;
}
ssh_callbacks_init(client->channel_cb);
client->channel_cb->userdata = client;
client->channel_cb->channel_eof_function = client_channel_eof;
client->channel_cb->channel_close_function = client_channel_close;
client->channel_cb->channel_pty_window_change_function =
client_channel_window_change;
if (ssh_set_channel_callbacks(client->channel, client->channel_cb) != SSH_OK) {
free(client->channel_cb);
client->channel_cb = NULL;
return -1;
}
return 0;
}

269
src/commands.c Normal file
View file

@ -0,0 +1,269 @@
#include "commands.h"
#include "chat_room.h"
#include "client.h"
#include "common.h"
#include "message.h"
#include "tui.h"
#include "utf8.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
void commands_dispatch(client_t *client) {
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;
/* Trim whitespace */
while (*cmd == ' ') cmd++;
size_t cmd_len = strlen(cmd);
if (cmd_len > 0) {
char *end = cmd + cmd_len - 1;
while (end > cmd && *end == ' ') {
*end = '\0';
end--;
}
}
/* 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;
}
snprintf(client->command_history[client->command_history_count],
sizeof(client->command_history[0]), "%s", cmd);
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,
"========================================\n"
" Online Users / 在线用户\n"
"========================================\n");
pthread_rwlock_rdlock(&g_room->lock);
buffer_appendf(output, sizeof(output), &pos,
"Total / 总数: %d\n"
"----------------------------------------\n",
g_room->client_count);
time_t now = time(NULL);
for (int i = 0; i < g_room->client_count; i++) {
char marker = (g_room->clients[i] == client) ? '*' : ' ';
int dur = (int)(now - g_room->clients[i]->connect_time);
char dur_str[32];
if (dur < 60) {
snprintf(dur_str, sizeof(dur_str), "%ds", dur);
} else if (dur < 3600) {
snprintf(dur_str, sizeof(dur_str), "%dm", dur / 60);
} else {
snprintf(dur_str, sizeof(dur_str), "%dh%dm", dur / 3600, (dur % 3600) / 60);
}
buffer_appendf(output, sizeof(output), &pos,
"%c %d. %s (%s)\n", marker, i + 1,
g_room->clients[i]->username, dur_str);
}
pthread_rwlock_unlock(&g_room->lock);
buffer_appendf(output, sizeof(output), &pos,
"========================================\n"
"* = you / 你\n");
} else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "commands") == 0) {
buffer_appendf(output, sizeof(output), &pos,
"========================================\n"
" Available Commands / 可用命令\n"
"========================================\n"
"list, users, who - Show online users\n"
"nick/name <name> - Change nickname\n"
"msg/w <user> <text> - Whisper to user\n"
"last [N] - Show last N messages\n"
"search <keyword> - Search message history\n"
"mute-joins - Toggle join/leave notices\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"
"In INSERT mode:\n"
" /me <action> - Send action message\n"
" @username - Mention (bell notify)\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;
client_t *target = NULL;
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) {
target = g_room->clients[i];
client_addref(target);
found = true;
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
if (target) {
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(target, whisper, strlen(whisper));
target->redraw_pending = true;
client_release(target);
}
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 (strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) {
char *new_name = cmd + 5;
while (*new_name == ' ') new_name++;
if (new_name[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos,
"Usage: nick <new_username>\n");
} else if (!is_valid_username(new_name)) {
buffer_appendf(output, sizeof(output), &pos,
"Invalid username\n");
} else {
char validated_name[MAX_USERNAME_LEN];
snprintf(validated_name, sizeof(validated_name), "%s", new_name);
if (utf8_strlen(validated_name) > 20) {
utf8_truncate(validated_name, 20);
}
char old_name[MAX_USERNAME_LEN];
pthread_rwlock_wrlock(&g_room->lock);
snprintf(old_name, sizeof(old_name), "%s", client->username);
snprintf(client->username, MAX_USERNAME_LEN, "%s", validated_name);
pthread_rwlock_unlock(&g_room->lock);
message_t nick_msg = { .timestamp = time(NULL) };
snprintf(nick_msg.username, MAX_USERNAME_LEN, "系统");
snprintf(nick_msg.content, MAX_MESSAGE_LEN,
"%s 更名为 %s", old_name, client->username);
room_broadcast(g_room, &nick_msg);
message_save(&nick_msg);
buffer_appendf(output, sizeof(output), &pos,
"Nickname changed: %s -> %s\n", old_name, client->username);
}
} else if (strncmp(cmd, "last", 4) == 0 && (cmd[4] == ' ' || cmd[4] == '\0')) {
char *arg = cmd + 4;
while (*arg == ' ') arg++;
int n = 10;
if (*arg != '\0') {
char *endp;
long val = strtol(arg, &endp, 10);
if (*endp != '\0' || val < 1 || val > 50) {
buffer_appendf(output, sizeof(output), &pos,
"Usage: last [N] (N: 1-50, default 10)\n");
goto cmd_done;
}
n = (int)val;
}
message_t *last_msgs = NULL;
int last_count = message_load(&last_msgs, n);
buffer_appendf(output, sizeof(output), &pos,
"--- Last %d message(s) ---\n", last_count);
for (int i = 0; i < last_count; i++) {
char ts[20];
struct tm tmi;
localtime_r(&last_msgs[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
"[%s] %s: %s\n", ts, last_msgs[i].username, last_msgs[i].content);
}
free(last_msgs);
} else if (strncmp(cmd, "search ", 7) == 0) {
char *query = cmd + 7;
while (*query == ' ') query++;
if (*query == '\0') {
buffer_appendf(output, sizeof(output), &pos,
"Usage: search <keyword>\n");
} else {
message_t *found = NULL;
int found_count = message_search(query, &found, 15);
buffer_appendf(output, sizeof(output), &pos,
"--- Search: \"%s\" (%d match(es)) ---\n", query, found_count);
for (int i = 0; i < found_count; i++) {
char ts[20];
struct tm tmi;
localtime_r(&found[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
"[%s] %s: %s\n", ts, found[i].username, found[i].content);
}
free(found);
}
} else if (strcmp(cmd, "mute-joins") == 0 || strcmp(cmd, "mute") == 0) {
client->mute_joins = !client->mute_joins;
buffer_appendf(output, sizeof(output), &pos,
"Join/leave notifications: %s\n",
client->mute_joins ? "muted" : "unmuted");
} 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");
} else if (cmd[0] == '\0') {
/* Empty command */
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return;
} else {
buffer_appendf(output, sizeof(output), &pos,
"Unknown command: %s\n"
"Type 'help' for available commands\n", cmd);
}
cmd_done:
buffer_appendf(output, sizeof(output), &pos,
"\nPress any key to continue...");
snprintf(client->command_output, sizeof(client->command_output), "%s", output);
client->command_input[0] = '\0';
tui_render_command_output(client);
}

View file

@ -1,5 +1,6 @@
#include "common.h"
#include <errno.h>
#include <stdarg.h>
#include <sys/stat.h>
#ifndef PATH_MAX
@ -84,3 +85,92 @@ int tnt_state_path(char *buffer, size_t buf_size, const char *filename) {
return 0;
}
void buffer_append_bytes(char *buffer, size_t buf_size, size_t *pos,
const char *data, size_t len) {
size_t available;
size_t to_copy;
if (!buffer || !pos || !data || len == 0 || buf_size == 0 ||
*pos >= buf_size - 1) {
return;
}
available = (buf_size - 1) - *pos;
to_copy = (len < available) ? len : available;
memcpy(buffer + *pos, data, to_copy);
*pos += to_copy;
buffer[*pos] = '\0';
}
void buffer_appendf(char *buffer, size_t buf_size, size_t *pos,
const char *fmt, ...) {
va_list args;
int written;
if (!buffer || !pos || !fmt || buf_size == 0 || *pos >= buf_size - 1) {
return;
}
va_start(args, fmt);
written = vsnprintf(buffer + *pos, buf_size - *pos, fmt, args);
va_end(args);
if (written < 0) {
return;
}
if ((size_t)written >= buf_size - *pos) {
*pos = buf_size - 1;
} else {
*pos += (size_t)written;
}
}
int env_int(const char *name, int fallback, int min_val, int max_val) {
const char *env = getenv(name);
if (!env || env[0] == '\0') return fallback;
char *end;
long val = strtol(env, &end, 10);
if (*end != '\0' || val < min_val || val > max_val) return fallback;
return (int)val;
}
bool is_valid_username(const char *username) {
if (!username || username[0] == '\0') {
return false;
}
/* Reject usernames starting with special characters */
if (username[0] == ' ' || username[0] == '.' || username[0] == '-') {
return false;
}
/* Check for illegal characters that could cause injection */
const char *illegal_chars = "|;&$`\n\r<>(){}[]'\"\\";
for (size_t i = 0; i < strlen(username); i++) {
/* Reject control characters (except tab) */
if (username[i] < 32 && username[i] != 9) {
return false;
}
/* Reject shell metacharacters */
if (strchr(illegal_chars, username[i])) {
return false;
}
}
return true;
}
void sanitize_terminal_size(int *width, int *height) {
if (!width || !height) {
return;
}
if (*width <= 0 || *width > 500) {
*width = 80;
}
if (*height <= 0 || *height > 200) {
*height = 24;
}
}

453
src/exec.c Normal file
View file

@ -0,0 +1,453 @@
#include "exec.h"
#include "chat_room.h"
#include "client.h"
#include "common.h"
#include "input.h"
#include "message.h"
#include "ratelimit.h"
#include "utf8.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.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;
if (!buffer || buf_size == 0) {
return;
}
gmtime_r(&ts, &tm_info);
strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info);
}
static void trim_ascii_whitespace(char *text) {
char *start;
char *end;
if (!text || text[0] == '\0') {
return;
}
start = text;
while (*start && isspace((unsigned char)*start)) {
start++;
}
if (start != text) {
memmove(text, start, strlen(start) + 1);
}
if (text[0] == '\0') {
return;
}
end = text + strlen(text) - 1;
while (end >= text && isspace((unsigned char)*end)) {
*end = '\0';
end--;
}
}
static void json_append_string(char *buffer, size_t buf_size, size_t *pos,
const char *text) {
const unsigned char *p = (const unsigned char *)(text ? text : "");
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
while (*p && *pos < buf_size - 1) {
char escaped[7];
switch (*p) {
case '\\':
buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2);
break;
case '"':
buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2);
break;
case '\n':
buffer_append_bytes(buffer, buf_size, pos, "\\n", 2);
break;
case '\r':
buffer_append_bytes(buffer, buf_size, pos, "\\r", 2);
break;
case '\t':
buffer_append_bytes(buffer, buf_size, pos, "\\t", 2);
break;
default:
if (*p < 0x20) {
snprintf(escaped, sizeof(escaped), "\\u%04x", *p);
buffer_append_bytes(buffer, buf_size, pos,
escaped, strlen(escaped));
} else {
buffer_append_bytes(buffer, buf_size, pos,
(const char *)p, 1);
}
break;
}
p++;
}
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
}
static void resolve_exec_username(const client_t *client, char *buffer,
size_t buf_size) {
if (!buffer || buf_size == 0) {
return;
}
if (client && client->ssh_login[0] != '\0' &&
is_valid_username(client->ssh_login)) {
snprintf(buffer, buf_size, "%s", client->ssh_login);
} else {
snprintf(buffer, buf_size, "%s", "anonymous");
}
if (utf8_strlen(buffer) > 20) {
utf8_truncate(buffer, 20);
}
}
static int exec_command_help(client_t *client) {
static const char help_text[] =
"TNT exec interface\n"
"Commands:\n"
" help Show this help\n"
" health Print service health\n"
" users [--json] List online users\n"
" stats [--json] Print room statistics\n"
" tail [N] Print recent messages\n"
" tail -n N Print recent messages\n"
" post MESSAGE Post a message non-interactively\n"
" post \"/me act\" Post an action message\n"
" exit Exit successfully\n";
return client_send(client, help_text, sizeof(help_text) - 1) == 0 ? 0 : 1;
}
static int exec_command_health(client_t *client) {
static const char ok[] = "ok\n";
return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1;
}
static int exec_command_users(client_t *client, bool json) {
int count;
char (*usernames)[MAX_USERNAME_LEN] = NULL;
char *output;
size_t output_size;
size_t pos = 0;
int rc;
pthread_rwlock_rdlock(&g_room->lock);
count = g_room->client_count;
if (count > 0) {
usernames = calloc((size_t)count, sizeof(*usernames));
if (!usernames) {
pthread_rwlock_unlock(&g_room->lock);
client_printf(client, "users: out of memory\n");
return 1;
}
for (int i = 0; i < count; i++) {
snprintf(usernames[i], MAX_USERNAME_LEN, "%s",
g_room->clients[i]->username);
}
}
pthread_rwlock_unlock(&g_room->lock);
output_size = json ? ((size_t)count * (MAX_USERNAME_LEN * 2 + 8) + 8)
: ((size_t)count * (MAX_USERNAME_LEN + 1) + 1);
if (output_size < 8) {
output_size = 8;
}
output = calloc(output_size, 1);
if (!output) {
free(usernames);
client_printf(client, "users: out of memory\n");
return 1;
}
if (json) {
buffer_append_bytes(output, output_size, &pos, "[", 1);
for (int i = 0; i < count; i++) {
if (i > 0) {
buffer_append_bytes(output, output_size, &pos, ",", 1);
}
json_append_string(output, output_size, &pos, usernames[i]);
}
buffer_append_bytes(output, output_size, &pos, "]\n", 2);
} else {
for (int i = 0; i < count; i++) {
buffer_appendf(output, output_size, &pos, "%s\n", usernames[i]);
}
}
rc = client_send(client, output, pos) == 0 ? 0 : 1;
free(output);
free(usernames);
return rc;
}
static int exec_command_stats(client_t *client, bool json) {
int online_users;
int message_count;
int client_capacity;
int active_connections;
time_t now = time(NULL);
long uptime_seconds;
char buffer[512];
int len;
pthread_rwlock_rdlock(&g_room->lock);
online_users = g_room->client_count;
message_count = g_room->message_count;
client_capacity = g_room->client_capacity;
pthread_rwlock_unlock(&g_room->lock);
active_connections = ratelimit_get_active_total();
time_t start = ssh_server_start_time();
uptime_seconds = (start > 0 && now >= start) ? (long)(now - start) : 0;
if (json) {
len = snprintf(buffer, sizeof(buffer),
"{\"status\":\"ok\",\"online_users\":%d,"
"\"message_count\":%d,\"client_capacity\":%d,"
"\"active_connections\":%d,\"uptime_seconds\":%ld}\n",
online_users, message_count, client_capacity,
active_connections, uptime_seconds);
} else {
len = snprintf(buffer, sizeof(buffer),
"status ok\n"
"online_users %d\n"
"message_count %d\n"
"client_capacity %d\n"
"active_connections %d\n"
"uptime_seconds %ld\n",
online_users, message_count, client_capacity,
active_connections, uptime_seconds);
}
if (len < 0 || len >= (int)sizeof(buffer)) {
client_printf(client, "stats: output overflow\n");
return 1;
}
return client_send(client, buffer, (size_t)len) == 0 ? 0 : 1;
}
static int parse_tail_count(const char *args, int *count) {
char *end = NULL;
long value;
if (!count) {
return -1;
}
*count = 20;
if (!args || args[0] == '\0') {
return 0;
}
if (strncmp(args, "-n", 2) == 0) {
args += 2;
while (*args && isspace((unsigned char)*args)) {
args++;
}
}
value = strtol(args, &end, 10);
if (end == args) {
return -1;
}
while (*end) {
if (!isspace((unsigned char)*end)) {
return -1;
}
end++;
}
if (value < 1 || value > MAX_MESSAGES) {
return -1;
}
*count = (int)value;
return 0;
}
static int exec_command_tail(client_t *client, const char *args) {
int requested = 20;
int total_messages;
int start;
int count;
message_t *snapshot = NULL;
char *output;
size_t output_size;
size_t pos = 0;
int rc;
if (parse_tail_count(args, &requested) < 0) {
client_printf(client, "tail: usage: tail [N] | tail -n N\n");
return 64;
}
pthread_rwlock_rdlock(&g_room->lock);
total_messages = g_room->message_count;
start = total_messages - requested;
if (start < 0) {
start = 0;
}
count = total_messages - start;
if (count > 0) {
snapshot = calloc((size_t)count, sizeof(message_t));
if (!snapshot) {
pthread_rwlock_unlock(&g_room->lock);
client_printf(client, "tail: out of memory\n");
return 1;
}
memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t));
}
pthread_rwlock_unlock(&g_room->lock);
output_size = (size_t)(count > 0 ? count : 1) *
(MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48);
output = calloc(output_size, 1);
if (!output) {
free(snapshot);
client_printf(client, "tail: out of memory\n");
return 1;
}
for (int i = 0; i < count; i++) {
char timestamp[64];
format_timestamp_utc(snapshot[i].timestamp, timestamp, sizeof(timestamp));
buffer_appendf(output, output_size, &pos, "%s\t%s\t%s\n",
timestamp, snapshot[i].username, snapshot[i].content);
}
rc = client_send(client, output, pos) == 0 ? 0 : 1;
free(output);
free(snapshot);
return rc;
}
static int exec_command_post(client_t *client, const char *args) {
char content[MAX_MESSAGE_LEN];
char username[MAX_USERNAME_LEN];
message_t msg = {
.timestamp = time(NULL),
};
if (!args || args[0] == '\0') {
client_printf(client, "post: usage: post MESSAGE\n");
return 64;
}
strncpy(content, args, sizeof(content) - 1);
content[sizeof(content) - 1] = '\0';
trim_ascii_whitespace(content);
if (content[0] == '\0') {
client_printf(client, "post: message cannot be empty\n");
return 64;
}
if (!utf8_is_valid_string(content)) {
client_printf(client, "post: invalid UTF-8 input\n");
return 1;
}
resolve_exec_username(client, username, sizeof(username));
if (strncmp(content, "/me ", 4) == 0 && content[4] != '\0') {
msg.username[0] = '*';
msg.username[1] = '\0';
int n = snprintf(msg.content, sizeof(msg.content), "%s %s", username, content + 4);
if (n >= (int)sizeof(msg.content)) {
msg.content[sizeof(msg.content) - 1] = '\0';
}
} else {
strncpy(msg.username, username, sizeof(msg.username) - 1);
msg.username[sizeof(msg.username) - 1] = '\0';
strncpy(msg.content, content, sizeof(msg.content) - 1);
msg.content[sizeof(msg.content) - 1] = '\0';
}
room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
if (message_save(&msg) < 0) {
client_printf(client, "post: failed to persist message\n");
return 1;
}
return client_send(client, "posted\n", 7) == 0 ? 0 : 1;
}
int exec_dispatch(client_t *client) {
char command_copy[MAX_EXEC_COMMAND_LEN];
char *cmd;
char *args;
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
command_copy[sizeof(command_copy) - 1] = '\0';
trim_ascii_whitespace(command_copy);
cmd = command_copy;
if (*cmd == '\0') {
return exec_command_help(client);
}
args = cmd;
while (*args && !isspace((unsigned char)*args)) {
args++;
}
if (*args) {
*args++ = '\0';
while (*args && isspace((unsigned char)*args)) {
args++;
}
} else {
args = NULL;
}
if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0) {
return exec_command_help(client);
}
if (strcmp(cmd, "health") == 0) {
return exec_command_health(client);
}
if (strcmp(cmd, "users") == 0) {
if (args && strcmp(args, "--json") != 0) {
client_printf(client, "users: usage: users [--json]\n");
return 64;
}
return exec_command_users(client, args != NULL);
}
if (strcmp(cmd, "stats") == 0) {
if (args && strcmp(args, "--json") != 0) {
client_printf(client, "stats: usage: stats [--json]\n");
return 64;
}
return exec_command_stats(client, args != NULL);
}
if (strcmp(cmd, "tail") == 0) {
return exec_command_tail(client, args);
}
if (strcmp(cmd, "post") == 0) {
return exec_command_post(client, args);
}
if (strcmp(cmd, "exit") == 0) {
return 0;
}
client_printf(client, "Unknown command: %s\n", cmd);
return 64;
}

638
src/input.c Normal file
View file

@ -0,0 +1,638 @@
#include "input.h"
#include "chat_room.h"
#include "client.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_render_welcome(client);
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 / MOTD display: any key dismisses */
if (client->command_output[0] != '\0') {
client->command_output[0] = '\0';
client->show_motd = false;
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),
"%s", motd_buf);
client->show_motd = true;
tui_render_motd(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->show_motd) {
tui_render_motd(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

@ -31,7 +31,9 @@ void message_init(void) {
/* Nothing to initialize for now */
}
/* Load messages from log file - Optimized for large files */
/* Load messages from log file - Optimized for large files.
* Holds g_message_file_lock for the duration of the read so concurrent
* message_save() calls from chat threads cannot interleave a partial line. */
int message_load(message_t **messages, int max_messages) {
char log_path[PATH_MAX];
@ -46,9 +48,12 @@ int message_load(message_t **messages, int max_messages) {
return 0;
}
pthread_mutex_lock(&g_message_file_lock);
FILE *fp = fopen(log_path, "r");
if (!fp) {
/* File doesn't exist yet, no messages */
pthread_mutex_unlock(&g_message_file_lock);
*messages = msg_array;
return 0;
}
@ -56,6 +61,7 @@ int message_load(message_t **messages, int max_messages) {
/* Seek to end */
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
*messages = msg_array;
return 0;
}
@ -63,6 +69,7 @@ int message_load(message_t **messages, int max_messages) {
long file_size = ftell(fp);
if (file_size <= 0) {
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
*messages = msg_array;
return 0;
}
@ -175,6 +182,7 @@ read_messages:;
}
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
*messages = msg_array;
return count;
}

211
src/ratelimit.c Normal file
View file

@ -0,0 +1,211 @@
#include "ratelimit.h"
#include "common.h"
#include <arpa/inet.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#define MAX_TRACKED_IPS 256
#define RATE_LIMIT_WINDOW 60 /* seconds */
#define MAX_AUTH_FAILURES 5 /* auth failures before block */
#define BLOCK_DURATION 300 /* seconds to block after too many failures */
typedef struct {
char ip[INET6_ADDRSTRLEN];
time_t window_start;
int recent_connection_count;
int active_connections;
int auth_failure_count;
bool is_blocked;
time_t block_until;
} ip_rate_limit_t;
static ip_rate_limit_t g_rate_limits[MAX_TRACKED_IPS];
static pthread_mutex_t g_rate_limit_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_total_connections = 0;
static pthread_mutex_t g_conn_count_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_max_connections = 64;
static int g_max_conn_per_ip = 5;
static int g_max_conn_rate_per_ip = 10;
static int g_rate_limit_enabled = 1;
void ratelimit_init(void) {
g_max_connections = env_int("TNT_MAX_CONNECTIONS", 64, 1, 1024);
g_max_conn_per_ip = env_int("TNT_MAX_CONN_PER_IP", 5, 1, 1024);
g_max_conn_rate_per_ip = env_int("TNT_MAX_CONN_RATE_PER_IP", 10, 1, 1024);
g_rate_limit_enabled = env_int("TNT_RATE_LIMIT", 1, 0, 1);
}
/* Caller MUST hold g_rate_limit_lock. */
static ip_rate_limit_t* get_rate_limit_entry(const char *ip) {
/* Look for existing entry */
for (int i = 0; i < MAX_TRACKED_IPS; i++) {
if (strcmp(g_rate_limits[i].ip, ip) == 0) {
return &g_rate_limits[i];
}
}
/* Find empty slot */
for (int i = 0; i < MAX_TRACKED_IPS; i++) {
if (g_rate_limits[i].ip[0] == '\0') {
strncpy(g_rate_limits[i].ip, ip, sizeof(g_rate_limits[i].ip) - 1);
g_rate_limits[i].window_start = time(NULL);
g_rate_limits[i].recent_connection_count = 0;
g_rate_limits[i].active_connections = 0;
g_rate_limits[i].auth_failure_count = 0;
g_rate_limits[i].is_blocked = false;
g_rate_limits[i].block_until = 0;
return &g_rate_limits[i];
}
}
/* Reuse the oldest inactive entry first so active IP accounting stays intact. */
int oldest_idx = -1;
time_t oldest_time = 0;
for (int i = 0; i < MAX_TRACKED_IPS; i++) {
if (g_rate_limits[i].active_connections != 0) {
continue;
}
if (oldest_idx < 0 || g_rate_limits[i].window_start < oldest_time) {
oldest_time = g_rate_limits[i].window_start;
oldest_idx = i;
}
}
if (oldest_idx < 0) {
/* All slots have active connections — evicting will corrupt their
* concurrency accounting. Pick the oldest entry but warn. */
oldest_idx = 0;
oldest_time = g_rate_limits[0].window_start;
for (int i = 1; i < MAX_TRACKED_IPS; i++) {
if (g_rate_limits[i].window_start < oldest_time) {
oldest_time = g_rate_limits[i].window_start;
oldest_idx = i;
}
}
fprintf(stderr, "Warning: rate-limit table full, evicting active IP %s "
"(%d active connections lost)\n",
g_rate_limits[oldest_idx].ip,
g_rate_limits[oldest_idx].active_connections);
}
/* Reset and reuse */
strncpy(g_rate_limits[oldest_idx].ip, ip, sizeof(g_rate_limits[oldest_idx].ip) - 1);
g_rate_limits[oldest_idx].ip[sizeof(g_rate_limits[oldest_idx].ip) - 1] = '\0';
g_rate_limits[oldest_idx].window_start = time(NULL);
g_rate_limits[oldest_idx].recent_connection_count = 0;
g_rate_limits[oldest_idx].active_connections = 0;
g_rate_limits[oldest_idx].auth_failure_count = 0;
g_rate_limits[oldest_idx].is_blocked = false;
g_rate_limits[oldest_idx].block_until = 0;
return &g_rate_limits[oldest_idx];
}
bool ratelimit_check_ip(const char *ip) {
time_t now = time(NULL);
pthread_mutex_lock(&g_rate_limit_lock);
ip_rate_limit_t *entry = get_rate_limit_entry(ip);
if (entry->active_connections >= g_max_conn_per_ip) {
pthread_mutex_unlock(&g_rate_limit_lock);
fprintf(stderr, "Concurrent IP limit reached for %s\n", ip);
return false;
}
if (g_rate_limit_enabled && entry->is_blocked && now < entry->block_until) {
pthread_mutex_unlock(&g_rate_limit_lock);
fprintf(stderr, "Blocked IP %s (blocked until %ld)\n", ip, (long)entry->block_until);
return false;
}
if (g_rate_limit_enabled && entry->is_blocked && now >= entry->block_until) {
entry->is_blocked = false;
entry->auth_failure_count = 0;
}
if (g_rate_limit_enabled) {
if (now - entry->window_start >= RATE_LIMIT_WINDOW) {
entry->window_start = now;
entry->recent_connection_count = 0;
}
entry->recent_connection_count++;
if (entry->recent_connection_count >= g_max_conn_rate_per_ip) {
entry->is_blocked = true;
entry->block_until = now + BLOCK_DURATION;
pthread_mutex_unlock(&g_rate_limit_lock);
fprintf(stderr, "Rate limit exceeded for IP %s\n", ip);
return false;
}
}
entry->active_connections++;
pthread_mutex_unlock(&g_rate_limit_lock);
return true;
}
void ratelimit_record_auth_failure(const char *ip) {
time_t now = time(NULL);
if (!g_rate_limit_enabled) {
return;
}
pthread_mutex_lock(&g_rate_limit_lock);
ip_rate_limit_t *entry = get_rate_limit_entry(ip);
entry->auth_failure_count++;
if (entry->auth_failure_count >= MAX_AUTH_FAILURES) {
entry->is_blocked = true;
entry->block_until = now + BLOCK_DURATION;
fprintf(stderr, "IP %s blocked due to %d auth failures\n", ip, entry->auth_failure_count);
}
pthread_mutex_unlock(&g_rate_limit_lock);
}
void ratelimit_release_ip(const char *ip) {
if (!ip || ip[0] == '\0') {
return;
}
pthread_mutex_lock(&g_rate_limit_lock);
ip_rate_limit_t *entry = get_rate_limit_entry(ip);
if (entry->active_connections > 0) {
entry->active_connections--;
}
pthread_mutex_unlock(&g_rate_limit_lock);
}
bool ratelimit_check_and_increment_total(void) {
pthread_mutex_lock(&g_conn_count_lock);
if (g_total_connections >= g_max_connections) {
pthread_mutex_unlock(&g_conn_count_lock);
return false;
}
g_total_connections++;
pthread_mutex_unlock(&g_conn_count_lock);
return true;
}
void ratelimit_decrement_total(void) {
pthread_mutex_lock(&g_conn_count_lock);
if (g_total_connections > 0) {
g_total_connections--;
}
pthread_mutex_unlock(&g_conn_count_lock);
}
int ratelimit_get_active_total(void) {
int count;
pthread_mutex_lock(&g_conn_count_lock);
count = g_total_connections;
pthread_mutex_unlock(&g_conn_count_lock);
return count;
}

File diff suppressed because it is too large Load diff

357
src/tui.c
View file

@ -1,8 +1,8 @@
#include "tui.h"
#include "client.h"
#include "ssh_server.h"
#include "chat_room.h"
#include "utf8.h"
#include <stdarg.h>
#include <unistd.h>
static bool is_join_leave_msg(const message_t *msg) {
@ -111,46 +111,6 @@ static void format_message_colored(const message_t *msg, char *buffer,
}
}
static void buffer_append_bytes(char *buffer, size_t buf_size, size_t *pos,
const char *data, size_t len) {
size_t available;
size_t to_copy;
if (!buffer || !pos || !data || len == 0 || buf_size == 0 || *pos >= buf_size - 1) {
return;
}
available = (buf_size - 1) - *pos;
to_copy = (len < available) ? len : available;
memcpy(buffer + *pos, data, to_copy);
*pos += to_copy;
buffer[*pos] = '\0';
}
static void buffer_appendf(char *buffer, size_t buf_size, size_t *pos,
const char *fmt, ...) {
va_list args;
int written;
if (!buffer || !pos || !fmt || buf_size == 0 || *pos >= buf_size - 1) {
return;
}
va_start(args, fmt);
written = vsnprintf(buffer + *pos, buf_size - *pos, fmt, args);
va_end(args);
if (written < 0) {
return;
}
if ((size_t)written >= buf_size - *pos) {
*pos = buf_size - 1;
} else {
*pos += (size_t)written;
}
}
/* Clear the screen */
void tui_clear_screen(client_t *client) {
if (!client || !client->connected) return;
@ -158,6 +118,102 @@ void tui_clear_screen(client_t *client) {
client_send(client, clear, strlen(clear));
}
/* Render the pre-login welcome banner.
*
* Centred horizontally; vertically positioned about a third of the way down
* the available height so the user's eye lands naturally on it before the
* prompt below. Uses light box-drawing characters (U+256D / U+2570) so the
* frame matches the rest of the TUI's aesthetic instead of the older ASCII
* `==` rules. */
void tui_render_welcome(client_t *client) {
if (!client || !client->connected) return;
int rw = client->width;
int rh = client->height;
if (rw < 10) rw = 10;
if (rh < 4) rh = 4;
/* Lines, in display order. Width is computed in display columns. */
const char *line1 = "TNT · " TNT_VERSION;
const char *line2 = "匿名聊天室 · SSH";
const char *line3 = "Anonymous chat over SSH";
int inner_w = utf8_string_width(line1);
int w2 = utf8_string_width(line2);
int w3 = utf8_string_width(line3);
if (w2 > inner_w) inner_w = w2;
if (w3 > inner_w) inner_w = w3;
inner_w += 4; /* 2 columns padding on each side */
/* Fall back to plain prompt if the terminal is too narrow for the frame. */
if (inner_w + 2 > rw) {
char fallback[128];
int n = snprintf(fallback, sizeof(fallback),
ANSI_CLEAR ANSI_HOME
"TNT %s — anonymous chat over SSH\r\n\r\n",
TNT_VERSION);
if (n > 0) client_send(client, fallback, (size_t)n);
return;
}
int top_pad = rh / 3;
if (top_pad < 1) top_pad = 1;
int left_pad = (rw - (inner_w + 2)) / 2;
if (left_pad < 0) left_pad = 0;
/* ~5 KiB is plenty for the framed banner even on the largest terminals. */
char buf[4096];
size_t pos = 0;
buffer_appendf(buf, sizeof(buf), &pos, ANSI_CLEAR ANSI_HOME);
for (int i = 0; i < top_pad; i++) {
buffer_appendf(buf, sizeof(buf), &pos, "\r\n");
}
/* Top border: ╭───…───╮ */
for (int i = 0; i < left_pad; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1);
buffer_append_bytes(buf, sizeof(buf), &pos, "\033[36m", 5);
buffer_append_bytes(buf, sizeof(buf), &pos, "", strlen(""));
for (int i = 0; i < inner_w; i++) buffer_append_bytes(buf, sizeof(buf), &pos, "", strlen(""));
buffer_append_bytes(buf, sizeof(buf), &pos, "", strlen(""));
buffer_append_bytes(buf, sizeof(buf), &pos, "\033[0m", 4);
buffer_appendf(buf, sizeof(buf), &pos, "\r\n");
/* Three content lines with surrounding │ borders, centred inside the frame. */
const char *lines[3] = {line1, line2, line3};
int widths[3] = {utf8_string_width(line1), w2, w3};
const char *line_color[3] = {"\033[1;36m", "\033[0m", "\033[2;37m"};
for (int li = 0; li < 3; li++) {
for (int i = 0; i < left_pad; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1);
buffer_append_bytes(buf, sizeof(buf), &pos, "\033[36m", 5);
buffer_append_bytes(buf, sizeof(buf), &pos, "", strlen(""));
buffer_append_bytes(buf, sizeof(buf), &pos, "\033[0m", 4);
int pad_total = inner_w - widths[li];
int pad_left = pad_total / 2;
int pad_right = pad_total - pad_left;
for (int i = 0; i < pad_left; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1);
buffer_appendf(buf, sizeof(buf), &pos, "%s%s\033[0m", line_color[li], lines[li]);
for (int i = 0; i < pad_right; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1);
buffer_append_bytes(buf, sizeof(buf), &pos, "\033[36m", 5);
buffer_append_bytes(buf, sizeof(buf), &pos, "", strlen(""));
buffer_append_bytes(buf, sizeof(buf), &pos, "\033[0m", 4);
buffer_appendf(buf, sizeof(buf), &pos, "\r\n");
}
/* Bottom border: ╰───…───╯ */
for (int i = 0; i < left_pad; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1);
buffer_append_bytes(buf, sizeof(buf), &pos, "\033[36m", 5);
buffer_append_bytes(buf, sizeof(buf), &pos, "", strlen(""));
for (int i = 0; i < inner_w; i++) buffer_append_bytes(buf, sizeof(buf), &pos, "", strlen(""));
buffer_append_bytes(buf, sizeof(buf), &pos, "", strlen(""));
buffer_append_bytes(buf, sizeof(buf), &pos, "\033[0m", 4);
buffer_appendf(buf, sizeof(buf), &pos, "\r\n\r\n");
client_send(client, buf, pos);
}
/* Render the main screen */
void tui_render_screen(client_t *client) {
if (!client || !client->connected) return;
@ -242,40 +298,116 @@ void tui_render_screen(client_t *client) {
/* Move to top (Home) - Do NOT clear screen to prevent flicker */
buffer_appendf(buffer, buf_size, &pos, ANSI_HOME);
/* Title bar */
const char *mode_str = (client->mode == MODE_INSERT) ? "INSERT" :
(client->mode == MODE_NORMAL) ? "NORMAL" :
(client->mode == MODE_COMMAND) ? "COMMAND" : "HELP";
/* Title bar — segmented chips on a single line, no full-line reverse.
*
* Segments (left to right):
* bold username
* online count
* mode name (colour matches the mode itself: cyan/yellow/magenta)
* mute marker, only when active
* right-aligned hint
*
* `· ` separators are dim grey so the eye groups segments without
* mistaking them for content. */
struct title_chip { const char *value; const char *value_color; };
struct title_chip chips[3];
int chip_count = 0;
char title[256];
snprintf(title, sizeof(title),
" %s | 在线: %d | 模式: %s%s | ? 帮助 ",
client->username, online, mode_str,
client->mute_joins ? " [静音]" : "");
chips[chip_count].value = client->username;
chips[chip_count].value_color = "\033[1;37m";
chip_count++;
int title_width = utf8_string_width(title);
int padding = render_width - title_width;
if (padding < 0) padding = 0;
char online_buf[32];
snprintf(online_buf, sizeof(online_buf), "在线 %d", online);
chips[chip_count].value = online_buf;
chips[chip_count].value_color = "\033[37m";
chip_count++;
buffer_appendf(buffer, buf_size, &pos, ANSI_REVERSE "%s", title);
for (int i = 0; i < padding; i++) {
const char *mode_str;
const char *mode_color;
switch (client->mode) {
case MODE_INSERT: mode_str = "INSERT"; mode_color = "\033[36m"; break;
case MODE_NORMAL: mode_str = "NORMAL"; mode_color = "\033[33m"; break;
case MODE_COMMAND: mode_str = "COMMAND"; mode_color = "\033[35m"; break;
default: mode_str = "HELP"; mode_color = "\033[34m"; break;
}
chips[chip_count].value = mode_str;
chips[chip_count].value_color = mode_color;
chip_count++;
/* Compose left half. */
char left[256];
size_t lpos = 0;
int left_width = 0;
for (int i = 0; i < chip_count; i++) {
if (i > 0) {
buffer_appendf(left, sizeof(left), &lpos, "\033[2;37m · \033[0m");
left_width += 3;
}
buffer_appendf(left, sizeof(left), &lpos, "%s%s\033[0m",
chips[i].value_color, chips[i].value);
left_width += utf8_string_width(chips[i].value);
}
if (client->mute_joins) {
buffer_appendf(left, sizeof(left), &lpos, " \033[2;37m静音\033[0m");
left_width += 4;
}
const char *hint = "? 帮助";
int hint_width = utf8_string_width(hint);
int gap = render_width - left_width - hint_width - 2;
if (gap < 1) gap = 1;
buffer_appendf(buffer, buf_size, &pos, " %s", left);
for (int i = 0; i < gap; i++) {
buffer_append_bytes(buffer, buf_size, &pos, " ", 1);
}
buffer_appendf(buffer, buf_size, &pos, ANSI_RESET "\033[K\r\n");
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m%s\033[0m \033[K\r\n", hint);
/* Render messages from snapshot */
/* Render messages from snapshot. Insert a dim "── YYYY-MM-DD ──" divider
* before the first message of each new day so the eye can land on dates
* when scrolling through a long-lived room.
*
* We track rows_written separately from snapshot index so dividers
* compete with messages for the fixed message-area height they do not
* push other content off the bottom. */
int rows_written = 0;
if (msg_snapshot) {
for (int i = 0; i < snapshot_count; i++) {
char last_date[11] = ""; /* "YYYY-MM-DD" */
for (int i = 0; i < snapshot_count && rows_written < msg_height; i++) {
char this_date[11];
struct tm tmi;
localtime_r(&msg_snapshot[i].timestamp, &tmi);
strftime(this_date, sizeof(this_date), "%Y-%m-%d", &tmi);
if (strcmp(this_date, last_date) != 0) {
/* Build divider: "── YYYY-MM-DD " then fill the rest with ─ */
int prefix_w = 3 + 10 + 1; /* "── 2026-05-17 " in display columns */
int dash_fill = render_width - prefix_w;
if (dash_fill < 0) dash_fill = 0;
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m── %s ", this_date);
for (int j = 0; j < dash_fill; j++) {
buffer_append_bytes(buffer, buf_size, &pos, "", strlen(""));
}
buffer_appendf(buffer, buf_size, &pos, "\033[0m\033[K\r\n");
memcpy(last_date, this_date, sizeof(last_date));
rows_written++;
if (rows_written >= msg_height) break;
}
char msg_line[2048];
format_message_colored(&msg_snapshot[i], msg_line, sizeof(msg_line),
render_width, client->username);
buffer_appendf(buffer, buf_size, &pos, "%s\033[K\r\n", msg_line);
rows_written++;
}
free(msg_snapshot);
}
/* Fill empty lines and clear them */
for (int i = snapshot_count; i < msg_height; i++) {
for (int i = rows_written; i < msg_height; i++) {
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
}
@ -287,22 +419,28 @@ void tui_render_screen(client_t *client) {
/* Status/Input line */
if (client->mode == MODE_INSERT) {
buffer_appendf(buffer, buf_size, &pos, "> \033[K");
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m\033[0m \033[K");
} else if (client->mode == MODE_NORMAL) {
int total = msg_count;
int scroll_pos = client->scroll_pos + 1;
if (total == 0) scroll_pos = 0;
int unseen = msg_count - end;
/* mode reverse-video chip + dim position + optional unseen marker */
if (unseen > 0) {
buffer_appendf(buffer, buf_size, &pos,
"-- NORMAL -- (%d/%d) \033[33m↓ %d new\033[0m\033[K",
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d / %d\033[0m"
" \033[33m▼ %d new\033[0m\033[K",
scroll_pos, total, unseen);
} else {
buffer_appendf(buffer, buf_size, &pos,
"-- NORMAL -- (%d/%d)\033[K", scroll_pos, total);
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d / %d\033[0m\033[K",
scroll_pos, total);
}
} else if (client->mode == MODE_COMMAND) {
buffer_appendf(buffer, buf_size, &pos, ":%s\033[K", client->command_input);
buffer_appendf(buffer, buf_size, &pos,
"\033[35m:\033[0m%s\033[K", client->command_input);
}
client_send(client, buffer, pos);
@ -344,7 +482,8 @@ void tui_render_input(client_t *client, const char *input) {
}
/* Move to input line and clear it, then write input */
snprintf(buffer, sizeof(buffer), "\033[%d;1H" ANSI_CLEAR_LINE "> %s",
snprintf(buffer, sizeof(buffer),
"\033[%d;1H" ANSI_CLEAR_LINE "\033[2;37m\033[0m %s",
rh, display);
client_send(client, buffer, strlen(buffer));
@ -404,7 +543,95 @@ void tui_render_command_output(client_t *client) {
client_send(client, buffer, pos);
}
/* Get help text based on language */
/* Render the MOTD screen.
*
* A framed banner with a title chip embedded in the top border and an
* "any key to continue" hint embedded in the bottom border, MOTD body
* left-padded inside. Dismissed by handle_key like any other modal
* (sets command_output[0]='\0' and show_motd=false).
*
* Lighter aesthetic than tui_render_command_output: no full-line reverse,
* dim borders, two blank lines of breathing room above and below the
* body so the announcement reads as a notice rather than a console dump. */
void tui_render_motd(client_t *client) {
if (!client || !client->connected) return;
int rw = client->width;
int rh = client->height;
if (rw < 10) rw = 10;
if (rh < 4) rh = 4;
char buffer[4096];
size_t pos = 0;
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Top border: ╭─ 公告 / MOTD ──...──╮ */
const char *title = " 公告 / MOTD ";
int title_w = utf8_string_width(title);
int top_dash_fill = rw - 2 - title_w - 1; /* 2 corners, 1 leading ─ */
if (top_dash_fill < 0) top_dash_fill = 0;
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[2;36m╭─");
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[0;1;36m%s\033[2;36m", title);
for (int i = 0; i < top_dash_fill; i++) {
buffer_append_bytes(buffer, sizeof(buffer), &pos, "", strlen(""));
}
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[0m\r\n");
/* Top breathing-room line */
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
/* Body lines (left-pad 2 cols, truncate to inner width) */
char body_copy[2048];
strncpy(body_copy, client->command_output, sizeof(body_copy) - 1);
body_copy[sizeof(body_copy) - 1] = '\0';
int body_lines = 0;
int max_body_lines = rh - 4; /* top border + top pad + bottom pad + bottom border */
if (max_body_lines < 1) max_body_lines = 1;
char *line = strtok(body_copy, "\n");
while (line && body_lines < max_body_lines) {
char truncated[1024];
strncpy(truncated, line, sizeof(truncated) - 1);
truncated[sizeof(truncated) - 1] = '\0';
int avail = rw - 4; /* 2 cols padding each side */
if (avail < 4) avail = 4;
if (utf8_string_width(truncated) > avail) {
utf8_truncate(truncated, avail);
}
buffer_appendf(buffer, sizeof(buffer), &pos, " %s\r\n", truncated);
body_lines++;
line = strtok(NULL, "\n");
}
/* Fill empty space up to the bottom border */
int used_rows = 1 /*top*/ + 1 /*pad*/ + body_lines + 1 /*pad*/ + 1 /*bottom*/;
int filler_rows = rh - used_rows;
if (filler_rows < 0) filler_rows = 0;
for (int i = 0; i < filler_rows; i++) {
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
}
/* Bottom breathing-room line */
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
/* Bottom border: ╰─ 按任意键继续 ─...─╯ */
const char *footer = " 按任意键继续 / press any key ";
int footer_w = utf8_string_width(footer);
int bot_dash_fill = rw - 2 - footer_w - 1;
if (bot_dash_fill < 0) bot_dash_fill = 0;
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[2;36m╰─");
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[0;2;37m%s\033[2;36m", footer);
for (int i = 0; i < bot_dash_fill; i++) {
buffer_append_bytes(buffer, sizeof(buffer), &pos, "", strlen(""));
}
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[0m");
client_send(client, buffer, pos);
}
const char* tui_get_help_text(help_lang_t lang) {
if (lang == LANG_EN) {
return "TERMINAL CHAT ROOM - HELP\n"