Localize tntctl help and diagnostics

This commit is contained in:
m1ngsama 2026-05-28 09:04:24 +08:00
parent f0499c32f6
commit b23b1ba194
4 changed files with 172 additions and 35 deletions

View file

@ -25,7 +25,7 @@ OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d) DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d)
TARGET = tnt TARGET = tnt
CTL_TARGET = tntctl CTL_TARGET = tntctl
CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/i18n.o
TARGETS = $(TARGET) $(CTL_TARGET) TARGETS = $(TARGET) $(CTL_TARGET)
PREFIX ?= /usr/local PREFIX ?= /usr/local

View file

@ -107,6 +107,8 @@
- `tntctl` now reuses the SSH exec command matcher for local command - `tntctl` now reuses the SSH exec command matcher for local command
validation, so `tntctl host --help` reaches the server-side exec help alias validation, so `tntctl host --help` reaches the server-side exec help alias
instead of being rejected locally. instead of being rejected locally.
- `tntctl` local help and local validation errors now follow `TNT_LANG` and
locale selection, matching the server CLI's i18n behavior.
- Split UI-language parsing from localized text lookup: `src/i18n.c` now owns - Split UI-language parsing from localized text lookup: `src/i18n.c` now owns
locale/code parsing, while `src/i18n_text.c` owns the table-driven text locale/code parsing, while `src/i18n_text.c` owns the table-driven text
catalog with coverage checks for every message ID. catalog with coverage checks for every message ID.

View file

@ -1,5 +1,6 @@
#include "common.h" #include "common.h"
#include "exec_catalog.h" #include "exec_catalog.h"
#include "i18n.h"
#include <ctype.h> #include <ctype.h>
#include <errno.h> #include <errno.h>
@ -7,21 +8,126 @@
#include <sys/wait.h> #include <sys/wait.h>
#include <unistd.h> #include <unistd.h>
static void print_usage(FILE *stream) { typedef enum {
fprintf(stream, TNTCTL_TEXT_USAGE,
"Usage: tntctl [options] host command [args...]\n" TNTCTL_TEXT_INVALID_PORT,
"\n" TNTCTL_TEXT_INVALID_LOGIN,
"Options:\n" TNTCTL_TEXT_INVALID_HOST_KEY_MODE,
" -p, --port PORT SSH port (default: 2222)\n" TNTCTL_TEXT_INVALID_KNOWN_HOSTS,
" -l, --login USER SSH login name for exec identity\n" TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT,
" --host-key-checking MODE\n" TNTCTL_TEXT_MISSING_HOST,
" OpenSSH host-key mode: yes, accept-new, no\n" TNTCTL_TEXT_INVALID_HOST,
" --known-hosts FILE OpenSSH known_hosts file\n" TNTCTL_TEXT_LOGIN_HOST_CONFLICT,
" -V, --version Print version and exit\n" TNTCTL_TEXT_UNKNOWN_COMMAND,
" -h, --help Print this help and exit\n" TNTCTL_TEXT_INVALID_REMOTE_COMMAND,
"\n" TNTCTL_TEXT_DESTINATION_TOO_LONG,
"Commands mirror the TNT SSH exec interface: health, stats, users,\n" TNTCTL_TEXT_INVALID_DESTINATION,
"tail, dump, post, help, and exit.\n"); TNTCTL_TEXT_OUT_OF_MEMORY,
TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG,
TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG,
TNTCTL_TEXT_COUNT
} tntctl_text_id_t;
static const i18n_string_t tntctl_text_catalog[TNTCTL_TEXT_COUNT] = {
[TNTCTL_TEXT_USAGE] = I18N_STRING(
"Usage: tntctl [options] host command [args...]\n"
"\n"
"Options:\n"
" -p, --port PORT SSH port (default: 2222)\n"
" -l, --login USER SSH login name for exec identity\n"
" --host-key-checking MODE\n"
" OpenSSH host-key mode: yes, accept-new, no\n"
" --known-hosts FILE OpenSSH known_hosts file\n"
" -V, --version Print version and exit\n"
" -h, --help Print this help and exit\n"
"\n"
"Commands mirror the TNT SSH exec interface: health, stats, users,\n"
"tail, dump, post, help, and exit.\n",
"用法: tntctl [options] host command [args...]\n"
"\n"
"选项:\n"
" -p, --port PORT SSH 端口 (默认: 2222)\n"
" -l, --login USER SSH 登录名,用作 exec 身份\n"
" --host-key-checking MODE\n"
" OpenSSH 主机密钥模式: yes, accept-new, no\n"
" --known-hosts FILE OpenSSH known_hosts 文件\n"
" -V, --version 输出版本并退出\n"
" -h, --help 输出此帮助并退出\n"
"\n"
"命令对应 TNT SSH exec 接口: health, stats, users,\n"
"tail, dump, post, help 和 exit.\n"
),
[TNTCTL_TEXT_INVALID_PORT] = I18N_STRING(
"invalid port", "端口无效"
),
[TNTCTL_TEXT_INVALID_LOGIN] = I18N_STRING(
"invalid login", "登录名无效"
),
[TNTCTL_TEXT_INVALID_HOST_KEY_MODE] = I18N_STRING(
"invalid host-key checking mode", "主机密钥检查模式无效"
),
[TNTCTL_TEXT_INVALID_KNOWN_HOSTS] = I18N_STRING(
"invalid known_hosts path", "known_hosts 路径无效"
),
[TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT] = I18N_STRING(
"unknown option: %s", "未知选项: %s"
),
[TNTCTL_TEXT_MISSING_HOST] = I18N_STRING(
"missing host", "缺少 host"
),
[TNTCTL_TEXT_INVALID_HOST] = I18N_STRING(
"invalid host", "host 无效"
),
[TNTCTL_TEXT_LOGIN_HOST_CONFLICT] = I18N_STRING(
"use either --login or user@host, not both",
"只能使用 --login 或 user@host 之一"
),
[TNTCTL_TEXT_UNKNOWN_COMMAND] = I18N_STRING(
"unknown or missing command", "未知命令或缺少命令"
),
[TNTCTL_TEXT_INVALID_REMOTE_COMMAND] = I18N_STRING(
"invalid or too-long command", "命令无效或过长"
),
[TNTCTL_TEXT_DESTINATION_TOO_LONG] = I18N_STRING(
"destination too long", "目标地址过长"
),
[TNTCTL_TEXT_INVALID_DESTINATION] = I18N_STRING(
"invalid destination", "目标地址无效"
),
[TNTCTL_TEXT_OUT_OF_MEMORY] = I18N_STRING(
"out of memory", "内存不足"
),
[TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG] = I18N_STRING(
"host-key option too long", "主机密钥选项过长"
),
[TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG] = I18N_STRING(
"known_hosts option too long", "known_hosts 选项过长"
)
};
typedef char tntctl_text_catalog_must_cover_enum[
sizeof(tntctl_text_catalog) / sizeof(tntctl_text_catalog[0]) ==
TNTCTL_TEXT_COUNT ? 1 : -1];
static const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id) {
if (id < 0 || id >= TNTCTL_TEXT_COUNT) {
return "";
}
return i18n_string(tntctl_text_catalog[id], lang);
}
static void print_usage(FILE *stream, ui_lang_t lang) {
fputs(tntctl_text(lang, TNTCTL_TEXT_USAGE), stream);
}
static void print_error(ui_lang_t lang, tntctl_text_id_t id) {
fprintf(stderr, "tntctl: %s\n", tntctl_text(lang, id));
}
static void print_error_format(ui_lang_t lang, tntctl_text_id_t id,
const char *value) {
fprintf(stderr, "tntctl: ");
fprintf(stderr, tntctl_text(lang, id), value);
fputc('\n', stderr);
} }
static bool is_valid_port(const char *value) { static bool is_valid_port(const char *value) {
@ -153,6 +259,7 @@ int main(int argc, char **argv) {
char **ssh_argv = NULL; char **ssh_argv = NULL;
int ssh_argc = 0; int ssh_argc = 0;
int rc; int rc;
ui_lang_t lang = i18n_default_ui_lang();
for (i = 1; i < argc; i++) { for (i = 1; i < argc; i++) {
if (strcmp(argv[i], "--") == 0) { if (strcmp(argv[i], "--") == 0) {
@ -160,7 +267,7 @@ int main(int argc, char **argv) {
break; break;
} else if (strcmp(argv[i], "-h") == 0 || } else if (strcmp(argv[i], "-h") == 0 ||
strcmp(argv[i], "--help") == 0) { strcmp(argv[i], "--help") == 0) {
print_usage(stdout); print_usage(stdout, lang);
return TNT_EXIT_OK; return TNT_EXIT_OK;
} else if (strcmp(argv[i], "-V") == 0 || } else if (strcmp(argv[i], "-V") == 0 ||
strcmp(argv[i], "--version") == 0) { strcmp(argv[i], "--version") == 0) {
@ -169,7 +276,7 @@ int main(int argc, char **argv) {
} else if (strcmp(argv[i], "-p") == 0 || } else if (strcmp(argv[i], "-p") == 0 ||
strcmp(argv[i], "--port") == 0) { strcmp(argv[i], "--port") == 0) {
if (i + 1 >= argc || !is_valid_port(argv[i + 1])) { if (i + 1 >= argc || !is_valid_port(argv[i + 1])) {
fprintf(stderr, "tntctl: invalid port\n"); print_error(lang, TNTCTL_TEXT_INVALID_PORT);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
port = argv[++i]; port = argv[++i];
@ -177,26 +284,27 @@ int main(int argc, char **argv) {
strcmp(argv[i], "--login") == 0) { strcmp(argv[i], "--login") == 0) {
if (i + 1 >= argc || is_safe_ssh_token(argv[i + 1]) || if (i + 1 >= argc || is_safe_ssh_token(argv[i + 1]) ||
strchr(argv[i + 1], '@')) { strchr(argv[i + 1], '@')) {
fprintf(stderr, "tntctl: invalid login\n"); print_error(lang, TNTCTL_TEXT_INVALID_LOGIN);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
login = argv[++i]; login = argv[++i];
} else if (strcmp(argv[i], "--host-key-checking") == 0) { } else if (strcmp(argv[i], "--host-key-checking") == 0) {
if (i + 1 >= argc || !is_host_key_checking_mode(argv[i + 1])) { if (i + 1 >= argc || !is_host_key_checking_mode(argv[i + 1])) {
fprintf(stderr, "tntctl: invalid host-key checking mode\n"); print_error(lang, TNTCTL_TEXT_INVALID_HOST_KEY_MODE);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
host_key_checking = argv[++i]; host_key_checking = argv[++i];
} else if (strcmp(argv[i], "--known-hosts") == 0) { } else if (strcmp(argv[i], "--known-hosts") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0' || if (i + 1 >= argc || argv[i + 1][0] == '\0' ||
has_newline(argv[i + 1])) { has_newline(argv[i + 1])) {
fprintf(stderr, "tntctl: invalid known_hosts path\n"); print_error(lang, TNTCTL_TEXT_INVALID_KNOWN_HOSTS);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
known_hosts = argv[++i]; known_hosts = argv[++i];
} else if (argv[i][0] == '-') { } else if (argv[i][0] == '-') {
fprintf(stderr, "tntctl: unknown option: %s\n", argv[i]); print_error_format(lang, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT,
print_usage(stderr); argv[i]);
print_usage(stderr, lang);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} else { } else {
break; break;
@ -204,29 +312,29 @@ int main(int argc, char **argv) {
} }
if (i >= argc) { if (i >= argc) {
fprintf(stderr, "tntctl: missing host\n"); print_error(lang, TNTCTL_TEXT_MISSING_HOST);
print_usage(stderr); print_usage(stderr, lang);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
host = argv[i++]; host = argv[i++];
if (is_safe_ssh_token(host)) { if (is_safe_ssh_token(host)) {
fprintf(stderr, "tntctl: invalid host\n"); print_error(lang, TNTCTL_TEXT_INVALID_HOST);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
if (login && strchr(host, '@')) { if (login && strchr(host, '@')) {
fprintf(stderr, "tntctl: use either --login or user@host, not both\n"); print_error(lang, TNTCTL_TEXT_LOGIN_HOST_CONFLICT);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
if (i >= argc || !is_known_exec_command(argv[i])) { if (i >= argc || !is_known_exec_command(argv[i])) {
fprintf(stderr, "tntctl: unknown or missing command\n"); print_error(lang, TNTCTL_TEXT_UNKNOWN_COMMAND);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
if (build_remote_command(remote_command, sizeof(remote_command), argc, if (build_remote_command(remote_command, sizeof(remote_command), argc,
argv, i) < 0) { argv, i) < 0) {
fprintf(stderr, "tntctl: invalid or too-long command\n"); print_error(lang, TNTCTL_TEXT_INVALID_REMOTE_COMMAND);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
@ -234,24 +342,24 @@ int main(int argc, char **argv) {
int n = snprintf(destination, sizeof(destination), "%s@%s", login, int n = snprintf(destination, sizeof(destination), "%s@%s", login,
host); host);
if (n < 0 || n >= (int)sizeof(destination)) { if (n < 0 || n >= (int)sizeof(destination)) {
fprintf(stderr, "tntctl: destination too long\n"); print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
} else { } else {
int n = snprintf(destination, sizeof(destination), "%s", host); int n = snprintf(destination, sizeof(destination), "%s", host);
if (n < 0 || n >= (int)sizeof(destination)) { if (n < 0 || n >= (int)sizeof(destination)) {
fprintf(stderr, "tntctl: destination too long\n"); print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
} }
if (destination[0] == '-') { if (destination[0] == '-') {
fprintf(stderr, "tntctl: invalid destination\n"); print_error(lang, TNTCTL_TEXT_INVALID_DESTINATION);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
ssh_argv = calloc((size_t)argc * 2u + 8u, sizeof(*ssh_argv)); ssh_argv = calloc((size_t)argc * 2u + 8u, sizeof(*ssh_argv));
if (!ssh_argv) { if (!ssh_argv) {
fprintf(stderr, "tntctl: out of memory\n"); print_error(lang, TNTCTL_TEXT_OUT_OF_MEMORY);
return TNT_EXIT_ERROR; return TNT_EXIT_ERROR;
} }
@ -262,7 +370,7 @@ int main(int argc, char **argv) {
int n = snprintf(host_key_option, sizeof(host_key_option), int n = snprintf(host_key_option, sizeof(host_key_option),
"StrictHostKeyChecking=%s", host_key_checking); "StrictHostKeyChecking=%s", host_key_checking);
if (n < 0 || n >= (int)sizeof(host_key_option)) { if (n < 0 || n >= (int)sizeof(host_key_option)) {
fprintf(stderr, "tntctl: host-key option too long\n"); print_error(lang, TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG);
free(ssh_argv); free(ssh_argv);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
@ -273,7 +381,7 @@ int main(int argc, char **argv) {
int n = snprintf(known_hosts_option, sizeof(known_hosts_option), int n = snprintf(known_hosts_option, sizeof(known_hosts_option),
"UserKnownHostsFile=%s", known_hosts); "UserKnownHostsFile=%s", known_hosts);
if (n < 0 || n >= (int)sizeof(known_hosts_option)) { if (n < 0 || n >= (int)sizeof(known_hosts_option)) {
fprintf(stderr, "tntctl: known_hosts option too long\n"); print_error(lang, TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG);
free(ssh_argv); free(ssh_argv);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }

View file

@ -73,6 +73,33 @@ case "$VERSION_OUTPUT" in
*) echo "✗ version output unexpected: $VERSION_OUTPUT"; FAIL=$((FAIL + 1)) ;; *) echo "✗ version output unexpected: $VERSION_OUTPUT"; FAIL=$((FAIL + 1)) ;;
esac esac
HELP_ZH=$(TNT_LANG=zh "$BIN" --help 2>/dev/null || true)
printf '%s\n' "$HELP_ZH" | grep -q '^用法: tntctl \[options\] host command \[args...\]' &&
printf '%s\n' "$HELP_ZH" | grep -q '^选项:$'
if [ $? -eq 0 ]; then
echo "✓ local help follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ localized help output unexpected"
printf '%s\n' "$HELP_ZH"
FAIL=$((FAIL + 1))
fi
rm -f "$SSH_LOG"
BAD_PORT_ZH=$(PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" TNT_LANG=zh "$BIN" -p nope example.com health 2>&1)
BAD_PORT_STATUS=$?
if [ "$BAD_PORT_STATUS" -eq 64 ] &&
[ ! -f "$SSH_LOG" ] &&
printf '%s\n' "$BAD_PORT_ZH" | grep -q '^tntctl: 端口无效$'; then
echo "✓ local diagnostics follow TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ localized diagnostic unexpected"
printf '%s\n' "$BAD_PORT_ZH"
[ -f "$SSH_LOG" ] && echo "fake ssh was invoked"
FAIL=$((FAIL + 1))
fi
run_ok "basic argv shape" "$BIN" -p 2222 example.com health run_ok "basic argv shape" "$BIN" -p 2222 example.com health
grep -q '^example.com$' "$SSH_LOG" && grep -q '^example.com$' "$SSH_LOG" &&
grep -q '^health$' "$SSH_LOG" grep -q '^health$' "$SSH_LOG"