diff --git a/README.md b/README.md index 4309e95..b619fb6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9407510..90761e9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index 1c7bf34..6b738bf 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -130,7 +130,7 @@ 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: @@ -138,8 +138,9 @@ Exports valid persisted `messages.log` v1 records in chronological order: 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. diff --git a/docs/MESSAGE_LOG.md b/docs/MESSAGE_LOG.md index 5f243d7..078b6cd 100644 --- a/docs/MESSAGE_LOG.md +++ b/docs/MESSAGE_LOG.md @@ -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 diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 4250806..c857a8c 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -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 post as the SSH login name MAINTENANCE diff --git a/src/exec.c b/src/exec.c index 2d0fdc0..4fe525e 100644 --- a/src/exec.c +++ b/src/exec.c @@ -16,6 +16,9 @@ #include #include +#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. */ @@ -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)) { @@ -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; } diff --git a/src/exec_catalog.c b/src/exec_catalog.c index 6f01d8d..52014dc 100644 --- a/src/exec_catalog.c +++ b/src/exec_catalog.c @@ -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, diff --git a/src/input.c b/src/input.c index aa8b206..856bed4 100644 --- a/src/input.c +++ b/src/input.c @@ -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"); diff --git a/src/module_runtime.c b/src/module_runtime.c index 007df88..d7a7303 100644 --- a/src/module_runtime.c +++ b/src/module_runtime.c @@ -9,8 +9,11 @@ #include #include #include +#include #include +#include #include +#include #include #define TNT_MODULE_LINE_MAX 4096 @@ -18,6 +21,8 @@ #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); @@ -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; } @@ -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); diff --git a/src/ssh_server.c b/src/ssh_server.c index d560b2e..3f5f126 100644 --- a/src/ssh_server.c +++ b/src/ssh_server.c @@ -21,6 +21,9 @@ #include #include #include +#include + +#define TNT_SESSION_THREAD_STACK_SIZE ((size_t)1024 * 1024) /* Global SSH bind instance */ static ssh_bind g_sshbind = NULL; @@ -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(); diff --git a/tests/test_exec_mode.sh b/tests/test_exec_mode.sh index 88ad874..a863c42 100755 --- a/tests/test_exec_mode.sh +++ b/tests/test_exec_mode.sh @@ -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)) @@ -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" @@ -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)) diff --git a/tests/unit/test_exec_catalog.c b/tests/unit/test_exec_catalog.c index 1587da7..0efc969 100644 --- a/tests/unit/test_exec_catalog.c +++ b/tests/unit/test_exec_catalog.c @@ -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); @@ -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")); @@ -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) { diff --git a/tnt.service b/tnt.service index 2c74027..84be372 100644 --- a/tnt.service +++ b/tnt.service @@ -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 diff --git a/tntctl.1 b/tntctl.1 index b33e8ea..fd93b22 100644 --- a/tntctl.1 +++ b/tntctl.1 @@ -79,6 +79,9 @@ Export persisted messages. .B dump -n N Export persisted messages. .TP +.B dump --all +Export all persisted messages. +.TP .B post MESSAGE Post a message non-interactively. .TP