Add offline message log recovery modes

This commit is contained in:
m1ngsama 2026-05-27 10:26:50 +08:00
parent 3252e4583c
commit 1c451b7722
16 changed files with 382 additions and 2 deletions

View file

@ -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..."

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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);

View file

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

View file

@ -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");

View file

@ -3,6 +3,7 @@
#include "common.h"
#include "i18n.h"
#include "message.h"
#include "message_log_tool.h"
#include "ssh_server.h"
#include <signal.h>
#include <unistd.h>
@ -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);

111
src/message_log_tool.c Normal file
View file

@ -0,0 +1,111 @@
#include "message_log_tool.h"
#include "message_log.h"
#include <errno.h>
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);
}

134
tests/test_message_log_tool.sh Executable file
View file

@ -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" <<EOF
$TS|alice|one
$TS|bob|two
EOF
CHECK_OUTPUT=$("$BIN" --log-check "$CLEAN_LOG" 2>&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" <<EOF
$TS|alice|one
$TS|mallory|extra|pipe
$TS|bob|two
EOF
printf '%s|partial|unterminated' "$TS" >> "$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"

View file

@ -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),

20
tnt.1
View file

@ -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