From 1c451b77227b8d170931ac8e5efcf04b72aae07b Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 10:26:50 +0800 Subject: [PATCH] Add offline message log recovery modes --- Makefile | 3 +- README.md | 11 +++ docs/CHANGELOG.md | 6 ++ docs/DEPLOYMENT.md | 10 +++ docs/Development-Guide.md | 2 + docs/MESSAGE_LOG.md | 24 ++++++ docs/QUICKREF.md | 4 + docs/ROADMAP.md | 2 +- include/cli_text.h | 1 + include/message_log_tool.h | 9 +++ src/cli_text.c | 11 +++ src/main.c | 29 +++++++ src/message_log_tool.c | 111 +++++++++++++++++++++++++++ tests/test_message_log_tool.sh | 134 +++++++++++++++++++++++++++++++++ tests/unit/test_cli_text.c | 7 ++ tnt.1 | 20 +++++ 16 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 include/message_log_tool.h create mode 100644 src/message_log_tool.c create mode 100755 tests/test_message_log_tool.sh diff --git a/Makefile b/Makefile index 7d7e512..c5281c9 100644 --- a/Makefile +++ b/Makefile @@ -120,9 +120,10 @@ unit-test: @echo "Running unit tests..." @$(MAKE) -C tests/unit run -script-test: +script-test: all @echo "Running script tests..." @cd tests && ./test_logrotate.sh + @cd tests && ./test_message_log_tool.sh integration-test: all @echo "Running integration tests..." diff --git a/README.md b/README.md index 4e88aa9..202fe2f 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,17 @@ The script archives the full log, keeps the last `KEEP_LINES` records in the active file, compresses the archive when `gzip` is available, and can be previewed with `--dry-run`. +Installed binaries also include offline checks for the v1 log format: + +```sh +tnt --log-check /var/lib/tnt/messages.log +tnt --log-recover /var/lib/tnt/messages.log > messages.recovered.log +``` + +`--log-check` prints record counts and exits non-zero when invalid records are +found. `--log-recover` writes valid records to stdout and reports skipped +records to stderr; it never edits the source log in place. + ## Development ### Building diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 61d6975..8f8f4b0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,9 @@ exporting valid persisted `messages.log` v1 records. - Added regression-tested manual log archive and compaction coverage for `scripts/logrotate.sh`. +- Added offline `tnt --log-check` and `tnt --log-recover` modes for auditing + and recovering valid `messages.log` v1 records without editing the source + log in place. - Added a public security policy, supported-version guidance, and GitHub issue templates for bug reports and feature requests. - Added `tntctl`, a thin local wrapper around the documented SSH exec @@ -65,6 +68,9 @@ test in the normal test suite. - `messages.log` v1 record parsing and formatting now live in a dedicated `message_log` module instead of being embedded in `message.c`. +- Offline message-log recovery shares the same `message_log` parser used by + replay, search, and `dump`, so recovery behavior follows the documented v1 + contract. - The two-user lifecycle test now covers opening `:inbox` before a private message arrives, matching the way users often leave an inbox page open. - Private-message inbox access now uses its own mutex instead of sharing the diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e8cb8f9..7fa5bb6 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -125,6 +125,16 @@ the archive when `gzip` is available, and keeps the newest five archives by default. Use `--dry-run` to preview actions, or `--keep-archives N` to change archive retention. +Before replacing a suspicious log, inspect and recover it offline: + +```bash +tnt --log-check /var/lib/tnt/messages.log +tnt --log-recover /var/lib/tnt/messages.log > /var/lib/tnt/messages.recovered.log +``` + +`--log-recover` writes valid records to stdout and reports skipped records to +stderr. Review the recovered file before replacing the active log. + ## Firewall ```bash diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index 51003e4..251f9aa 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -82,6 +82,7 @@ src/ ├── chat_room.c - Chat room state, message ring, and update sequence ├── message.c - Message persistence (RFC3339 format) ├── message_log.c - messages.log v1 parsing and formatting +├── message_log_tool.c - Offline messages.log check/recover CLI ├── history_view.c - NORMAL-mode scroll window rules ├── tui.c - Terminal UI rendering (ANSI escape codes) ├── tui_status.c - Mode/status/input-line rendering @@ -105,6 +106,7 @@ include/ ├── chat_room.h - Chat room interface ├── message.h - Message structure and persistence ├── message_log.h - messages.log v1 parser/formatter interface +├── message_log_tool.h - Offline log check/recover interface ├── command_catalog.h - COMMAND-mode command metadata interface ├── history_view.h - Scroll-state helpers ├── tui.h - TUI rendering functions diff --git a/docs/MESSAGE_LOG.md b/docs/MESSAGE_LOG.md index 1bbaff4..9d5f245 100644 --- a/docs/MESSAGE_LOG.md +++ b/docs/MESSAGE_LOG.md @@ -75,6 +75,30 @@ the active file to the last `KEEP_LINES` records, compresses the archive when Run it while TNT is stopped or during a quiet maintenance window if strict log consistency matters. +## Recovery + +Installed `tnt` binaries provide offline log checking and recovery: + +```sh +tnt --log-check LOG_FILE +tnt --log-recover LOG_FILE > recovered.messages.log +``` + +`--log-check` prints a summary: + +```text +path /var/lib/tnt/messages.log +records_seen 120 +valid_records 119 +invalid_records 1 +first_invalid_line 120 +``` + +It exits `0` when every record is valid and `1` when invalid records are found +or the log cannot be read. `--log-recover` writes only valid v1 records to +stdout, prints the same summary to stderr, and also exits `1` if records were +skipped. It never modifies the source log. + ## Compatibility The v1 record format is stable for TNT 1.x. Future incompatible storage diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 1dfbbba..ca3f015 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -59,6 +59,9 @@ MAINTENANCE archive and compact messages.log scripts/logrotate.sh --dry-run ... preview log maintenance actions + tnt --log-check LOG_FILE audit messages.log v1 records + tnt --log-recover LOG_FILE > OUT + write valid records to stdout STRUCTURE src/main.c entry, signals @@ -72,6 +75,7 @@ STRUCTURE src/exec.c SSH exec command dispatch src/message.c persistence, search src/message_log.c messages.log v1 parsing and formatting + src/message_log_tool.c offline messages.log check/recover CLI src/history_view.c message viewport / scroll state src/help_text.c full-screen key reference text src/manual.c concise manual panel rendering diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 923e194..ad07aa3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -59,7 +59,7 @@ Goal: make stored history durable, inspectable, and recoverable. - ✅ validate persisted UTF-8 and record structure before replay/search - ✅ provide an inspection/export command for persisted records - ✅ add log rotation and compaction tooling -- define broader recovery tooling for truncated or partially corrupted logs +- ✅ define broader recovery tooling for truncated or partially corrupted logs ## Stage 4: Interactive UX diff --git a/include/cli_text.h b/include/cli_text.h index bbf0edc..24a0a2b 100644 --- a/include/cli_text.h +++ b/include/cli_text.h @@ -7,6 +7,7 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, const char *program_name, ui_lang_t lang); const char *cli_text_invalid_port_format(ui_lang_t lang); const char *cli_text_invalid_value_format(ui_lang_t lang); +const char *cli_text_option_requires_arg_format(ui_lang_t lang); const char *cli_text_unknown_option_format(ui_lang_t lang); const char *cli_text_short_usage_format(ui_lang_t lang); diff --git a/include/message_log_tool.h b/include/message_log_tool.h new file mode 100644 index 0000000..f76c1a4 --- /dev/null +++ b/include/message_log_tool.h @@ -0,0 +1,9 @@ +#ifndef MESSAGE_LOG_TOOL_H +#define MESSAGE_LOG_TOOL_H + +#include "common.h" + +int message_log_tool_check(const char *path); +int message_log_tool_recover(const char *path); + +#endif /* MESSAGE_LOG_TOOL_H */ diff --git a/src/cli_text.c b/src/cli_text.c index 6b3f5b1..87f1e3a 100644 --- a/src/cli_text.c +++ b/src/cli_text.c @@ -18,6 +18,8 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, " --rate-limit 0|1 Disable/enable rate-based blocking\n" " --idle-timeout SECONDS Idle disconnect timeout\n" " --ssh-log-level LEVEL libssh log level 0..4\n" + " --log-check FILE Check messages.log v1 records\n" + " --log-recover FILE Write valid records to stdout\n" " -V, --version Show version\n" " -h, --help Show this help\n" "\n" @@ -42,6 +44,8 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, " --rate-limit 0|1 禁用/启用速率封禁\n" " --idle-timeout SECONDS 空闲断开时间\n" " --ssh-log-level LEVEL libssh 日志级别 0..4\n" + " --log-check FILE 检查 messages.log v1 记录\n" + " --log-recover FILE 将有效记录写入 stdout\n" " -V, --version 显示版本\n" " -h, --help 显示此帮助\n" "\n" @@ -74,6 +78,13 @@ const char *cli_text_invalid_value_format(ui_lang_t lang) { return i18n_string(text, lang); } +const char *cli_text_option_requires_arg_format(ui_lang_t lang) { + static const i18n_string_t text = + I18N_STRING("Option requires argument: %s\n", + "选项需要参数: %s\n"); + return i18n_string(text, lang); +} + const char *cli_text_unknown_option_format(ui_lang_t lang) { static const i18n_string_t text = I18N_STRING("Unknown option: %s\n", "未知选项: %s\n"); diff --git a/src/main.c b/src/main.c index 036df2c..81ea22f 100644 --- a/src/main.c +++ b/src/main.c @@ -3,6 +3,7 @@ #include "common.h" #include "i18n.h" #include "message.h" +#include "message_log_tool.h" #include "ssh_server.h" #include #include @@ -77,6 +78,8 @@ static int set_numeric_env_option(const char *env_name, const char *opt_name, int main(int argc, char **argv) { int port = DEFAULT_PORT; ui_lang_t lang = i18n_default_ui_lang(); + const char *log_check_path = NULL; + const char *log_recover_path = NULL; /* Environment provides defaults; command-line flags override it. */ const char *port_env = getenv("PORT"); @@ -179,6 +182,20 @@ int main(int argc, char **argv) { return rc; } i++; + } else if (strcmp(argv[i], "--log-check") == 0) { + if (i + 1 >= argc || argv[i + 1][0] == '\0') { + fprintf(stderr, cli_text_option_requires_arg_format(lang), + argv[i]); + return TNT_EXIT_USAGE; + } + log_check_path = argv[++i]; + } else if (strcmp(argv[i], "--log-recover") == 0) { + if (i + 1 >= argc || argv[i + 1][0] == '\0') { + fprintf(stderr, cli_text_option_requires_arg_format(lang), + argv[i]); + return TNT_EXIT_USAGE; + } + log_recover_path = argv[++i]; } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) { printf("tnt %s\n", TNT_VERSION); return TNT_EXIT_OK; @@ -196,6 +213,18 @@ int main(int argc, char **argv) { } } + if (log_check_path && log_recover_path) { + fprintf(stderr, cli_text_invalid_value_format(lang), + "--log-check", "--log-recover"); + return TNT_EXIT_USAGE; + } + if (log_check_path) { + return message_log_tool_check(log_check_path); + } + if (log_recover_path) { + return message_log_tool_recover(log_recover_path); + } + /* Setup signal handlers */ signal(SIGINT, signal_handler); signal(SIGTERM, signal_handler); diff --git a/src/message_log_tool.c b/src/message_log_tool.c new file mode 100644 index 0000000..79fad96 --- /dev/null +++ b/src/message_log_tool.c @@ -0,0 +1,111 @@ +#include "message_log_tool.h" + +#include "message_log.h" + +#include + +typedef struct { + long records_seen; + long valid_records; + long invalid_records; + long first_invalid_line; +} message_log_report_t; + +static void discard_line_remainder(FILE *fp) { + int c; + + while ((c = fgetc(fp)) != '\n' && c != EOF) { + } +} + +static int print_recovered_record(const message_t *msg) { + char record[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48]; + size_t record_len = 0; + + if (message_log_format_record(msg, record, sizeof(record), + &record_len) < 0) { + return -1; + } + return fwrite(record, 1, record_len, stdout) == record_len ? 0 : -1; +} + +static void print_report(FILE *stream, const char *path, + const message_log_report_t *report) { + fprintf(stream, + "path %s\n" + "records_seen %ld\n" + "valid_records %ld\n" + "invalid_records %ld\n" + "first_invalid_line %ld\n", + path, + report->records_seen, + report->valid_records, + report->invalid_records, + report->first_invalid_line); +} + +static int scan_log(const char *path, bool recover) { + FILE *fp; + char line[MESSAGE_LOG_MAX_LINE]; + long line_no = 0; + time_t now = time(NULL); + message_log_report_t report = {0}; + + if (!path || path[0] == '\0') { + fprintf(stderr, "log: invalid path\n"); + return TNT_EXIT_USAGE; + } + + fp = fopen(path, "r"); + if (!fp) { + fprintf(stderr, "log: %s: %s\n", path, strerror(errno)); + return TNT_EXIT_ERROR; + } + + while (fgets(line, sizeof(line), fp)) { + size_t line_len = strlen(line); + message_t parsed; + bool valid = false; + + line_no++; + report.records_seen++; + + if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') { + discard_line_remainder(fp); + } else { + valid = message_log_parse_record(line, &parsed, now); + } + + if (valid) { + report.valid_records++; + if (recover && print_recovered_record(&parsed) < 0) { + fclose(fp); + fprintf(stderr, "log: failed to write recovered output\n"); + return TNT_EXIT_ERROR; + } + } else { + report.invalid_records++; + if (report.first_invalid_line == 0) { + report.first_invalid_line = line_no; + } + } + } + + if (ferror(fp)) { + fclose(fp); + fprintf(stderr, "log: failed to read %s\n", path); + return TNT_EXIT_ERROR; + } + fclose(fp); + + print_report(recover ? stderr : stdout, path, &report); + return report.invalid_records == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR; +} + +int message_log_tool_check(const char *path) { + return scan_log(path, false); +} + +int message_log_tool_recover(const char *path) { + return scan_log(path, true); +} diff --git a/tests/test_message_log_tool.sh b/tests/test_message_log_tool.sh new file mode 100755 index 0000000..f60b741 --- /dev/null +++ b/tests/test_message_log_tool.sh @@ -0,0 +1,134 @@ +#!/bin/sh +# Offline messages.log check/recover regression tests. + +set -u + +PASS=0 +FAIL=0 +BIN="../tnt" +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-log-tool-test.XXXXXX") + +cleanup() { + rm -rf "$STATE_DIR" +} +trap cleanup EXIT + +pass() { + echo "✓ $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "✗ $1" + FAIL=$((FAIL + 1)) +} + +ts_now() { + date -u +%Y-%m-%dT%H:%M:%SZ +} + +echo "=== TNT Message Log Tool Tests ===" + +if [ ! -x "$BIN" ]; then + echo "Error: binary $BIN not found. Run make first." + exit 1 +fi + +TS=$(ts_now) +CLEAN_LOG="$STATE_DIR/clean.log" +cat > "$CLEAN_LOG" <&1) +CHECK_STATUS=$? +printf '%s\n' "$CHECK_OUTPUT" | grep -q '^valid_records 2$' && +printf '%s\n' "$CHECK_OUTPUT" | grep -q '^invalid_records 0$' +if [ "$CHECK_STATUS" -eq 0 ] && [ $? -eq 0 ]; then + pass "clean log check exits 0" +else + fail "clean log check" + printf '%s\n' "$CHECK_OUTPUT" + echo "exit status: $CHECK_STATUS" +fi + +BAD_LOG="$STATE_DIR/bad.log" +cat > "$BAD_LOG" <> "$BAD_LOG" + +BAD_CHECK_OUTPUT=$("$BIN" --log-check "$BAD_LOG" 2>&1) +BAD_CHECK_STATUS=$? +printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^records_seen 4$' && +printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^valid_records 2$' && +printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^invalid_records 2$' && +printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^first_invalid_line 2$' +if [ "$BAD_CHECK_STATUS" -eq 1 ] && [ $? -eq 0 ]; then + pass "bad log check reports skipped records" +else + fail "bad log check" + printf '%s\n' "$BAD_CHECK_OUTPUT" + echo "exit status: $BAD_CHECK_STATUS" +fi + +RECOVERED="$STATE_DIR/recovered.log" +RECOVER_REPORT="$STATE_DIR/recover.report" +"$BIN" --log-recover "$BAD_LOG" > "$RECOVERED" 2> "$RECOVER_REPORT" +RECOVER_STATUS=$? +if [ "$RECOVER_STATUS" -eq 1 ] && + grep -q '^valid_records 2$' "$RECOVER_REPORT" && + grep -q '^invalid_records 2$' "$RECOVER_REPORT" && + grep -q "$TS|alice|one" "$RECOVERED" && + grep -q "$TS|bob|two" "$RECOVERED" && + ! grep -q 'mallory' "$RECOVERED" && + ! grep -q 'partial' "$RECOVERED"; then + pass "recover writes valid records and reports skipped records" +else + fail "bad log recovery" + cat "$RECOVERED" 2>/dev/null + cat "$RECOVER_REPORT" 2>/dev/null + echo "exit status: $RECOVER_STATUS" +fi + +MISSING_OUTPUT=$("$BIN" --log-check "$STATE_DIR/missing.log" 2>&1) +MISSING_STATUS=$? +if [ "$MISSING_STATUS" -eq 1 ] && + printf '%s\n' "$MISSING_OUTPUT" | grep -q 'No such file'; then + pass "missing log exits 1" +else + fail "missing log handling" + printf '%s\n' "$MISSING_OUTPUT" + echo "exit status: $MISSING_STATUS" +fi + +USAGE_OUTPUT=$("$BIN" --log-check 2>&1) +USAGE_STATUS=$? +if [ "$USAGE_STATUS" -eq 64 ] && + printf '%s\n' "$USAGE_OUTPUT" | grep -q 'Option requires argument: --log-check'; then + pass "missing log-check argument exits 64" +else + fail "missing log-check argument" + printf '%s\n' "$USAGE_OUTPUT" + echo "exit status: $USAGE_STATUS" +fi + +CONFLICT_OUTPUT=$("$BIN" --log-check "$CLEAN_LOG" --log-recover "$CLEAN_LOG" 2>&1) +CONFLICT_STATUS=$? +if [ "$CONFLICT_STATUS" -eq 64 ] && + printf '%s\n' "$CONFLICT_OUTPUT" | grep -q 'Invalid --log-check: --log-recover'; then + pass "conflicting log modes exit 64" +else + fail "conflicting log modes" + printf '%s\n' "$CONFLICT_OUTPUT" + echo "exit status: $CONFLICT_STATUS" +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/unit/test_cli_text.c b/tests/unit/test_cli_text.c index 66b5967..e5d12d4 100644 --- a/tests/unit/test_cli_text.c +++ b/tests/unit/test_cli_text.c @@ -24,6 +24,8 @@ TEST(help_matches_language) { assert(strstr(output, "Usage: tnt [options]") != NULL); assert(strstr(output, "--bind ADDR") != NULL); assert(strstr(output, "--max-connections N") != NULL); + assert(strstr(output, "--log-check FILE") != NULL); + assert(strstr(output, "--log-recover FILE") != NULL); assert(strstr(output, "TNT_LANG") != NULL); memset(output, 0, sizeof(output)); @@ -39,6 +41,7 @@ TEST(help_matches_language) { assert(strstr(output, "[选项]") == NULL); assert(strstr(output, "--public-host HOST") != NULL); assert(strstr(output, "--idle-timeout SECONDS") != NULL); + assert(strstr(output, "--log-check FILE") != NULL); assert(strstr(output, "TNT_LANG") != NULL); } @@ -51,6 +54,10 @@ TEST(error_formats_match_language) { "Invalid %s: %s\n") == 0); assert(strcmp(cli_text_invalid_value_format(UI_LANG_ZH), "%s 无效: %s\n") == 0); + assert(strcmp(cli_text_option_requires_arg_format(UI_LANG_EN), + "Option requires argument: %s\n") == 0); + assert(strcmp(cli_text_option_requires_arg_format(UI_LANG_ZH), + "选项需要参数: %s\n") == 0); assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN), "Unknown option: %s\n") == 0); assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH), diff --git a/tnt.1 b/tnt.1 index 3d8af09..3befbc9 100644 --- a/tnt.1 +++ b/tnt.1 @@ -26,6 +26,14 @@ tnt \- anonymous SSH chat server with Vim\-style TUI .IR level ] .RB [ \-V | \-\-version ] .RB [ \-h | \-\-help ] +.br +.B tnt +.B \-\-log\-check +.I file +.br +.B tnt +.B \-\-log\-recover +.I file .SH DESCRIPTION .B tnt is a multi\-user anonymous chat server accessed over SSH. @@ -117,6 +125,18 @@ Overrides the .B TNT_SSH_LOG_LEVEL environment variable. .TP +.BR \-\-log\-check " " \fIfile\fR +Check a +.I messages.log +v1 file and print record counts. +Exits non-zero when invalid records are found or the file cannot be read. +.TP +.BR \-\-log\-recover " " \fIfile\fR +Write valid +.I messages.log +v1 records to standard output and print a recovery summary to standard error. +The source file is not modified. +.TP .BR \-V ", " \-\-version Print version and exit. .TP