Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ ssh -p 2222 chat.example.com 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.

**`dump` limits**: plain `dump` returns the last 100 persisted records. Use
`dump -n N` for an explicit bounded export or `dump --all` for a full log export.

See [docs/INTERFACE.md](docs/INTERFACE.md) for the stable exec command
contract, exit statuses, and JSON field definitions.

Expand Down
17 changes: 17 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

## Unreleased

### Changed
- `dump` now defaults to the last 100 persisted records; use `dump -n N` for an
explicit bounded export or `dump --all` for a full persisted-log export.
- Interactive sends now persist before broadcasting, matching the SSH exec
`post` path and avoiding visible-but-unpersisted messages when storage fails.

### Security
- Hardened module child process startup by closing inherited file descriptors,
disabling common dynamic-loader injection variables, and applying conservative
core and file-descriptor limits before exec.
- Hardened module shutdown by escalating from SIGTERM to SIGKILL after a grace
period and reaping the child process.
- Reduced per-session thread stack size to lower high-connection memory pressure.
- Added additional systemd service hardening for devices, kernel interfaces,
realtime scheduling, SUID/SGID transitions, personality changes, and
capabilities.

## 1.1.0 - 2026-06-16

### Added
Expand Down
7 changes: 4 additions & 3 deletions docs/INTERFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,17 @@ Prints recent in-memory messages as tab-separated lines:
The current upper bound is `MAX_MESSAGES`. This command reads the live
in-memory room buffer, not the full persisted log.

### `dump [N]` / `dump -n N`
### `dump [N]` / `dump -n N` / `dump --all`

Exports valid persisted `messages.log` v1 records in chronological order:

```text
2026-05-25T12:00:00Z|alice|hello
```

Without `N`, `dump` exports all valid persisted records. With `N`, it exports
the last `N` valid persisted records. Malformed, invalid UTF-8, oversized, or
Without `N`, `dump` exports the last 100 valid persisted records. With `N`,
it exports the last `N` valid persisted records. Use `dump --all` to export
all valid persisted records. Malformed, invalid UTF-8, oversized, or
truncated records are skipped by the same strict parser used for replay and
search.

Expand Down
9 changes: 5 additions & 4 deletions docs/MESSAGE_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ line is treated as a partial append and ignored rather than replayed.

## Export

`dump [N]` and `dump -n N` export valid persisted records through the SSH exec
interface and `tntctl`. The output format is exactly the v1 record format
above. Without `N`, `dump` exports all valid records; with `N`, it exports the
last `N` valid records.
`dump [N]`, `dump -n N`, and `dump --all` export valid persisted records
through the SSH exec interface and `tntctl`. The output format is exactly the
v1 record format above. Without `N`, `dump` exports the last 100 valid records;
with `N`, it exports the last `N` valid records. Use `dump --all` to export all
valid records.

## Maintenance

Expand Down
3 changes: 2 additions & 1 deletion docs/QUICKREF.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ EXEC COMMANDS
stats [--json] print room statistics
users [--json] list online users
tail [N] / tail -n N recent in-memory room messages
dump [N] / dump -n N persisted messages.log v1 records
dump [N] / dump -n N / dump --all
persisted messages.log v1 records
post <message> post as the SSH login name

MAINTENANCE
Expand Down
16 changes: 14 additions & 2 deletions src/exec.c
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
#include <string.h>
#include <time.h>

#define TNT_DUMP_DEFAULT_RECORDS 100
#define TNT_DUMP_MAX_RECORDS 10000

/* `notify_mentions` is shared with the interactive INSERT-mode send path.
* Declared in input.h. */

Expand Down Expand Up @@ -259,11 +262,20 @@ static int parse_dump_count(const char *args, int *count) {
return -1;
}

*count = 0;
*count = TNT_DUMP_DEFAULT_RECORDS;
if (!args || args[0] == '\0') {
return 0;
}

while (*args && isspace((unsigned char)*args)) {
args++;
}

if (strcmp(args, "--all") == 0) {
*count = 0;
return 0;
}

if (strncmp(args, "-n", 2) == 0) {
args += 2;
while (*args && isspace((unsigned char)*args)) {
Expand All @@ -282,7 +294,7 @@ static int parse_dump_count(const char *args, int *count) {
end++;
}

if (value < 1 || value > 10000) {
if (value < 1 || value > TNT_DUMP_MAX_RECORDS) {
return -1;
}

Expand Down
8 changes: 6 additions & 2 deletions src/exec_catalog.c
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ static const exec_catalog_entry_t entries[] = {
I18N_STRING("Print recent messages", "输出最近消息"),
false, false, false},
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
"dump [N]", "dump [N] | dump -n N",
"dump [N]", "dump [N] | dump -n N | dump --all",
I18N_STRING("Export persisted messages", "导出持久化消息"),
false, false, false},
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
"dump -n N", "dump [N] | dump -n N",
"dump -n N", "dump [N] | dump -n N | dump --all",
I18N_STRING("Export persisted messages", "导出持久化消息"),
false, false, false},
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
"dump --all", "dump [N] | dump -n N | dump --all",
I18N_STRING("Export persisted messages", "导出持久化消息"),
false, false, false},
{TNT_EXEC_COMMAND_POST, "post", NULL,
Expand Down
4 changes: 2 additions & 2 deletions src/input.c
Original file line number Diff line number Diff line change
Expand Up @@ -583,9 +583,9 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
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);
if (message_save(&msg) == 0) {
room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
tnt_module_runtime_publish_message_created(&msg);
} else {
fprintf(stderr, "interactive: failed to persist message\n");
Expand Down
90 changes: 86 additions & 4 deletions src/module_runtime.c
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/select.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define TNT_MODULE_LINE_MAX 4096
#define TNT_MODULE_HANDSHAKE_TIMEOUT_MS 2000
#define TNT_MODULE_RESPONSE_TIMEOUT_MS 100
#define TNT_MODULE_MAX_RESPONSES_PER_EVENT 8
#define TNT_MODULE_MAX_INVALID_RESPONSES 3
#define TNT_MODULE_STOP_GRACE_MS 500
#define TNT_MODULE_MAX_OPEN_FILES 64

struct client;
void notify_mentions(const char *content, const struct client *sender);
Expand Down Expand Up @@ -241,13 +246,89 @@ static int read_line_timeout(int fd, char *line, size_t line_size,
return pos > 0 ? (int)pos : 0;
}

static void set_module_rlimit(int resource, rlim_t value) {
struct rlimit limit;

limit.rlim_cur = value;
limit.rlim_max = value;
(void)setrlimit(resource, &limit);
}

static void close_inherited_fds(void) {
long open_max = sysconf(_SC_OPEN_MAX);

if (open_max < 0 || open_max > 65536) {
open_max = 1024;
}

for (int fd = 3; fd < open_max; fd++) {
close(fd);
}
}

static void prepare_module_child(void) {
close_inherited_fds();

#ifdef RLIMIT_CORE
set_module_rlimit(RLIMIT_CORE, 0);
#endif
#ifdef RLIMIT_NOFILE
set_module_rlimit(RLIMIT_NOFILE, TNT_MODULE_MAX_OPEN_FILES);
#endif
unsetenv("LD_PRELOAD");
unsetenv("LD_LIBRARY_PATH");
#ifdef __APPLE__
unsetenv("DYLD_INSERT_LIBRARIES");
unsetenv("DYLD_LIBRARY_PATH");
#endif
}

static void sleep_millis(int millis) {
struct timespec ts;

ts.tv_sec = millis / 1000;
ts.tv_nsec = (long)(millis % 1000) * 1000000L;
while (nanosleep(&ts, &ts) < 0 && errno == EINTR) {
}
}

static void reap_module_process(pid_t pid) {
int status;
int waited_ms = 0;

if (pid <= 0) {
return;
}

while (waitpid(pid, &status, WNOHANG) == 0) {
if (waited_ms >= TNT_MODULE_STOP_GRACE_MS) {
kill(pid, SIGKILL);
break;
}
sleep_millis(10);
waited_ms += 10;
}

while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {
}
}

static void close_module_process(module_process_t *module) {
if (!module || !module->active) return;

close(module->stdin_fd);
close(module->stdout_fd);
kill(module->pid, SIGTERM);
waitpid(module->pid, NULL, WNOHANG);
if (module->stdin_fd >= 0) {
close(module->stdin_fd);
module->stdin_fd = -1;
}
if (module->stdout_fd >= 0) {
close(module->stdout_fd);
module->stdout_fd = -1;
}
if (module->pid > 0) {
kill(module->pid, SIGTERM);
reap_module_process(module->pid);
module->pid = -1;
}
module->active = false;
}

Expand Down Expand Up @@ -301,6 +382,7 @@ static int start_module_process(const char *module_dir,
}
close(in_pipe[0]);
close(out_pipe[1]);
prepare_module_child();
execl(module->manifest.entrypoint, module->manifest.entrypoint,
(char *)NULL);
_exit(127);
Expand Down
14 changes: 14 additions & 0 deletions src/ssh_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
#include <ctype.h>
#include <stdarg.h>
#include <sys/stat.h>
#include <limits.h>

#define TNT_SESSION_THREAD_STACK_SIZE ((size_t)1024 * 1024)

/* Global SSH bind instance */
static ssh_bind g_sshbind = NULL;
Expand Down Expand Up @@ -211,6 +214,17 @@ int ssh_server_start(int unused) {

pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
{
size_t stack_size = TNT_SESSION_THREAD_STACK_SIZE;
#ifdef PTHREAD_STACK_MIN
if (stack_size < PTHREAD_STACK_MIN) {
stack_size = PTHREAD_STACK_MIN;
}
#endif
if (pthread_attr_setstacksize(&attr, stack_size) != 0) {
fprintf(stderr, "Warning: could not set session thread stack size\n");
}
}

while (1) {
ssh_session session = ssh_new();
Expand Down
18 changes: 16 additions & 2 deletions tests/test_exec_mode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ fi

DUMP_USAGE=$(ssh $SSH_OPTS localhost "dump -n nope" 2>/dev/null)
DUMP_USAGE_STATUS=$?
printf '%s\n' "$DUMP_USAGE" | grep -q '^dump: 用法: dump \[N\] | dump -n N$'
printf '%s\n' "$DUMP_USAGE" | grep -q '^dump: 用法: dump \[N\] | dump -n N | dump --all$'
if [ $? -eq 0 ] && [ "$DUMP_USAGE_STATUS" -eq 64 ]; then
echo "✓ dump usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1))
Expand Down Expand Up @@ -185,6 +185,17 @@ else
FAIL=$((FAIL + 1))
fi

DUMP_ALL_OUTPUT=$(ssh $SSH_OPTS localhost "dump --all" 2>/dev/null || true)
printf '%s\n' "$DUMP_ALL_OUTPUT" | grep -q '|execposter|hello from exec$'
if [ $? -eq 0 ]; then
echo "✓ dump --all explicitly exports persisted records"
PASS=$((PASS + 1))
else
echo "✗ dump --all output unexpected"
printf '%s\n' "$DUMP_ALL_OUTPUT"
FAIL=$((FAIL + 1))
fi

PERSIST_FAIL_MARKER="persist-fail-marker"
rm -f "$STATE_DIR/messages.log"
mkdir "$STATE_DIR/messages.log"
Expand All @@ -193,11 +204,14 @@ PERSIST_FAIL_STATUS=$?
rmdir "$STATE_DIR/messages.log"
printf '%s\n' "$PERSIST_FAIL_OUTPUT" | grep -q 'posted'
PERSIST_FAIL_POSTED=$?
printf '%s\n' "$PERSIST_FAIL_OUTPUT" | grep -q '持久化失败'
PERSIST_FAIL_ERROR=$?
PERSIST_FAIL_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
printf '%s\n' "$PERSIST_FAIL_TAIL" | grep -q "$PERSIST_FAIL_MARKER"
PERSIST_FAIL_VISIBLE=$?
if [ "$PERSIST_FAIL_STATUS" -eq 1 ] &&
if [ "$PERSIST_FAIL_STATUS" -ne 0 ] &&
[ "$PERSIST_FAIL_POSTED" -ne 0 ] &&
[ "$PERSIST_FAIL_ERROR" -eq 0 ] &&
[ "$PERSIST_FAIL_VISIBLE" -ne 0 ]; then
echo "✓ post persistence failure is not broadcast or acknowledged"
PASS=$((PASS + 1))
Expand Down
7 changes: 6 additions & 1 deletion tests/unit/test_exec_catalog.c
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ TEST(matches_exec_commands_and_args) {
assert(id == TNT_EXEC_COMMAND_DUMP);
assert(strcmp(args, "-n 20") == 0);

assert(exec_catalog_match("dump --all", &id, &args));
assert(id == TNT_EXEC_COMMAND_DUMP);
assert(strcmp(args, "--all") == 0);

assert(exec_catalog_match("post hello world", &id, &args));
assert(id == TNT_EXEC_COMMAND_POST);
assert(strcmp(args, "hello world") == 0);
Expand Down Expand Up @@ -98,6 +102,7 @@ TEST(validates_argument_shapes) {

assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, "-n 20"));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, "--all"));

assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello"));
Expand All @@ -121,7 +126,7 @@ TEST(generates_localized_usage) {
en_pos = 0;
exec_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_EXEC_COMMAND_DUMP, (ui_lang_t)99);
assert(strcmp(en, "dump: usage: dump [N] | dump -n N\n") == 0);
assert(strcmp(en, "dump: usage: dump [N] | dump -n N | dump --all\n") == 0);
}

TEST(generates_unique_command_list) {
Expand Down
9 changes: 9 additions & 0 deletions tnt.service
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,17 @@ StartLimitBurst=5
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
SystemCallArchitectures=native
CapabilityBoundingSet=
ReadWritePaths=/var/lib/tnt

# Resource limits for stability
Expand Down
Loading
Loading