diff --git a/.gitignore b/.gitignore index 50a70a8..9234561 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ tests/unit/test_richtext tests/unit/test_editor tests/unit/test_keymap tests/unit/test_message_log +tests/unit/test_identity perf-results/ __pycache__/ *.pyc diff --git a/MAINTAINERS b/MAINTAINERS index 456c204..475639c 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -90,6 +90,8 @@ F: tests/test_gateway_e2e.sh PACKAGING AND RELEASE S: Maintained +F: flake.lock +F: flake.nix F: packaging/** F: scripts/healthcheck.sh F: scripts/package_*.sh diff --git a/include/chat_room.h b/include/chat_room.h index 79c12e4..9ba947b 100644 --- a/include/chat_room.h +++ b/include/chat_room.h @@ -12,6 +12,9 @@ struct client; * lifetime, so it must never block or call back into chat_room APIs. */ typedef void (*room_client_notifier_fn)(struct client *client); typedef const char *(*room_client_name_fn)(const struct client *client); +/* True when the client holds its name as a signed-in handle, which several + * sessions may share. */ +typedef bool (*room_client_owns_name_fn)(const struct client *client); /* Runs with the room lock held, so it must not call back into chat_room. */ typedef void (*room_presence_fn)(const char *nickname, bool joined); @@ -34,6 +37,7 @@ typedef struct { uint64_t update_seq; room_client_notifier_fn client_notifier; room_client_name_fn client_name; + room_client_owns_name_fn client_owns_name; room_presence_fn presence_notifier; } chat_room_t; @@ -47,7 +51,8 @@ chat_room_t* room_create(void); void room_destroy(chat_room_t *room); /* Add client to room atomically. Returns 0 on success, -1 when full, and -2 - * when another connected client already owns the same display name. */ + * when another connected client already owns the same display name, unless + * the client owns its name. */ int room_add_client(chat_room_t *room, struct client *client); /* Remove client from room */ @@ -65,6 +70,10 @@ void room_set_client_notifier(chat_room_t *room, void room_set_client_name_accessor(chat_room_t *room, room_client_name_fn accessor); +/* Install the accessor that exempts a client from the duplicate-name check. */ +void room_set_client_name_owner(chat_room_t *room, + room_client_owns_name_fn accessor); + /* Install the presence hook called when a client joins or leaves. */ void room_set_presence_notifier(chat_room_t *room, room_presence_fn notifier); diff --git a/include/command_catalog.h b/include/command_catalog.h index 3ccca48..54690da 100644 --- a/include/command_catalog.h +++ b/include/command_catalog.h @@ -11,6 +11,8 @@ typedef enum { TNT_COMMAND_REPLY, TNT_COMMAND_INBOX, TNT_COMMAND_NICK, + TNT_COMMAND_LOGIN, + TNT_COMMAND_LOGOUT, TNT_COMMAND_LAST, TNT_COMMAND_SEARCH, TNT_COMMAND_MUTE_JOINS, diff --git a/include/commands.h b/include/commands.h index 8d60eca..2851644 100644 --- a/include/commands.h +++ b/include/commands.h @@ -19,6 +19,15 @@ * path; callers must not hold client->io_lock before dispatching. */ void commands_dispatch(client_t *client); +/* Rename the session and announce it to the room. A verified rename takes a + * signed-in handle and skips the duplicate-name check; an unverified one + * clears the session's verified state. Returns 0 when renamed, 1 when the + * name is unchanged, and -1 when another session holds the name. */ +int commands_rename(client_t *client, const char *new_name, bool verified); + +/* Rename to "anonymous", or "anonymousN" when that is taken. */ +void commands_become_anonymous(client_t *client); + /* Rebuild the currently visible command output when it is backed by live * client state, such as :inbox. Returns true if output changed and the caller * should render it again. */ diff --git a/include/common.h b/include/common.h index fbae1ab..b51f0ec 100644 --- a/include/common.h +++ b/include/common.h @@ -14,7 +14,7 @@ #include "config_defaults.h" /* Project Metadata */ -#define TNT_VERSION "1.4.0" +#define TNT_VERSION "1.5.0" /* Public process/exec exit statuses. TNT follows the common sysexits(3) * convention for usage errors while keeping runtime failures portable. */ diff --git a/include/i18n.h b/include/i18n.h index ff1d578..2ccb1c9 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -60,6 +60,15 @@ typedef enum { I18N_NICK_TAKEN_FORMAT, I18N_NICK_UNCHANGED, I18N_NICK_CHANGED_FORMAT, + I18N_NICK_RESERVED_FORMAT, + I18N_NICK_SIGNED_IN_FORMAT, + I18N_LOGIN_UNAVAILABLE, + I18N_LOGIN_OPENING, + I18N_LOGIN_ALREADY_FORMAT, + I18N_SIGNED_IN_FORMAT, + I18N_SIGNED_IN_KEY_HINT_FORMAT, + I18N_LOGOUT_NOT_SIGNED_IN, + I18N_LOGOUT_DONE_FORMAT, I18N_LAST_HEADER_FORMAT, I18N_LAST_EMPTY, I18N_SEARCH_HEADER_FORMAT, @@ -82,6 +91,7 @@ typedef enum { I18N_EXEC_POST_ASCII_CONTROL, I18N_EXEC_POST_TOO_LONG, I18N_EXEC_POST_PERSIST_FAILED, + I18N_EXEC_POST_RESERVED_FORMAT, I18N_EXEC_COMMAND_TOO_LONG, I18N_EXEC_UNKNOWN_COMMAND_FORMAT, I18N_TEXT_COUNT diff --git a/include/identity.h b/include/identity.h new file mode 100644 index 0000000..bf0fdf5 --- /dev/null +++ b/include/identity.h @@ -0,0 +1,62 @@ +#ifndef IDENTITY_H +#define IDENTITY_H + +#include "common.h" +#include + +#define TNT_IDENTITY_FILE "identities" +#define TNT_IDENTITY_HANDLE_MAX 20 +/* "SHA256:", 43 unpadded base64 characters, and the NUL byte. */ +#define TNT_IDENTITY_FINGERPRINT_SIZE 51 +#define TNT_IDENTITY_MAX_KEYS 16 + +typedef struct { + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + int key_count; + char keys[TNT_IDENTITY_MAX_KEYS][TNT_IDENTITY_FINGERPRINT_SIZE]; +} identity_entry_t; + +/* The identities file: one handle per line followed by zero or more SSH key + * fingerprints, all separated by spaces. */ +typedef struct { + identity_entry_t *entries; + size_t count; +} identity_table_t; + +bool identity_handle_valid(const char *handle); +bool identity_fingerprint_valid(const char *fingerprint); + +/* Format `key` the way `ssh-keygen -l` does: "SHA256:" and unpadded base64. */ +int identity_fingerprint_from_key(ssh_key key, char *out, size_t out_size); + +/* A missing file loads as an empty table. Invalid lines and fingerprints are + * skipped. */ +int identity_table_load(identity_table_t *table, const char *path); + +/* Replace `path` atomically with a mode 0600 file. */ +int identity_table_save(const identity_table_t *table, const char *path); + +void identity_table_free(identity_table_t *table); + +/* Match `name` against handles without regard to ASCII letter case. */ +const identity_entry_t *identity_table_find(const identity_table_t *table, + const char *name); +const char *identity_table_handle_for_key(const identity_table_t *table, + const char *fingerprint); + +/* Add `handle` if absent. A non-NULL `fingerprint` moves to this handle from + * any other one; past TNT_IDENTITY_MAX_KEYS the oldest key is dropped. */ +int identity_table_add(identity_table_t *table, const char *handle, + const char *fingerprint); +bool identity_table_forget_key(identity_table_t *table, + const char *fingerprint); + +/* The process-wide identities file in the state directory. Each call + * reloads the file when it changed on disk. */ +bool identity_is_reserved(const char *name); +bool identity_handle_for_key(const char *fingerprint, char *handle, + size_t handle_size); +int identity_reserve(const char *handle, const char *fingerprint); +int identity_forget_key(const char *fingerprint); + +#endif /* IDENTITY_H */ diff --git a/include/input.h b/include/input.h index 4ee8e6c..19fc986 100644 --- a/include/input.h +++ b/include/input.h @@ -28,4 +28,9 @@ void input_run_session(client_t *client); * inside input_run_session and by exec_command_post. */ void notify_mentions(const char *content, const client_t *sender); +/* Module-facing identity hooks, called from the identity module's worker. + * Each returns false when no interactive session has that id. */ +bool input_identity_notice(const char *session, const char *plain_text); +bool input_identity_verified(const char *session, const char *handle); + #endif /* INPUT_H */ diff --git a/include/module_protocol.h b/include/module_protocol.h index ca8e181..a5e9442 100644 --- a/include/module_protocol.h +++ b/include/module_protocol.h @@ -1,6 +1,7 @@ #ifndef MODULE_PROTOCOL_H #define MODULE_PROTOCOL_H +#include "identity.h" #include "message.h" #define TNT_MODULE_PROTOCOL_VERSION "tnt.module.v1" @@ -10,6 +11,9 @@ #define TNT_MODULE_EVENT_PRESENCE_JOINED "presence.joined" #define TNT_MODULE_EVENT_PRESENCE_LEFT "presence.left" #define TNT_MODULE_EVENT_PRESENCE_SNAPSHOT "presence.snapshot" +#define TNT_MODULE_EVENT_IDENTITY_REQUESTED "identity.requested" +#define TNT_MODULE_RECORD_IDENTITY_PREFIX "identity." +#define TNT_MODULE_SESSION_ID_SIZE 32 typedef struct { char plain_text[MAX_MESSAGE_LEN]; @@ -20,12 +24,25 @@ typedef struct { char plain_text[MAX_MESSAGE_LEN]; } tnt_module_message_post_t; +typedef enum { + TNT_MODULE_IDENTITY_NOTICE, + TNT_MODULE_IDENTITY_VERIFIED, + TNT_MODULE_IDENTITY_RESERVE +} tnt_module_identity_kind_t; + +typedef struct { + tnt_module_identity_kind_t kind; + char session[TNT_MODULE_SESSION_ID_SIZE]; + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + char plain_text[MAX_MESSAGE_LEN]; +} tnt_module_identity_record_t; + int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, const char *server_version); int tnt_module_append_message_created(char *buffer, size_t buf_size, size_t *pos, const char *message_id, - const message_t *msg); + const message_t *msg, bool verified); int tnt_module_append_presence(char *buffer, size_t buf_size, size_t *pos, const char *type, const char *nickname, @@ -36,10 +53,17 @@ int tnt_module_append_presence_snapshot(char *buffer, size_t buf_size, const char *const *nicknames, size_t count); +int tnt_module_append_identity_requested(char *buffer, size_t buf_size, + size_t *pos, const char *session, + const char *nickname); + bool tnt_module_parse_message_create(const char *line, tnt_module_message_create_t *out); bool tnt_module_parse_message_post(const char *line, tnt_module_message_post_t *out); +bool tnt_module_parse_identity_record(const char *line, + tnt_module_identity_record_t *out); + #endif /* MODULE_PROTOCOL_H */ diff --git a/include/module_runtime.h b/include/module_runtime.h index 2582caa..0ee62ec 100644 --- a/include/module_runtime.h +++ b/include/module_runtime.h @@ -17,6 +17,7 @@ typedef struct { bool can_create_messages; bool can_post_messages; bool can_read_presence; + bool can_write_identity; } tnt_module_manifest_t; int tnt_module_manifest_load(const char *module_dir, @@ -27,12 +28,18 @@ void tnt_module_runtime_shutdown(void); /* Queue a user/core-created public message for enabled modules. This is * intentionally fire-and-forget so basic chat never depends on module health. */ -void tnt_module_runtime_publish_message_created(const message_t *msg); +void tnt_module_runtime_publish_message_created(const message_t *msg, + bool verified); /* Track an interactive user joining or leaving and notify presence:read * modules. Safe to call with the room lock held. */ void tnt_module_runtime_publish_presence(const char *nickname, bool joined); +/* Ask the identity:write module to sign in an interactive session. Returns + * false when no such module can take the request. */ +bool tnt_module_runtime_request_identity(const char *session, + const char *nickname); + #ifdef TNT_TESTING /* Pipe-level test seams for deterministic backpressure/deadline coverage. */ int tnt_module_runtime_test_write_fd(int fd, const char *data, size_t len, diff --git a/include/ssh_server.h b/include/ssh_server.h index ec49e0e..c24d891 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -2,6 +2,7 @@ #define SSH_SERVER_H #include "common.h" +#include "identity.h" #include "keymap.h" #include "chat_room.h" #include @@ -25,6 +26,7 @@ typedef struct { typedef enum { TNT_COMMAND_OUTPUT_NONE, TNT_COMMAND_OUTPUT_GENERIC, + TNT_COMMAND_OUTPUT_NOTICE, TNT_COMMAND_OUTPUT_INBOX } tnt_command_output_kind_t; @@ -59,7 +61,10 @@ typedef struct client { bool show_motd; /* command_output holds MOTD text */ char exec_command[MAX_EXEC_COMMAND_LEN]; bool exec_command_too_long; + bool verified; /* Name is a signed-in handle; room lock */ char ssh_login[MAX_USERNAME_LEN]; + char session_id[32]; /* Opaque id an identity module sees */ + char key_fingerprint[TNT_IDENTITY_FINGERPRINT_SIZE]; /* Signed SSH key */ time_t connect_time; time_t last_active; atomic_bool redraw_pending; @@ -80,13 +85,19 @@ typedef struct client { whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; int whisper_inbox_count; bool mute_joins; + /* Identity mailbox filled by the module worker under whisper_lock and + * applied by the session's own thread, which alone renames it. */ + char identity_notice[MAX_COMMAND_OUTPUT_LEN / 2]; + char identity_handle[TNT_IDENTITY_HANDLE_MAX + 1]; + bool identity_evict; + atomic_bool identity_pending; pthread_t thread; atomic_bool connected; atomic_bool channel_closed; /* Peer CHANNEL_CLOSE callback observed */ int ref_count; /* Reference count for safe cleanup */ pthread_mutex_t ref_lock; /* Lock for ref_count */ pthread_mutex_t io_lock; /* Serialize SSH channel writes */ - pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */ + pthread_mutex_t whisper_lock; /* Serialize whisper inbox and identity mailbox */ atomic_bool wake_ready; /* Session thread accepts directed wake signals */ atomic_bool wake_pending; /* State check closes unblock/poll wake races */ /* Server-worker lifetime hook. Called after channel teardown and @@ -111,4 +122,8 @@ int ssh_server_start(int shutdown_fd, /* Read-only accessor for the server start time (used by exec stats). */ time_t ssh_server_start_time(void); +/* The listening port and the host name shown to users in connection hints. */ +int ssh_server_port(void); +const char *ssh_server_public_host(void); + #endif /* SSH_SERVER_H */ diff --git a/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO index 6d84e26..b255c14 100644 --- a/packaging/arch/.SRCINFO +++ b/packaging/arch/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = tnt-chat pkgdesc = SSH-native terminal chat server with a Vim-style interface - pkgver = 1.4.0 + pkgver = 1.5.0 pkgrel = 1 url = https://github.com/m1ngsama/TNT arch = x86_64 @@ -10,9 +10,9 @@ pkgbase = tnt-chat makedepends = make depends = libssh depends = openssh - source = tnt-chat-v1.4.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.4.0/tnt-chat-v1.4.0-source.tar.gz + source = tnt-chat-v1.5.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.5.0/tnt-chat-v1.5.0-source.tar.gz source = tnt-chat.sysusers - sha256sums = 9724bf0ff4e22aab11dd12c46db52d14ea2ffe7bfbaa47091f5ed5e2f0c3aa5b + sha256sums = SKIP sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed pkgname = tnt-chat diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index abe88b1..9107d0d 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: M1ng pkgname=tnt-chat -pkgver=1.4.0 +pkgver=1.5.0 pkgrel=1 pkgdesc='SSH-native terminal chat server with a Vim-style interface' arch=('x86_64' 'aarch64') @@ -11,7 +11,7 @@ depends=('libssh' 'openssh') makedepends=('gcc' 'make') source=("${pkgname}-v${pkgver}-source.tar.gz::${url}/releases/download/v${pkgver}/${pkgname}-v${pkgver}-source.tar.gz" "${pkgname}.sysusers") -sha256sums=('9724bf0ff4e22aab11dd12c46db52d14ea2ffe7bfbaa47091f5ed5e2f0c3aa5b' +sha256sums=('SKIP' '8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed') build() { diff --git a/packaging/debian/debian/changelog b/packaging/debian/debian/changelog index df850bb..e4b5a19 100644 --- a/packaging/debian/debian/changelog +++ b/packaging/debian/debian/changelog @@ -1,3 +1,15 @@ +tnt-chat (1.5.0-1) unstable; urgency=medium + + * Let members of m1ngsama ID sign in with :login and :logout, reserve + their handles, and sign in again by SSH key. + * Add the identity:write module permission and mark verified senders in + message.created metadata. + * Recognise a member by SSH key when the login name is their handle. + * Join a blank or fallback nickname as the first free anonymousN instead + of disconnecting when anonymous is taken. + + -- M1ng Wed, 14 Oct 2026 00:00:00 +0800 + tnt-chat (1.4.0-1) unstable; urgency=medium * Add a WebSocket gateway module that lets signed-in clients read and diff --git a/packaging/homebrew/tnt-chat.rb b/packaging/homebrew/tnt-chat.rb index 0e0126b..d7ff877 100644 --- a/packaging/homebrew/tnt-chat.rb +++ b/packaging/homebrew/tnt-chat.rb @@ -1,8 +1,8 @@ class TntChat < Formula desc "SSH-native terminal chat server with a Vim-style interface" homepage "https://github.com/m1ngsama/TNT" - url "https://github.com/m1ngsama/TNT/releases/download/v1.4.0/tnt-chat-v1.4.0-source.tar.gz" - sha256 "9724bf0ff4e22aab11dd12c46db52d14ea2ffe7bfbaa47091f5ed5e2f0c3aa5b" + url "https://github.com/m1ngsama/TNT/releases/download/v1.5.0/tnt-chat-v1.5.0-source.tar.gz" + sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256" license "MIT" depends_on "libssh" diff --git a/src/bootstrap.c b/src/bootstrap.c index a7486cc..1f379cf 100644 --- a/src/bootstrap.c +++ b/src/bootstrap.c @@ -1,6 +1,7 @@ #include "bootstrap.h" #include "client.h" #include "common.h" +#include "identity.h" #include "input.h" #include "ratelimit.h" #include "theme.h" @@ -25,6 +26,7 @@ typedef struct { char client_ip[INET6_ADDRSTRLEN]; char requested_user[MAX_USERNAME_LEN]; + char key_fingerprint[TNT_IDENTITY_FINGERPRINT_SIZE]; int pty_width; int pty_height; char exec_command[MAX_EXEC_COMMAND_LEN]; @@ -238,15 +240,44 @@ static int auth_none(ssh_session session, const char *user, void *userdata) { ctx->requested_user[sizeof(ctx->requested_user) - 1] = '\0'; } - /* If access token is configured, reject passwordless */ if (g_access_token[0] != '\0') { return SSH_AUTH_DENIED; - } else { - /* No token configured, allow passwordless */ - ctx->auth_success = true; - ratelimit_record_auth_success(ctx->client_ip); - return SSH_AUTH_SUCCESS; } + /* Accepting "none" ends authentication before the client offers its + * public key, so a login name that is a reserved handle has to refuse + * it for the key to be seen. A keyless client still gets in through + * keyboard-interactive, which asks nothing. */ + if (identity_is_reserved(ctx->requested_user)) { + return SSH_AUTH_DENIED; + } + ctx->auth_success = true; + ratelimit_record_auth_success(ctx->client_ip); + return SSH_AUTH_SUCCESS; +} + +/* Keyboard-interactive with zero prompts keeps keyless access passwordless. */ +static int auth_message(ssh_session session, ssh_message msg, void *userdata) { + session_context_t *ctx = (session_context_t *)userdata; + (void)session; + + if (g_access_token[0] != '\0' || + ssh_message_type(msg) != SSH_REQUEST_AUTH || + ssh_message_subtype(msg) != SSH_AUTH_METHOD_INTERACTIVE) { + return 1; + } + + if (ssh_message_auth_user(msg) && ssh_message_auth_user(msg)[0] != '\0') { + snprintf(ctx->requested_user, sizeof(ctx->requested_user), "%s", + ssh_message_auth_user(msg)); + } + if (!ssh_message_auth_kbdint_is_response(msg)) { + return ssh_message_auth_interactive_request(msg, "", "", 0, NULL, + NULL) == SSH_OK ? 0 : 1; + } + + ctx->auth_success = true; + ratelimit_record_auth_success(ctx->client_ip); + return ssh_message_auth_reply_success(msg, 0) == SSH_OK ? 0 : 1; } /* Public key authentication callback */ @@ -254,7 +285,6 @@ static int auth_pubkey(ssh_session session, const char *user, struct ssh_key_struct *pubkey, char signature_state, void *userdata) { (void)session; - (void)pubkey; session_context_t *ctx = (session_context_t *)userdata; if (user && user[0] != '\0') { @@ -274,6 +304,10 @@ static int auth_pubkey(ssh_session session, const char *user, return SSH_AUTH_SUCCESS; } + if (identity_fingerprint_from_key(pubkey, ctx->key_fingerprint, + sizeof(ctx->key_fingerprint)) < 0) { + ctx->key_fingerprint[0] = '\0'; + } ctx->auth_success = true; ratelimit_record_auth_success(ctx->client_ip); return SSH_AUTH_SUCCESS; @@ -521,6 +555,12 @@ void *bootstrap_run(void *arg) { server_cb.auth_pubkey_function = auth_pubkey; server_cb.channel_open_request_session_function = channel_open_request_session; ssh_set_server_callbacks(session, &server_cb); + ssh_set_message_callback(session, auth_message, ctx); + if (g_access_token[0] == '\0') { + ssh_set_auth_methods(session, SSH_AUTH_METHOD_PUBLICKEY | + SSH_AUTH_METHOD_INTERACTIVE | + SSH_AUTH_METHOD_PASSWORD); + } setup_deadline_ms = bootstrap_monotonic_millis() + BOOTSTRAP_SETUP_TIMEOUT_MS; @@ -604,6 +644,7 @@ void *bootstrap_run(void *arg) { atomic_init(&client->wake_ready, false); atomic_init(&client->wake_pending, false); atomic_init(&client->channel_closed, false); + atomic_init(&client->identity_pending, false); client->session = session; client->channel = channel; @@ -638,6 +679,8 @@ void *bootstrap_run(void *arg) { snprintf(client->client_ip, sizeof(client->client_ip), "%s", ctx->client_ip); } + snprintf(client->key_fingerprint, sizeof(client->key_fingerprint), "%s", + ctx->key_fingerprint); if (ctx->exec_command[0] != '\0') { strncpy(client->exec_command, ctx->exec_command, sizeof(client->exec_command) - 1); diff --git a/src/chat_room.c b/src/chat_room.c index 9b69db8..c6a207e 100644 --- a/src/chat_room.c +++ b/src/chat_room.c @@ -68,7 +68,8 @@ int room_add_client(chat_room_t *room, struct client *client) { return -1; } - if (room->client_name) { + if (room->client_name && + !(room->client_owns_name && room->client_owns_name(client))) { const char *candidate_name = room->client_name(client); for (int i = 0; i < room->client_count; i++) { const char *existing_name = room->client_name(room->clients[i]); @@ -167,6 +168,15 @@ void room_set_client_name_accessor(chat_room_t *room, pthread_rwlock_unlock(&room->lock); } +void room_set_client_name_owner(chat_room_t *room, + room_client_owns_name_fn accessor) { + if (!room) return; + + pthread_rwlock_wrlock(&room->lock); + room->client_owns_name = accessor; + pthread_rwlock_unlock(&room->lock); +} + void room_set_presence_notifier(chat_room_t *room, room_presence_fn notifier) { if (!room) return; diff --git a/src/command_catalog.c b/src/command_catalog.c index f5338c9..74561b1 100644 --- a/src/command_catalog.c +++ b/src/command_catalog.c @@ -66,6 +66,22 @@ static const command_catalog_entry_t entries[] = { I18N_STRING("Usage: nick \n", "用法: nick \n"), 2, false, true }, + { + {TNT_COMMAND_LOGIN, "login", {"login", NULL}}, + I18N_STRING(":login", ":login"), + I18N_STRING("Sign in with m1ngsama ID", "用 m1ngsama ID 登录"), + I18N_STRING(":login", ":login"), + I18N_STRING("Usage: login\n", "用法: login\n"), + 2, true, false + }, + { + {TNT_COMMAND_LOGOUT, "logout", {"logout", NULL}}, + I18N_STRING(":logout", ":logout"), + I18N_STRING("Sign out and become anonymous", "退出登录,改回匿名"), + I18N_STRING(":logout", ":logout"), + I18N_STRING("Usage: logout\n", "用法: logout\n"), + 2, true, false + }, { {TNT_COMMAND_LAST, "last", {"last", NULL}}, I18N_STRING(":last [N]", ":last [N]"), diff --git a/src/commands.c b/src/commands.c index 04c357c..68d3693 100644 --- a/src/commands.c +++ b/src/commands.c @@ -10,6 +10,7 @@ #include "command_catalog.h" #include "common.h" #include "i18n.h" +#include "identity.h" #include "keymap.h" #include "manual.h" #include "message.h" @@ -231,6 +232,57 @@ static void clear_inbox(client_t *client) { pthread_mutex_unlock(&client->whisper_lock); } +int commands_rename(client_t *client, const char *new_name, bool verified) { + char old_name[MAX_USERNAME_LEN]; + bool taken = false; + bool renamed; + + pthread_rwlock_wrlock(&g_room->lock); + snprintf(old_name, sizeof(old_name), "%s", client->username); + renamed = strcmp(new_name, old_name) != 0; + if (renamed && !verified) { + for (int i = 0; i < g_room->client_count; i++) { + if (g_room->clients[i] != client && + strcasecmp(g_room->clients[i]->username, new_name) == 0) { + taken = true; + break; + } + } + } + if (!taken) { + client->verified = verified; + snprintf(client->username, MAX_USERNAME_LEN, "%s", new_name); + if (g_room->presence_notifier && renamed) { + g_room->presence_notifier(old_name, false); + g_room->presence_notifier(client->username, true); + } + } + pthread_rwlock_unlock(&g_room->lock); + + if (taken) { + return -1; + } + if (!renamed) { + return 1; + } + + message_t nick_msg; + system_message_make_nick(&nick_msg, old_name, client->username, + client->ui_lang); + room_broadcast(g_room, &nick_msg); + message_save(&nick_msg); + tnt_module_runtime_publish_message_created(&nick_msg, false); + return 0; +} + +void commands_become_anonymous(client_t *client) { + char name[MAX_USERNAME_LEN] = "anonymous"; + + for (int n = 2; commands_rename(client, name, false) < 0 && n < 1000; n++) { + snprintf(name, sizeof(name), "anonymous%d", n); + } +} + bool commands_refresh_active_output(client_t *client) { char output[MAX_COMMAND_OUTPUT_LEN] = {0}; size_t pos = 0; @@ -440,6 +492,11 @@ void commands_dispatch(client_t *client) { if (new_name[0] == '\0') { append_command_usage(output, sizeof(output), &pos, TNT_COMMAND_NICK, client->ui_lang); + } else if (client->verified) { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_NICK_SIGNED_IN_FORMAT), + client->username); } else if (!is_valid_username(new_name)) { buffer_appendf(output, sizeof(output), &pos, "%s", i18n_text(client->ui_lang, I18N_NICK_INVALID)); @@ -450,55 +507,65 @@ void commands_dispatch(client_t *client) { utf8_truncate(validated_name, 20); } - /* Reject collisions with active room members. Held under - * wrlock so the username swap below races neither read nor - * concurrent :nick from another client. */ char old_name[MAX_USERNAME_LEN]; - bool taken = false; - pthread_rwlock_wrlock(&g_room->lock); snprintf(old_name, sizeof(old_name), "%s", client->username); - if (strcmp(validated_name, old_name) != 0) { - for (int i = 0; i < g_room->client_count; i++) { - if (g_room->clients[i] == client) continue; - if (strcasecmp(g_room->clients[i]->username, - validated_name) == 0) { - taken = true; - break; - } - } - } - if (!taken) { - snprintf(client->username, MAX_USERNAME_LEN, "%s", validated_name); - if (g_room->presence_notifier && - strcmp(client->username, old_name) != 0) { - g_room->presence_notifier(old_name, false); - g_room->presence_notifier(client->username, true); - } - } - pthread_rwlock_unlock(&g_room->lock); - if (taken) { + if (identity_is_reserved(validated_name)) { buffer_appendf(output, sizeof(output), &pos, i18n_text(client->ui_lang, - I18N_NICK_TAKEN_FORMAT), + I18N_NICK_RESERVED_FORMAT), validated_name); - } else if (strcmp(validated_name, old_name) == 0) { - buffer_appendf(output, sizeof(output), &pos, "%s", - i18n_text(client->ui_lang, - I18N_NICK_UNCHANGED)); } else { - message_t nick_msg; - system_message_make_nick(&nick_msg, old_name, - client->username, client->ui_lang); - room_broadcast(g_room, &nick_msg); - message_save(&nick_msg); - tnt_module_runtime_publish_message_created(&nick_msg); + int rc = commands_rename(client, validated_name, false); + + if (rc < 0) { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_NICK_TAKEN_FORMAT), + validated_name); + } else if (rc > 0) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, + I18N_NICK_UNCHANGED)); + } else { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_NICK_CHANGED_FORMAT), + old_name, client->username); + } + } + } - buffer_appendf(output, sizeof(output), &pos, - i18n_text(client->ui_lang, - I18N_NICK_CHANGED_FORMAT), - old_name, client->username); + } else if (command_id == TNT_COMMAND_LOGIN) { + if (client->verified) { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_LOGIN_ALREADY_FORMAT), + client->username); + } else if (tnt_module_runtime_request_identity(client->session_id, + client->username)) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, I18N_LOGIN_OPENING)); + } else { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, + I18N_LOGIN_UNAVAILABLE)); + } + + } else if (command_id == TNT_COMMAND_LOGOUT) { + if (!client->verified) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, + I18N_LOGOUT_NOT_SIGNED_IN)); + } else { + if (client->key_fingerprint[0] != '\0') { + (void)identity_forget_key(client->key_fingerprint); } + commands_become_anonymous(client); + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_LOGOUT_DONE_FORMAT), + client->username); } } else if (command_id == TNT_COMMAND_LAST) { diff --git a/src/exec.c b/src/exec.c index 763b3b0..6bac005 100644 --- a/src/exec.c +++ b/src/exec.c @@ -4,6 +4,7 @@ #include "common.h" #include "exec_catalog.h" #include "i18n.h" +#include "identity.h" #include "input.h" #include "json_text.h" #include "message.h" @@ -14,6 +15,7 @@ #include #include #include +#include #include #define TNT_DUMP_DEFAULT_RECORDS 100 @@ -369,6 +371,8 @@ static int exec_command_dump(client_t *client, const char *args) { static int exec_command_post(client_t *client, const char *args) { char content[MAX_MESSAGE_LEN]; char username[MAX_USERNAME_LEN]; + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + bool verified = false; message_t msg = { .timestamp = time(NULL), }; @@ -408,6 +412,19 @@ static int exec_command_post(client_t *client, const char *args) { } resolve_exec_username(client, username, sizeof(username)); + if (identity_is_reserved(username)) { + if (!identity_handle_for_key(client->key_fingerprint, handle, + sizeof(handle)) || + strcasecmp(handle, username) != 0) { + client_printf(client, + i18n_text(client->ui_lang, + I18N_EXEC_POST_RESERVED_FORMAT), + username); + return TNT_EXIT_ERROR; + } + snprintf(username, sizeof(username), "%s", handle); + verified = true; + } if (strncmp(content, "/me ", 4) == 0 && content[4] != '\0') { size_t username_len = strlen(username); @@ -442,7 +459,7 @@ static int exec_command_post(client_t *client, const char *args) { room_broadcast(g_room, &msg); notify_mentions(msg.content, client); - tnt_module_runtime_publish_message_created(&msg); + tnt_module_runtime_publish_message_created(&msg, verified); if (client_send(client, "posted\n", 7) != 0) { return TNT_EXIT_ERROR; diff --git a/src/i18n_text.c b/src/i18n_text.c index 74ef4b7..844b4ca 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -181,6 +181,50 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "Nickname changed: %s -> %s\n", "昵称已修改: %s -> %s\n" ), + [I18N_NICK_RESERVED_FORMAT] = I18N_STRING( + "@%s belongs to a signed-in member.\n" + "Use :login if it is you, or pick another name.\n", + "@%s 属于一位已登录的成员。\n" + "如果是你本人,请用 :login 登录,否则请换一个名字。\n" + ), + [I18N_NICK_SIGNED_IN_FORMAT] = I18N_STRING( + "You are signed in as @%s.\n" + "Use :logout before changing your name.\n", + "你已登录为 @%s。\n" + "要改名,请先用 :logout 退出登录。\n" + ), + [I18N_LOGIN_UNAVAILABLE] = I18N_STRING( + "Sign-in is not available on this server.\n", + "这台服务器没有开放登录。\n" + ), + [I18N_LOGIN_OPENING] = I18N_STRING( + "Opening sign-in…\n", + "正在打开登录…\n" + ), + [I18N_LOGIN_ALREADY_FORMAT] = I18N_STRING( + "You are already signed in as @%s.\n", + "你已经登录为 @%s。\n" + ), + [I18N_SIGNED_IN_FORMAT] = I18N_STRING( + "Signed in as @%s.\n", + "已登录为 @%s。\n" + ), + [I18N_SIGNED_IN_KEY_HINT_FORMAT] = I18N_STRING( + "To be recognised by your SSH key next time, connect with\n" + " ssh %s@%s -p %d\n" + "and run :login once more.\n", + "想下次凭 SSH 密钥自动识别,请用你的密钥这样连接:\n" + " ssh %s@%s -p %d\n" + "然后再运行一次 :login。\n" + ), + [I18N_LOGOUT_NOT_SIGNED_IN] = I18N_STRING( + "You are not signed in.\n", + "你还没有登录。\n" + ), + [I18N_LOGOUT_DONE_FORMAT] = I18N_STRING( + "Signed out. Your name is now %s.\n", + "已退出登录,你现在的名字是 %s。\n" + ), [I18N_LAST_HEADER_FORMAT] = I18N_STRING( "--- Last %d message(s) ---\n", "--- 最近 %d 条消息 ---\n" @@ -279,6 +323,12 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "post: failed to persist message\n", "post: 消息持久化失败\n" ), + [I18N_EXEC_POST_RESERVED_FORMAT] = I18N_STRING( + "post: %s belongs to a signed-in member; " + "connect with that member's SSH key to post as it\n", + "post: %s 属于一位已登录的成员," + "只有用这位成员的 SSH 密钥连接才能以这个名字发帖\n" + ), [I18N_EXEC_COMMAND_TOO_LONG] = I18N_STRING( "exec: command too long\n", "exec: 命令过长\n" diff --git a/src/identity.c b/src/identity.c new file mode 100644 index 0000000..9a7a849 --- /dev/null +++ b/src/identity.c @@ -0,0 +1,380 @@ +#include "identity.h" + +#include +#include +#include +#include + +#define FINGERPRINT_PREFIX "SHA256:" +#define FINGERPRINT_PREFIX_LEN 7 +#define FINGERPRINT_BASE64_LEN 43 + +static pthread_mutex_t g_identity_lock = PTHREAD_MUTEX_INITIALIZER; +static identity_table_t g_identities; +static bool g_identities_loaded = false; +static struct stat g_identities_stat; +static bool g_identities_exist = false; + +bool identity_handle_valid(const char *handle) { + size_t len; + + if (!handle) { + return false; + } + len = strlen(handle); + if (len == 0 || len > TNT_IDENTITY_HANDLE_MAX) { + return false; + } + for (const char *p = handle; *p; p++) { + if (!((*p >= 'a' && *p <= 'z') || (*p >= '0' && *p <= '9') || + *p == '_')) { + return false; + } + } + return true; +} + +bool identity_fingerprint_valid(const char *fingerprint) { + if (!fingerprint || + strncmp(fingerprint, FINGERPRINT_PREFIX, FINGERPRINT_PREFIX_LEN) != 0 || + strlen(fingerprint) != + FINGERPRINT_PREFIX_LEN + FINGERPRINT_BASE64_LEN) { + return false; + } + for (const char *p = fingerprint + FINGERPRINT_PREFIX_LEN; *p; p++) { + if (!((*p >= 'A' && *p <= 'Z') || (*p >= 'a' && *p <= 'z') || + (*p >= '0' && *p <= '9') || *p == '+' || *p == '/')) { + return false; + } + } + return true; +} + +int identity_fingerprint_from_key(ssh_key key, char *out, size_t out_size) { + unsigned char *hash = NULL; + size_t hash_len = 0; + char *fingerprint; + int rc = -1; + + if (!key || !out || out_size == 0) { + return -1; + } + out[0] = '\0'; + if (ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA256, &hash, + &hash_len) != 0) { + return -1; + } + fingerprint = ssh_get_fingerprint_hash(SSH_PUBLICKEY_HASH_SHA256, hash, + hash_len); + ssh_clean_pubkey_hash(&hash); + if (fingerprint && identity_fingerprint_valid(fingerprint) && + strlen(fingerprint) < out_size) { + snprintf(out, out_size, "%s", fingerprint); + rc = 0; + } + ssh_string_free_char(fingerprint); + return rc; +} + +void identity_table_free(identity_table_t *table) { + if (!table) { + return; + } + free(table->entries); + table->entries = NULL; + table->count = 0; +} + +const identity_entry_t *identity_table_find(const identity_table_t *table, + const char *name) { + if (!table || !name) { + return NULL; + } + for (size_t i = 0; i < table->count; i++) { + if (strcasecmp(table->entries[i].handle, name) == 0) { + return &table->entries[i]; + } + } + return NULL; +} + +const char *identity_table_handle_for_key(const identity_table_t *table, + const char *fingerprint) { + if (!table || !fingerprint || fingerprint[0] == '\0') { + return NULL; + } + for (size_t i = 0; i < table->count; i++) { + for (int k = 0; k < table->entries[i].key_count; k++) { + if (strcmp(table->entries[i].keys[k], fingerprint) == 0) { + return table->entries[i].handle; + } + } + } + return NULL; +} + +static void entry_remove_key(identity_entry_t *entry, int index) { + memmove(entry->keys[index], entry->keys[index + 1], + (size_t)(entry->key_count - index - 1) * sizeof(entry->keys[0])); + entry->key_count--; +} + +bool identity_table_forget_key(identity_table_t *table, + const char *fingerprint) { + bool removed = false; + + if (!table || !fingerprint) { + return false; + } + for (size_t i = 0; i < table->count; i++) { + identity_entry_t *entry = &table->entries[i]; + for (int k = entry->key_count - 1; k >= 0; k--) { + if (strcmp(entry->keys[k], fingerprint) == 0) { + entry_remove_key(entry, k); + removed = true; + } + } + } + return removed; +} + +int identity_table_add(identity_table_t *table, const char *handle, + const char *fingerprint) { + identity_entry_t *entry; + + if (!table || !identity_handle_valid(handle) || + (fingerprint && !identity_fingerprint_valid(fingerprint))) { + return -1; + } + + entry = (identity_entry_t *)identity_table_find(table, handle); + if (!entry) { + identity_entry_t *grown = realloc( + table->entries, (table->count + 1) * sizeof(*table->entries)); + if (!grown) { + return -1; + } + table->entries = grown; + entry = &table->entries[table->count++]; + memset(entry, 0, sizeof(*entry)); + snprintf(entry->handle, sizeof(entry->handle), "%s", handle); + } + + if (fingerprint) { + (void)identity_table_forget_key(table, fingerprint); + if (entry->key_count == TNT_IDENTITY_MAX_KEYS) { + entry_remove_key(entry, 0); + } + snprintf(entry->keys[entry->key_count++], sizeof(entry->keys[0]), + "%s", fingerprint); + } + return 0; +} + +static int parse_line(identity_table_t *table, char *line) { + char *saveptr = NULL; + char *token = strtok_r(line, " \t\r\n", &saveptr); + + if (!token || !identity_handle_valid(token) || + identity_table_find(table, token)) { + return 0; + } + if (identity_table_add(table, token, NULL) < 0) { + return -1; + } + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + snprintf(handle, sizeof(handle), "%s", token); + while ((token = strtok_r(NULL, " \t\r\n", &saveptr)) != NULL) { + if (identity_fingerprint_valid(token) && + !identity_table_handle_for_key(table, token) && + identity_table_add(table, handle, token) < 0) { + return -1; + } + } + return 0; +} + +int identity_table_load(identity_table_t *table, const char *path) { + FILE *fp; + char *line = NULL; + size_t line_size = 0; + int rc = 0; + + if (!table || !path) { + return -1; + } + table->entries = NULL; + table->count = 0; + + fp = fopen(path, "r"); + if (!fp) { + return errno == ENOENT ? 0 : -1; + } + while (getline(&line, &line_size, fp) >= 0) { + if (parse_line(table, line) < 0) { + rc = -1; + break; + } + } + if (ferror(fp)) { + rc = -1; + } + free(line); + fclose(fp); + if (rc < 0) { + identity_table_free(table); + } + return rc; +} + +int identity_table_save(const identity_table_t *table, const char *path) { + char temp_path[PATH_MAX]; + FILE *fp; + int fd; + bool ok = true; + + if (!table || !path || + snprintf(temp_path, sizeof(temp_path), "%s.XXXXXX", path) >= + (int)sizeof(temp_path)) { + return -1; + } + + fd = mkstemp(temp_path); + if (fd < 0) { + return -1; + } + fp = fdopen(fd, "w"); + if (!fp) { + close(fd); + unlink(temp_path); + return -1; + } + + for (size_t i = 0; i < table->count && ok; i++) { + const identity_entry_t *entry = &table->entries[i]; + + ok = fputs(entry->handle, fp) >= 0; + for (int k = 0; k < entry->key_count && ok; k++) { + ok = fprintf(fp, " %s", entry->keys[k]) >= 0; + } + ok = ok && fputc('\n', fp) != EOF; + } + ok = ok && fchmod(fd, 0600) == 0 && fflush(fp) == 0 && fsync(fd) == 0; + if (fclose(fp) != 0) { + ok = false; + } + if (!ok || rename(temp_path, path) != 0) { + unlink(temp_path); + return -1; + } + return 0; +} + +static bool same_file_state(const struct stat *a, const struct stat *b) { + return a->st_ino == b->st_ino && a->st_size == b->st_size && + a->st_mtime == b->st_mtime; +} + +static int identities_path(char *path, size_t path_size) { + return tnt_state_path(path, path_size, TNT_IDENTITY_FILE); +} + +/* Caller holds g_identity_lock. */ +static int refresh_locked(const char *path) { + struct stat st; + bool exists = stat(path, &st) == 0; + identity_table_t fresh; + + if (g_identities_loaded && exists == g_identities_exist && + (!exists || same_file_state(&st, &g_identities_stat))) { + return 0; + } + if (identity_table_load(&fresh, path) < 0) { + fprintf(stderr, "identity: failed to read %s\n", path); + return -1; + } + identity_table_free(&g_identities); + g_identities = fresh; + g_identities_loaded = true; + g_identities_exist = exists; + if (exists) { + g_identities_stat = st; + } + return 0; +} + +static int save_locked(const char *path) { + if (identity_table_save(&g_identities, path) < 0) { + fprintf(stderr, "identity: failed to write %s\n", path); + g_identities_loaded = false; + return -1; + } + g_identities_exist = stat(path, &g_identities_stat) == 0; + return 0; +} + +bool identity_is_reserved(const char *name) { + char path[PATH_MAX]; + bool reserved = false; + + if (!name || name[0] == '\0' || identities_path(path, sizeof(path)) < 0) { + return false; + } + pthread_mutex_lock(&g_identity_lock); + (void)refresh_locked(path); + reserved = identity_table_find(&g_identities, name) != NULL; + pthread_mutex_unlock(&g_identity_lock); + return reserved; +} + +bool identity_handle_for_key(const char *fingerprint, char *handle, + size_t handle_size) { + char path[PATH_MAX]; + const char *found; + + if (!fingerprint || fingerprint[0] == '\0' || !handle || + handle_size == 0 || identities_path(path, sizeof(path)) < 0) { + return false; + } + pthread_mutex_lock(&g_identity_lock); + (void)refresh_locked(path); + found = identity_table_handle_for_key(&g_identities, fingerprint); + if (found) { + snprintf(handle, handle_size, "%s", found); + } + pthread_mutex_unlock(&g_identity_lock); + return found != NULL; +} + +int identity_reserve(const char *handle, const char *fingerprint) { + char path[PATH_MAX]; + int rc = -1; + + if (identities_path(path, sizeof(path)) < 0) { + return -1; + } + pthread_mutex_lock(&g_identity_lock); + if (refresh_locked(path) == 0 && + identity_table_add(&g_identities, handle, fingerprint) == 0) { + rc = save_locked(path); + } + pthread_mutex_unlock(&g_identity_lock); + return rc; +} + +int identity_forget_key(const char *fingerprint) { + char path[PATH_MAX]; + int rc = -1; + + if (!fingerprint || identities_path(path, sizeof(path)) < 0) { + return -1; + } + pthread_mutex_lock(&g_identity_lock); + if (refresh_locked(path) == 0) { + rc = identity_table_forget_key(&g_identities, fingerprint) + ? save_locked(path) + : 0; + } + pthread_mutex_unlock(&g_identity_lock); + return rc; +} diff --git a/src/input.c b/src/input.c index 1f4e499..3983ae1 100644 --- a/src/input.c +++ b/src/input.c @@ -13,6 +13,7 @@ #include "exec.h" #include "history_view.h" #include "i18n.h" +#include "identity.h" #include "keymap.h" #include "input_buffer.h" #include "message.h" @@ -49,12 +50,18 @@ static tnt_keymap_t g_default_keymap = TNT_KEYMAP_DEFAULT; #define ROOM_REDRAW_MIN_INTERVAL_MS 8 #define USERNAME_TIMEOUT_MS 60000 +static atomic_ullong g_next_session_id = 0; + static int64_t input_monotonic_millis(void); static const char *input_client_name(const struct client *client) { return client ? ((const client_t *)client)->username : NULL; } +static bool input_client_owns_name(const struct client *client) { + return client && ((const client_t *)client)->verified; +} + void input_init(void) { g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT); g_default_ui_lang = i18n_default_ui_lang(); @@ -62,6 +69,7 @@ void input_init(void) { g_default_keymap); room_set_client_notifier(g_room, client_wake); room_set_client_name_accessor(g_room, input_client_name); + room_set_client_name_owner(g_room, input_client_owns_name); room_set_presence_notifier(g_room, tnt_module_runtime_publish_presence); } @@ -242,6 +250,147 @@ void notify_mentions(const char *content, const client_t *sender) { free(targets); } +static void input_post_identity(client_t *client, const char *notice, + const char *handle, bool evict) { + pthread_mutex_lock(&client->whisper_lock); + if (notice) { + size_t len = strlen(client->identity_notice); + snprintf(client->identity_notice + len, + sizeof(client->identity_notice) - len, "%s%s", + len > 0 ? "\n" : "", notice); + } + if (handle) { + snprintf(client->identity_handle, sizeof(client->identity_handle), + "%s", handle); + } + if (evict) { + client->identity_evict = true; + } + pthread_mutex_unlock(&client->whisper_lock); + atomic_store(&client->identity_pending, true); + client_wake(client); +} + +static client_t *input_find_session(const char *session) { + client_t *found = NULL; + + pthread_rwlock_rdlock(&g_room->lock); + for (int i = 0; i < g_room->client_count; i++) { + if (strcmp(g_room->clients[i]->session_id, session) == 0) { + found = g_room->clients[i]; + client_addref(found); + break; + } + } + pthread_rwlock_unlock(&g_room->lock); + return found; +} + +bool input_identity_notice(const char *session, const char *plain_text) { + client_t *client = input_find_session(session); + + if (!client) { + return false; + } + input_post_identity(client, plain_text, NULL, false); + client_release(client); + return true; +} + +bool input_identity_verified(const char *session, const char *handle) { + client_t *client = input_find_session(session); + + if (!client) { + return false; + } + (void)identity_reserve(handle, client->key_fingerprint[0] != '\0' + ? client->key_fingerprint + : NULL); + input_post_identity(client, NULL, handle, false); + client_release(client); + return true; +} + +/* Sessions rename only themselves, so an unverified holder of a handle that + * just signed in is asked to step aside rather than renamed from here. */ +static void evict_unverified_holders(const client_t *self, const char *handle) { + client_t **targets = NULL; + int target_count = 0; + + pthread_rwlock_rdlock(&g_room->lock); + if (g_room->client_count > 0) { + targets = calloc((size_t)g_room->client_count, sizeof(*targets)); + } + for (int i = 0; targets && i < g_room->client_count; i++) { + client_t *c = g_room->clients[i]; + if (c != self && !c->verified && + strcasecmp(c->username, handle) == 0) { + client_addref(c); + targets[target_count++] = c; + } + } + pthread_rwlock_unlock(&g_room->lock); + + for (int i = 0; i < target_count; i++) { + input_post_identity(targets[i], NULL, NULL, true); + client_release(targets[i]); + } + free(targets); +} + +static void show_session_notice(client_t *client, const char *text) { + snprintf(client->command_output, sizeof(client->command_output), "%s", + text); + client->command_output_scroll = 0; + client->command_output_kind = TNT_COMMAND_OUTPUT_NOTICE; + client->show_motd = false; + client->redraw_pending = true; +} + +static void apply_identity_mailbox(client_t *client) { + char notice[sizeof(client->identity_notice)]; + char handle[sizeof(client->identity_handle)]; + char output[MAX_COMMAND_OUTPUT_LEN] = ""; + size_t pos = 0; + bool evict; + + pthread_mutex_lock(&client->whisper_lock); + memcpy(notice, client->identity_notice, sizeof(notice)); + memcpy(handle, client->identity_handle, sizeof(handle)); + evict = client->identity_evict; + client->identity_notice[0] = '\0'; + client->identity_handle[0] = '\0'; + client->identity_evict = false; + pthread_mutex_unlock(&client->whisper_lock); + + if (notice[0] != '\0') { + buffer_appendf(output, sizeof(output), &pos, "%s\n", notice); + } + if (handle[0] != '\0') { + (void)commands_rename(client, handle, true); + evict_unverified_holders(client, handle); + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, I18N_SIGNED_IN_FORMAT), + handle); + if (client->key_fingerprint[0] == '\0') { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_SIGNED_IN_KEY_HINT_FORMAT), + handle, ssh_server_public_host(), + ssh_server_port()); + } + } else if (evict && !client->verified && + identity_is_reserved(client->username)) { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, I18N_NICK_RESERVED_FORMAT), + client->username); + commands_become_anonymous(client); + } + if (output[0] != '\0') { + show_session_notice(client, output); + } +} + static int read_channel_exact(client_t *client, char *buf, size_t len, int timeout_ms) { size_t got = 0; @@ -696,6 +845,20 @@ static bool handle_key(client_t *client, unsigned char key, editor_t *ed, /* Handle command output / MOTD display. MOTD remains a simple notice; * command output behaves like a small pager so long results can be read. */ + /* A notice can arrive at any moment, from a module or on entry, so it + * must not swallow what someone is already typing: any key but q or + * Esc closes it and then does its usual job. */ + if (client->command_output[0] != '\0' && + client->command_output_kind == TNT_COMMAND_OUTPUT_NOTICE && + !client->show_motd) { + client_mode_t mode = client->mode; + dismiss_command_output(client); + client->mode = mode; + if (key == 'q' || key == 27) { + return true; + } + } + if (client->command_output[0] != '\0') { pager_action_t action; @@ -1015,7 +1178,8 @@ static bool handle_key(client_t *client, unsigned char key, editor_t *ed, if (message_save(&msg) == 0) { room_broadcast(g_room, &msg); notify_mentions(msg.content, client); - tnt_module_runtime_publish_message_created(&msg); + tnt_module_runtime_publish_message_created( + &msg, client->verified); } else { fprintf(stderr, "interactive: failed to persist message\n"); } @@ -1393,6 +1557,8 @@ void input_run_session(client_t *client) { int64_t last_keepalive_ms = input_monotonic_millis(); int64_t last_activity_ms = last_keepalive_ms; int64_t last_room_render_ms = 0; + char entry_notice[512] = ""; + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; /* Terminal size already set from PTY request */ client->mode = MODE_INSERT; @@ -1417,13 +1583,37 @@ void input_run_session(client_t *client) { goto cleanup; } - /* Read username */ - if (read_username(client) < 0) { - goto cleanup; + if (client->key_fingerprint[0] != '\0' && + identity_handle_for_key(client->key_fingerprint, handle, + sizeof(handle))) { + snprintf(client->username, sizeof(client->username), "%s", handle); + client->verified = true; + snprintf(entry_notice, sizeof(entry_notice), + i18n_text(client->ui_lang, I18N_SIGNED_IN_FORMAT), handle); + } else { + if (read_username(client) < 0) { + goto cleanup; + } + if (identity_is_reserved(client->username)) { + snprintf(entry_notice, sizeof(entry_notice), + i18n_text(client->ui_lang, I18N_NICK_RESERVED_FORMAT), + client->username); + snprintf(client->username, sizeof(client->username), + "anonymous"); + } } + snprintf(client->session_id, sizeof(client->session_id), "s-%llu", + atomic_fetch_add(&g_next_session_id, 1) + 1); - /* Add to room */ + /* Add to room. Nobody asked for a particular anonymous name, so a + * taken one moves on to the next free anonymousN. */ + bool anonymous_name = strcmp(client->username, "anonymous") == 0; int join_rc = room_add_client(g_room, client); + for (int n = 2; join_rc == -2 && anonymous_name && n < 1000; n++) { + snprintf(client->username, sizeof(client->username), "anonymous%d", + n); + join_rc = room_add_client(g_room, client); + } if (join_rc < 0) { if (join_rc == -2) { client_printf(client, @@ -1437,6 +1627,9 @@ void input_run_session(client_t *client) { goto cleanup; } joined_room = true; + if (client->verified) { + evict_unverified_holders(client, client->username); + } /* Enable xterm bracketed-paste mode only for interactive chat, so * multi-line pastes arrive framed by ESC[200~...ESC[201~ instead of @@ -1449,7 +1642,7 @@ void input_run_session(client_t *client) { system_message_make_join(&join_msg, client->username, client->ui_lang); room_broadcast(g_room, &join_msg); message_save(&join_msg); - tnt_module_runtime_publish_message_created(&join_msg); + tnt_module_runtime_publish_message_created(&join_msg, false); /* Show MOTD if motd.txt exists in state directory */ { @@ -1464,7 +1657,9 @@ void input_run_session(client_t *client) { motd_buf[motd_len] = '\0'; snprintf(client->command_output, sizeof(client->command_output), - "%s", motd_buf); + "%s%s%s", entry_notice, + entry_notice[0] != '\0' ? "\n" : "", + motd_buf); client->command_output_scroll = 0; client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->show_motd = true; @@ -1480,6 +1675,9 @@ void input_run_session(client_t *client) { seen_update_seq = room_get_update_seq(g_room); tui_render_screen(client); last_room_render_ms = input_monotonic_millis(); + if (entry_notice[0] != '\0') { + show_session_notice(client, entry_notice); + } main_loop: @@ -1503,6 +1701,10 @@ void input_run_session(client_t *client) { break; } + if (atomic_exchange(&client->identity_pending, false)) { + apply_identity_mailbox(client); + } + if (current_update_seq != seen_update_seq) { room_updated = true; } @@ -1839,7 +2041,7 @@ void input_run_session(client_t *client) { room_remove_client(g_room, client); room_broadcast(g_room, &leave_msg); message_save(&leave_msg); - tnt_module_runtime_publish_message_created(&leave_msg); + tnt_module_runtime_publish_message_created(&leave_msg, false); } /* Interactive shells otherwise look like a transport reset to OpenSSH diff --git a/src/module_protocol.c b/src/module_protocol.c index e688b6e..3e96602 100644 --- a/src/module_protocol.c +++ b/src/module_protocol.c @@ -40,7 +40,7 @@ int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, int tnt_module_append_message_created(char *buffer, size_t buf_size, size_t *pos, const char *message_id, - const message_t *msg) { + const message_t *msg, bool verified) { char timestamp[64]; size_t before; @@ -64,7 +64,8 @@ int tnt_module_append_message_created(char *buffer, size_t buf_size, buffer_appendf(buffer, buf_size, pos, ",\"kind\":\"text\"," "\"plain_text\":"); tnt_json_append_string(buffer, buf_size, pos, msg->content); - buffer_appendf(buffer, buf_size, pos, ",\"metadata\":{}}}\n"); + buffer_appendf(buffer, buf_size, pos, ",\"metadata\":{%s}}}\n", + verified ? "\"verified\":true" : ""); return append_result(before, *pos, buf_size); } @@ -117,6 +118,27 @@ int tnt_module_append_presence_snapshot(char *buffer, size_t buf_size, return append_result(before, *pos, buf_size); } +int tnt_module_append_identity_requested(char *buffer, size_t buf_size, + size_t *pos, const char *session, + const char *nickname) { + size_t before; + + if (!buffer || !pos || buf_size == 0 || !session || session[0] == '\0' || + !nickname || !utf8_is_valid_string(nickname)) { + return -1; + } + + before = *pos; + buffer_appendf(buffer, buf_size, pos, "{\"type\":\"%s\",\"session\":", + TNT_MODULE_EVENT_IDENTITY_REQUESTED); + tnt_json_append_string(buffer, buf_size, pos, session); + buffer_appendf(buffer, buf_size, pos, ",\"nickname\":"); + tnt_json_append_string(buffer, buf_size, pos, nickname); + buffer_appendf(buffer, buf_size, pos, "}\n"); + + return append_result(before, *pos, buf_size); +} + bool tnt_module_parse_message_create(const char *line, tnt_module_message_create_t *out) { char type[64]; @@ -163,3 +185,41 @@ bool tnt_module_parse_message_post(const char *line, sizeof(out->plain_text)) && is_valid_plain_text(out->plain_text); } + +bool tnt_module_parse_identity_record(const char *line, + tnt_module_identity_record_t *out) { + char type[64]; + + if (!line || !out) { + return false; + } + + memset(out, 0, sizeof(*out)); + if (!tnt_json_get_string_field(line, "type", type, sizeof(type))) { + return false; + } + if (strcmp(type, "identity.notice") == 0) { + out->kind = TNT_MODULE_IDENTITY_NOTICE; + return tnt_json_get_string_field(line, "session", out->session, + sizeof(out->session)) && + out->session[0] != '\0' && + tnt_json_get_string_field(line, "plain_text", out->plain_text, + sizeof(out->plain_text)) && + is_valid_plain_text(out->plain_text); + } + if (strcmp(type, "identity.verified") == 0) { + out->kind = TNT_MODULE_IDENTITY_VERIFIED; + if (!tnt_json_get_string_field(line, "session", out->session, + sizeof(out->session)) || + out->session[0] == '\0') { + return false; + } + } else if (strcmp(type, "identity.reserve") == 0) { + out->kind = TNT_MODULE_IDENTITY_RESERVE; + } else { + return false; + } + return tnt_json_get_string_field(line, "handle", out->handle, + sizeof(out->handle)) && + identity_handle_valid(out->handle); +} diff --git a/src/module_runtime.c b/src/module_runtime.c index 12b183d..99879d1 100644 --- a/src/module_runtime.c +++ b/src/module_runtime.c @@ -3,6 +3,7 @@ #include "chat_room.h" #include "common.h" #include "config_defaults.h" +#include "identity.h" #include "json_text.h" #include "module_protocol.h" #include "utf8.h" @@ -33,18 +34,24 @@ struct client; void notify_mentions(const char *content, const struct client *sender); +bool input_identity_notice(const char *session, const char *plain_text); +bool input_identity_verified(const char *session, const char *handle); typedef enum module_event_kind { MODULE_EVENT_MESSAGE_CREATED, MODULE_EVENT_PRESENCE_JOINED, MODULE_EVENT_PRESENCE_LEFT, - MODULE_EVENT_PRESENCE_SNAPSHOT + MODULE_EVENT_PRESENCE_SNAPSHOT, + MODULE_EVENT_IDENTITY_REQUESTED } module_event_kind_t; +/* An identity request carries the nickname in msg.username and the session + * id in msg.content. */ typedef struct module_event { module_event_kind_t kind; message_t msg; uint64_t event_id; + bool verified; } module_event_t; typedef struct module_read_buffer { @@ -113,6 +120,7 @@ static uint64_t g_next_event_id = 0; static atomic_bool g_stop_requested = ATOMIC_VAR_INIT(true); static pthread_mutex_t g_dispatch_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_module_publish_lock = PTHREAD_MUTEX_INITIALIZER; +static module_process_t *g_identity_module = NULL; static char g_online[TNT_MAX_CONFIGURED_CLIENTS][MAX_USERNAME_LEN]; static int g_online_count = 0; @@ -261,6 +269,8 @@ int tnt_module_manifest_load(const char *module_dir, manifest, "permissions", "message:post"); out->can_read_presence = json_array_contains_string( manifest, "permissions", "presence:read"); + out->can_write_identity = json_array_contains_string( + manifest, "permissions", "identity:write"); if (!out->wants_message_created || !out->can_read_messages || !out->can_create_messages) { @@ -791,7 +801,8 @@ static module_queue_push_result_t module_queue_push(module_process_t *module, active = atomic_load_explicit(&module->active, memory_order_acquire); if (!module->queue_running || queue_full || !active) { if (module->queue_running && queue_full && active && - event->kind != MODULE_EVENT_MESSAGE_CREATED) { + event->kind != MODULE_EVENT_MESSAGE_CREATED && + event->kind != MODULE_EVENT_IDENTITY_REQUESTED) { module->dropped_presence++; } else if (module->queue_running && queue_full && active && !module->saturation_reported) { @@ -901,10 +912,11 @@ static bool online_update_locked(const char *nickname, bool joined) { } static int queue_event_locked(module_event_kind_t kind, const message_t *msg, - const module_process_t *origin, + bool verified, const module_process_t *origin, char saturated[][TNT_MODULE_NAME_MAX + 1]) { module_event_t event = { .kind = kind, + .verified = verified, }; int saturated_count = 0; @@ -949,7 +961,7 @@ static void report_saturated(char saturated[][TNT_MODULE_NAME_MAX + 1], } static void dispatch_event(module_event_kind_t kind, const message_t *msg, - const module_process_t *origin) { + bool verified, const module_process_t *origin) { char saturated[TNT_MAX_MODULES][TNT_MODULE_NAME_MAX + 1]; int saturated_count = 0; @@ -960,7 +972,8 @@ static void dispatch_event(module_event_kind_t kind, const message_t *msg, pthread_mutex_lock(&g_dispatch_lock); if (g_accepting && !atomic_load_explicit(&g_stop_requested, memory_order_acquire)) { - saturated_count = queue_event_locked(kind, msg, origin, saturated); + saturated_count = queue_event_locked(kind, msg, verified, origin, + saturated); } pthread_mutex_unlock(&g_dispatch_lock); report_saturated(saturated, saturated_count); @@ -1014,7 +1027,7 @@ static void publish_module_message(const module_process_t *module, TNT_MODULE_NAME_MAX, module->manifest.name); snprintf(msg.content, sizeof(msg.content), "%s", plain_text); if (publish_message(&msg)) { - dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, module); + dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, false, module); } } @@ -1033,21 +1046,59 @@ static void publish_posted_message(const module_process_t *module, snprintf(msg.content, sizeof(msg.content), "%s", post->plain_text); } if (publish_message(&msg)) { - dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, module); + dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, + module->manifest.can_write_identity, module); } } -static module_response_action_t handle_module_post(module_process_t *module, - const char *line) { - tnt_module_message_post_t post; +static void apply_identity_record(const tnt_module_identity_record_t *record) { + switch (record->kind) { + case TNT_MODULE_IDENTITY_NOTICE: + (void)input_identity_notice(record->session, record->plain_text); + break; + case TNT_MODULE_IDENTITY_VERIFIED: + (void)input_identity_verified(record->session, record->handle); + break; + case TNT_MODULE_IDENTITY_RESERVE: + (void)identity_reserve(record->handle, NULL); + break; + } +} + +/* Records a granted module may write at any time, outside the response + * window of an event. */ +static bool is_unsolicited_record(const module_process_t *module, + const char *line) { + char type[64]; + + if (!tnt_json_get_string_field(line, "type", type, sizeof(type))) { + return false; + } + return (module->manifest.can_post_messages && + strcmp(type, TNT_MODULE_RECORD_MESSAGE_POST) == 0) || + (module->manifest.can_write_identity && + strncmp(type, TNT_MODULE_RECORD_IDENTITY_PREFIX, + strlen(TNT_MODULE_RECORD_IDENTITY_PREFIX)) == 0); +} - if (!module->manifest.can_post_messages || - !tnt_module_parse_message_post(line, &post)) { +static module_response_action_t handle_module_record(module_process_t *module, + const char *line) { + tnt_module_message_post_t post; + tnt_module_identity_record_t identity; + + if (module->manifest.can_post_messages && + tnt_module_parse_message_post(line, &post) && + (module->manifest.can_write_identity || + !identity_is_reserved(post.sender))) { + publish_posted_message(module, &post); + } else if (module->manifest.can_write_identity && + tnt_module_parse_identity_record(line, &identity)) { + apply_identity_record(&identity); + } else { fprintf(stderr, "module runtime: ignored invalid response from %s\n", module->manifest.name); return MODULE_RESPONSE_INVALID; } - publish_posted_message(module, &post); module->invalid_responses = 0; return MODULE_RESPONSE_CONTINUE; } @@ -1091,7 +1142,7 @@ static bool write_module_record(module_process_t *module, const char *record) { static void deliver_message_to_module(module_process_t *module, const message_t *msg, - uint64_t event_id) { + uint64_t event_id, bool verified) { char event[TNT_MODULE_LINE_MAX] = ""; char line[TNT_MODULE_LINE_MAX]; char message_id[64]; @@ -1102,7 +1153,7 @@ static void deliver_message_to_module(module_process_t *module, snprintf(message_id, sizeof(message_id), "local-%llu", (unsigned long long)event_id); if (tnt_module_append_message_created(event, sizeof(event), &pos, - message_id, msg) < 0) { + message_id, msg, verified) < 0) { disable_module(module, "event encoding failure"); return; } @@ -1135,11 +1186,11 @@ static void deliver_message_to_module(module_process_t *module, disable_module(module, "read failure"); return; } - if (module->manifest.can_post_messages && - record_has_type(line, TNT_MODULE_RECORD_MESSAGE_POST)) { + if (is_unsolicited_record(module, line)) { int64_t started = monotonic_millis(); - if (handle_module_post(module, line) == MODULE_RESPONSE_INVALID && + if (handle_module_record(module, line) == + MODULE_RESPONSE_INVALID && !note_invalid_response(module)) { return; } @@ -1181,6 +1232,20 @@ static void deliver_presence_to_module(module_process_t *module, (void)write_module_record(module, record); } +static void deliver_identity_request_to_module(module_process_t *module, + const module_event_t *event) { + char record[TNT_MODULE_LINE_MAX] = ""; + size_t pos = 0; + + if (tnt_module_append_identity_requested(record, sizeof(record), &pos, + event->msg.content, + event->msg.username) < 0) { + disable_module(module, "event encoding failure"); + return; + } + (void)write_module_record(module, record); +} + static void deliver_snapshot_to_module(module_process_t *module) { char (*online)[MAX_USERNAME_LEN] = malloc(sizeof(g_online)); int count; @@ -1231,7 +1296,11 @@ static void deliver_event_to_module(module_process_t *module, switch (event->kind) { case MODULE_EVENT_MESSAGE_CREATED: - deliver_message_to_module(module, &event->msg, event->event_id); + deliver_message_to_module(module, &event->msg, event->event_id, + event->verified); + break; + case MODULE_EVENT_IDENTITY_REQUESTED: + deliver_identity_request_to_module(module, event); break; case MODULE_EVENT_PRESENCE_SNAPSHOT: deliver_snapshot_to_module(module); @@ -1270,7 +1339,7 @@ static void read_unsolicited_record(module_process_t *module, int wait_ms) { disable_module(module, "read failure"); return; } - if (handle_module_post(module, line) == MODULE_RESPONSE_INVALID) { + if (handle_module_record(module, line) == MODULE_RESPONSE_INVALID) { (void)note_invalid_response(module); } } @@ -1325,16 +1394,24 @@ static bool module_is_granted(const char *name) { static bool module_permissions_allowed(module_process_t *module) { const tnt_module_manifest_t *manifest = &module->manifest; - module->privileged = - manifest->can_post_messages || manifest->can_read_presence; - if (!module->privileged || module_is_granted(manifest->name)) { - return true; + module->privileged = manifest->can_post_messages || + manifest->can_read_presence || + manifest->can_write_identity; + if (module->privileged && !module_is_granted(manifest->name)) { + fprintf(stderr, + "module runtime: %s requests message:post, presence:read or " + "identity:write but is not listed in TNT_MODULE_GRANTS\n", + manifest->name); + return false; } - fprintf(stderr, - "module runtime: %s requests message:post or presence:read but " - "is not listed in TNT_MODULE_GRANTS\n", - manifest->name); - return false; + if (manifest->can_write_identity && g_identity_module) { + fprintf(stderr, + "module runtime: %s requests identity:write, which %s " + "already holds\n", + manifest->name, g_identity_module->manifest.name); + return false; + } + return true; } static int load_modules_from_env(void) { @@ -1367,6 +1444,9 @@ static int load_modules_from_env(void) { start_module_process(token, module) == 0) { fprintf(stderr, "module runtime: enabled %s\n", module->manifest.name); + if (module->manifest.can_write_identity) { + g_identity_module = module; + } g_module_count++; } else { fprintf(stderr, "module runtime: failed to enable module at %s\n", @@ -1478,7 +1558,8 @@ int tnt_module_runtime_init(void) { pthread_mutex_lock(&g_dispatch_lock); g_accepting = true; - (void)queue_event_locked(MODULE_EVENT_PRESENCE_SNAPSHOT, NULL, NULL, NULL); + (void)queue_event_locked(MODULE_EVENT_PRESENCE_SNAPSHOT, NULL, false, NULL, + NULL); pthread_mutex_unlock(&g_dispatch_lock); pthread_mutex_unlock(&g_lifecycle_lock); return 0; @@ -1490,6 +1571,7 @@ int tnt_module_runtime_init(void) { close_module_processes(count); pthread_mutex_lock(&g_dispatch_lock); g_module_count = 0; + g_identity_module = NULL; pthread_mutex_unlock(&g_dispatch_lock); pthread_mutex_unlock(&g_lifecycle_lock); return -1; @@ -1510,14 +1592,16 @@ void tnt_module_runtime_shutdown(void) { pthread_mutex_lock(&g_dispatch_lock); g_module_count = 0; + g_identity_module = NULL; g_next_event_id = 0; pthread_mutex_unlock(&g_dispatch_lock); pthread_mutex_unlock(&g_lifecycle_lock); } -void tnt_module_runtime_publish_message_created(const message_t *msg) { +void tnt_module_runtime_publish_message_created(const message_t *msg, + bool verified) { if (msg) { - dispatch_event(MODULE_EVENT_MESSAGE_CREATED, msg, NULL); + dispatch_event(MODULE_EVENT_MESSAGE_CREATED, msg, verified, NULL); } } @@ -1536,9 +1620,31 @@ void tnt_module_runtime_publish_presence(const char *nickname, bool joined) { !atomic_load_explicit(&g_stop_requested, memory_order_acquire)) { (void)queue_event_locked( joined ? MODULE_EVENT_PRESENCE_JOINED : MODULE_EVENT_PRESENCE_LEFT, - &msg, NULL, NULL); + &msg, false, NULL, NULL); + } + pthread_mutex_unlock(&g_dispatch_lock); +} + +bool tnt_module_runtime_request_identity(const char *session, + const char *nickname) { + module_event_t event = { + .kind = MODULE_EVENT_IDENTITY_REQUESTED, + }; + module_queue_push_result_t pushed = MODULE_QUEUE_DROPPED; + + if (!session || session[0] == '\0' || !nickname) { + return false; + } + snprintf(event.msg.username, sizeof(event.msg.username), "%s", nickname); + snprintf(event.msg.content, sizeof(event.msg.content), "%s", session); + + pthread_mutex_lock(&g_dispatch_lock); + if (g_accepting && g_identity_module && + !atomic_load_explicit(&g_stop_requested, memory_order_acquire)) { + pushed = module_queue_push(g_identity_module, &event); } pthread_mutex_unlock(&g_dispatch_lock); + return pushed == MODULE_QUEUE_PUSHED; } #ifdef TNT_TESTING diff --git a/src/ssh_server.c b/src/ssh_server.c index f5d1885..bac709a 100644 --- a/src/ssh_server.c +++ b/src/ssh_server.c @@ -52,6 +52,16 @@ time_t ssh_server_start_time(void) { return g_server_start_time; } +int ssh_server_port(void) { + return g_listen_port; +} + +const char *ssh_server_public_host(void) { + const char *public_host = getenv("TNT_PUBLIC_HOST"); + + return public_host && public_host[0] != '\0' ? public_host : "localhost"; +} + static void session_worker_register(session_worker_t *worker) { pthread_mutex_lock(&g_session_lock); worker->next = g_session_workers; @@ -316,7 +326,7 @@ int ssh_server_init(int port) { /* Start SSH server (blocking) */ int ssh_server_start(int shutdown_fd, const volatile sig_atomic_t *shutdown_requested) { - const char *public_host = getenv("TNT_PUBLIC_HOST"); + const char *public_host = ssh_server_public_host(); pthread_attr_t attr; int result = 0; int listen_fd; @@ -331,10 +341,6 @@ int ssh_server_start(int shutdown_fd, return -1; } - if (!public_host || public_host[0] == '\0') { - public_host = "localhost"; - } - printf("TNT chat server listening on port %d (SSH)\n", g_listen_port); printf("Connect with: ssh -p %d %s\n", g_listen_port, public_host); fflush(stdout); diff --git a/tests/run_suites.sh b/tests/run_suites.sh index 1091ed2..e192724 100755 --- a/tests/run_suites.sh +++ b/tests/run_suites.sh @@ -21,7 +21,8 @@ multiline_input 14 cursor_editing 10 vim_keymap 12 wide_text_view 9 -module_privileged 16" +module_privileged 16 +identity 17" WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-suites.XXXXXX") trap 'rm -rf "$WORK_DIR"' EXIT diff --git a/tests/test_identity.sh b/tests/test_identity.sh new file mode 100755 index 0000000..867405f --- /dev/null +++ b/tests/test_identity.sh @@ -0,0 +1,380 @@ +#!/bin/sh +# Verified identities: :login through an identity:write module, reserved +# names, :logout, SSH key sign-in, verified metadata, and exec post. + +. ./lib.sh + +PORT=${PORT:-12369} +trap tnt_cleanup EXIT + +tnt_skip_without_expect "identity test" +tnt_require_binary +tnt_state_dir identity-test + +echo "=== TNT Identity Tests ===" + +MODULE_DIR="$STATE_DIR/ident" +IDENTITIES="$STATE_DIR/identities" +LOG="$STATE_DIR/messages.log" +TNT_IDENT_EVENTS="$STATE_DIR/ident.events" +KEY="$STATE_DIR/key" +OTHER_KEY="$STATE_DIR/other" +export TNT_IDENT_EVENTS + +ssh-keygen -q -t ed25519 -N "" -C "" -f "$KEY" +ssh-keygen -q -t ed25519 -N "" -C "" -f "$OTHER_KEY" +FINGERPRINT=$(ssh-keygen -lf "$KEY.pub" | cut -d' ' -f2) + +SSH_KEYLESS="$(tnt_ssh_opts "$PORT") -o PubkeyAuthentication=no" +SSH_KEY="$(tnt_ssh_opts "$PORT") -o IdentitiesOnly=yes -i $KEY" +SSH_EXEC="ssh -n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ +-o BatchMode=yes -o LogLevel=ERROR -o IdentitiesOnly=yes -p $PORT" + +# Run expect body $2 as session $1. $3 holds the ssh options and $4 the +# login name; the body starts right after the connection opens. +run_session() { + _script="$STATE_DIR/$1.expect" + cat >"$_script" <"$STATE_DIR/$1.session.log" 2>&1 +} + +# --- Without an identity module ------------------------------------------ + +tnt_start_server "$PORT" "$STATE_DIR" + +if run_session nomodule ' +expect "): " +send -- "bob\r" +expect "/help" +send -- "/login\r" +expect { + "Sign-in is not available on this server." {} + timeout { exit 1 } +} +' "$SSH_KEYLESS"; then + pass ":login without an identity module says sign-in is unavailable" +else + fail ":login without a module" + tnt_dump_session nomodule +fi + +if [ "$(ssh -n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o BatchMode=yes -o PubkeyAuthentication=no -o LogLevel=ERROR \ + -p "$PORT" bob@127.0.0.1 health 2>&1)" = "ok" ]; then + pass "a keyless BatchMode client still connects" +else + fail "a keyless BatchMode client was refused" +fi + +cat >"$STATE_DIR/anon.expect" <"$STATE_DIR/anon.session.log" 2>&1 && + tnt_wait_for_fixed "$LOG" "|system|anonymous2 joined the room"; then + pass "a second blank nickname joins as anonymous2" +else + fail "a second blank nickname did not join" + tnt_dump_session anon +fi + +kill "$SERVER_PID" 2>/dev/null +wait "$SERVER_PID" 2>/dev/null +SERVER_PID="" + +# --- With a fake identity module ----------------------------------------- + +mkdir -p "$MODULE_DIR" +cat >"$MODULE_DIR/tnt-module.json" <<'JSON' +{ + "protocol": "tnt.module.v1", + "name": "ident", + "entrypoint": "./ident.sh", + "permissions": ["message:read", "message:create", "message:post", "identity:write"], + "events": ["message.created"] +} +JSON +cat >"$MODULE_DIR/ident.sh" <<'SH' +#!/bin/sh +while IFS= read -r line; do + printf '%s\n' "$line" >>"$TNT_IDENT_EVENTS" + case "$line" in + *'"type":"handshake"'*) + printf '{"type":"handshake.ok","protocol":"tnt.module.v1"}\n' + printf '{"type":"identity.reserve","handle":"webby"}\n' ;; + *'"type":"identity.requested"'*) + session=$(printf '%s' "$line" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p') + printf '{"type":"identity.notice","session":"%s","plain_text":"Enter code TEST-CODE"}\n' "$session" + printf '{"type":"identity.verified","session":"%s","handle":"alice"}\n' "$session" ;; + *'"type":"message.created"'*'"plain_text":"web hello"'*) + printf '{"type":"event.ok"}\n' ;; + *'"type":"message.created"'*) + printf '{"type":"event.ok"}\n' ;; + esac +done +SH +chmod +x "$MODULE_DIR/ident.sh" + +TNT_MODULE_PATHS="$MODULE_DIR" +export TNT_MODULE_PATHS + +tnt_start_server "$PORT" "$STATE_DIR" +if grep -q "ident requests message:post, presence:read or identity:write" \ + "$STATE_DIR/server.log"; then + pass "an ungranted identity:write module is refused" +else + fail "an ungranted identity:write module was not refused" + sed -n '1,40p' "$STATE_DIR/server.log" +fi +kill "$SERVER_PID" 2>/dev/null +wait "$SERVER_PID" 2>/dev/null +SERVER_PID="" + +SECOND_DIR="$STATE_DIR/second" +mkdir -p "$SECOND_DIR" +sed 's/"name": "ident"/"name": "second"/' "$MODULE_DIR/tnt-module.json" \ + >"$SECOND_DIR/tnt-module.json" +cp "$MODULE_DIR/ident.sh" "$SECOND_DIR/ident.sh" +TNT_MODULE_PATHS="$MODULE_DIR:$SECOND_DIR" +TNT_MODULE_GRANTS="ident:second" +export TNT_MODULE_PATHS TNT_MODULE_GRANTS +tnt_start_server "$PORT" "$STATE_DIR" + +if grep -q "second requests identity:write, which ident already holds" \ + "$STATE_DIR/server.log" && + grep -q "enabled ident" "$STATE_DIR/server.log" && + ! grep -q "enabled second" "$STATE_DIR/server.log"; then + pass "only the first identity:write module is enabled" +else + fail "a second identity:write module was enabled" + sed -n '1,40p' "$STATE_DIR/server.log" +fi + +if tnt_wait_for "$IDENTITIES" "^webby$"; then + pass "identity.reserve adds a handle without a key" +else + fail "identity.reserve did not reach the identities file" + cat "$IDENTITIES" 2>/dev/null +fi + +if [ "$(ls -l "$IDENTITIES" | cut -c1-10)" = "-rw-------" ]; then + pass "the identities file has mode 0600" +else + fail "the identities file mode is wrong" +fi + +# A holds "alice" before it is reserved; B signs in as alice and A steps aside. +cat >"$STATE_DIR/login.expect" <"$STATE_DIR/login.session.log" 2>&1; then + pass ":login shows the module notice and signs the session in" +else + fail ":login flow" + tnt_dump_session login +fi + +if tnt_wait_for_fixed "$LOG" "|system|alice renamed to anonymous" && + tnt_wait_for_fixed "$LOG" "|system|bob renamed to alice"; then + pass "the unverified holder of a handle is renamed when its owner signs in" +else + fail "renames missing" + cat "$LOG" +fi + +if tnt_wait_for_fixed "$TNT_IDENT_EVENTS" '"identity.requested","session":"s-' && + grep -qF '"nickname":"bob"}' "$TNT_IDENT_EVENTS"; then + pass ":login sends identity.requested with the session and nickname" +else + fail "identity.requested is missing" + cat "$TNT_IDENT_EVENTS" +fi + +if tnt_wait_for_fixed "$TNT_IDENT_EVENTS" \ + '"sender":"alice","kind":"text","plain_text":"hello verified","metadata":{"verified":true}'; then + pass "a verified session's messages carry metadata.verified" +else + fail "metadata.verified is missing" + cat "$TNT_IDENT_EVENTS" +fi + +if tnt_wait_for_fixed "$TNT_IDENT_EVENTS" '"plain_text":"after logout","metadata":{}'; then + pass ":logout drops metadata.verified" +else + fail "a signed-out session still looks verified" + cat "$TNT_IDENT_EVENTS" +fi + +if grep -q "^alice$" "$IDENTITIES"; then + pass "a keyless sign-in reserves the handle without a key" +else + fail "the identities file is wrong after a keyless sign-in" + cat "$IDENTITIES" +fi + +if run_session reserved " +expect \"): \" +send -- \"Alice\r\" +expect { + \"@Alice belongs to a signed-in member.\" {} + timeout { exit 1 } +} +send -- \"q\" +sleep 0.3 +send -- \"/nick webby\r\" +expect { + \"@webby belongs to a signed-in member.\" {} + timeout { exit 1 } +} +" "$SSH_KEYLESS"; then + pass "reserved names are refused at the prompt and by :nick" +else + fail "a reserved name was accepted" + tnt_dump_session reserved +fi + +if run_session bindkey ' +expect "): " +send -- "carol\r" +expect "/help" +send -- "/login\r" +expect { + "Signed in as @alice." {} + timeout { exit 1 } +} +' "$SSH_KEY" alice && + tnt_wait_for_fixed "$IDENTITIES" "alice $FINGERPRINT"; then + pass "signing in over a public key binds the key to the handle" +else + fail "the key was not bound" + tnt_dump_session bindkey + cat "$IDENTITIES" +fi + +if run_session otherlogin ' +expect { + "): " {} + "Signed in as" { exit 1 } + timeout { exit 1 } +} +' "$SSH_KEY"; then + pass "a bound key is recognised only under the handle as login name" +else + fail "a bound key was recognised under another login name" + tnt_dump_session otherlogin +fi + +POST_OUTPUT=$($SSH_EXEC -i "$KEY" alice@127.0.0.1 post "from my key" 2>&1) +POST_STATUS=$? +if [ "$POST_STATUS" -eq 0 ] && [ "$POST_OUTPUT" = "posted" ] && + tnt_wait_for_fixed "$TNT_IDENT_EVENTS" \ + '"plain_text":"from my key","metadata":{"verified":true}'; then + pass "exec post under a reserved name works with its key and is verified" +else + fail "exec post with the bound key: $POST_STATUS $POST_OUTPUT" +fi + +POST_OUTPUT=$($SSH_EXEC -i "$OTHER_KEY" Alice@127.0.0.1 post "forged" 2>&1) +POST_STATUS=$? +if [ "$POST_STATUS" -eq 1 ] && + printf '%s' "$POST_OUTPUT" | grep -q "belongs to a signed-in member" && + ! grep -q "forged" "$LOG"; then + pass "exec post under a reserved name is refused without its key" +else + fail "exec post was not refused: $POST_STATUS $POST_OUTPUT" +fi + +if run_session autologin ' +expect { + "): " { exit 1 } + "Signed in as @alice." {} + timeout { exit 1 } +} +send -- "q" +sleep 0.3 +send -- "/logout\r" +expect { + "Signed out. Your name is now" {} + timeout { exit 1 } +} +' "$SSH_KEY" alice; then + pass "a bound key signs in on reconnect without the prompt" +else + fail "automatic sign-in by key" + tnt_dump_session autologin +fi + +if tnt_poll_until 5 sh -c "! grep -qF '$FINGERPRINT' '$IDENTITIES'" && + grep -q "^alice$" "$IDENTITIES"; then + pass ":logout removes the session's key and keeps the handle reserved" +else + fail ":logout left the key bound" + cat "$IDENTITIES" +fi + +if ! grep -q "disabling ident" "$STATE_DIR/server.log"; then + pass "the identity module stays enabled" +else + fail "the identity module was disabled" + sed -n '1,80p' "$STATE_DIR/server.log" +fi + +tnt_check_server_alive "server survived the identity session" +tnt_summary diff --git a/tests/test_module_privileged.sh b/tests/test_module_privileged.sh index f2c081e..0bd9d09 100755 --- a/tests/test_module_privileged.sh +++ b/tests/test_module_privileged.sh @@ -76,7 +76,7 @@ TNT_MODULE_PATHS="$POSTER_DIR:$ECHO_DIR" export TNT_MODULE_PATHS tnt_start_server "$PORT" "$STATE_DIR" -if grep -q "poster requests message:post or presence:read" "$STATE_DIR/server.log" && +if grep -q "poster requests message:post, presence:read or identity:write" "$STATE_DIR/server.log" && ! grep -q "enabled poster" "$STATE_DIR/server.log"; then pass "an ungranted module requesting message:post is refused" else diff --git a/tests/unit/Makefile b/tests/unit/Makefile index e997e0d..3471aa7 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -46,8 +46,9 @@ THEME_SRC = ../../src/theme.c RICHTEXT_SRC = ../../src/richtext.c EDITOR_SRC = ../../src/editor.c KEYMAP_SRC = ../../src/keymap.c +IDENTITY_SRC = ../../src/identity.c -TESTS = test_utf8 test_input_buffer test_json_text test_module_protocol test_module_runtime test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_common test_client_outbox test_config_defaults test_theme test_richtext test_editor test_keymap test_message_log +TESTS = test_utf8 test_input_buffer test_json_text test_module_protocol test_module_runtime test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_common test_client_outbox test_config_defaults test_theme test_richtext test_editor test_keymap test_message_log test_identity .PHONY: all clean run @@ -74,11 +75,14 @@ test_input_buffer: test_input_buffer.c $(INPUT_BUFFER_SRC) $(UTF8_SRC) test_json_text: test_json_text.c $(JSON_TEXT_SRC) $(COMMON_SRC) $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) $(LDLIBS) -test_module_protocol: test_module_protocol.c $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) - $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) $(LDLIBS) +test_module_protocol: test_module_protocol.c $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(IDENTITY_SRC) + $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) -lssh $(LDLIBS) -test_module_runtime: test_module_runtime.c $(MODULE_RUNTIME_SRC) $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CONFIG_DEFAULTS_SRC) - $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) -DTNT_TESTING $(LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) $(LDLIBS) +test_identity: test_identity.c $(IDENTITY_SRC) $(COMMON_SRC) + $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) -lssh $(LDLIBS) + +test_module_runtime: test_module_runtime.c $(MODULE_RUNTIME_SRC) $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CONFIG_DEFAULTS_SRC) $(IDENTITY_SRC) + $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) -DTNT_TESTING $(LDFLAGS) $(PROJECT_LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) -lssh $(LDLIBS) test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) $(LDLIBS) @@ -154,6 +158,9 @@ run: all @echo "=== Running Module Protocol Tests ===" ./test_module_protocol @echo "" + @echo "=== Running Identity Tests ===" + ./test_identity + @echo "" @echo "=== Running Module Runtime Tests ===" ./test_module_runtime @echo "" diff --git a/tests/unit/test_identity.c b/tests/unit/test_identity.c new file mode 100644 index 0000000..8bc0705 --- /dev/null +++ b/tests/unit/test_identity.c @@ -0,0 +1,233 @@ +#include "../../include/identity.h" + +#include +#include +#include +#include +#include +#include + +#define TEST(name) static void test_##name(void) +#define RUN_TEST(name) do { \ + printf("Running %s... ", #name); \ + test_##name(); \ + printf("ok\n"); \ + tests_passed++; \ +} while (0) + +/* Generated with `ssh-keygen -t ed25519`; `ssh-keygen -lf` prints KEY_SHA256. */ +#define KEY_BASE64 \ + "AAAAC3NzaC1lZDI1NTE5AAAAIH8bmE4NEMDW8dBlYJ2ADZ1zmtMmJpNfWMd1yKnpNjtC" +#define KEY_SHA256 "SHA256:UMSdY3XS0sbuWW4MC+rV6SEa0OanWxNxnFV35VM5Sng" +#define OTHER_SHA256 "SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU" + +static int tests_passed = 0; +static char state_dir[PATH_MAX]; + +static void write_text(const char *path, const char *text) { + FILE *fp = fopen(path, "w"); + + assert(fp); + assert(fputs(text, fp) >= 0); + assert(fclose(fp) == 0); +} + +static void read_text(const char *path, char *out, size_t out_size) { + FILE *fp = fopen(path, "r"); + size_t n; + + assert(fp); + n = fread(out, 1, out_size - 1, fp); + out[n] = '\0'; + fclose(fp); +} + +static void state_path(char *out, size_t out_size, const char *name) { + snprintf(out, out_size, "%s/%s", state_dir, name); +} + +TEST(validates_handles) { + assert(identity_handle_valid("alice")); + assert(identity_handle_valid("a_1")); + assert(identity_handle_valid("abcdefghijklmnopqrst")); + assert(!identity_handle_valid("")); + assert(!identity_handle_valid("abcdefghijklmnopqrstu")); + assert(!identity_handle_valid("Alice")); + assert(!identity_handle_valid("al-ice")); + assert(!identity_handle_valid("al ice")); + assert(!identity_handle_valid("用户")); +} + +TEST(validates_fingerprints) { + assert(identity_fingerprint_valid(KEY_SHA256)); + assert(!identity_fingerprint_valid("SHA256:")); + assert(!identity_fingerprint_valid( + "MD5:UMSdY3XS0sbuWW4MC+rV6SEa0OanWxNxnFV35VM5Sng")); + assert(!identity_fingerprint_valid(KEY_SHA256 "=")); + assert(!identity_fingerprint_valid( + "SHA256:UMSdY3XS0sbuWW4MC+rV6SEa0OanWxNxnFV35VM5Sn")); + assert(!identity_fingerprint_valid( + "SHA256:UMSdY3XS0sbuWW4MC-rV6SEa0OanWxNxnFV35VM5Sng")); +} + +TEST(formats_fingerprints_like_ssh_keygen) { + ssh_key key = NULL; + char fingerprint[TNT_IDENTITY_FINGERPRINT_SIZE]; + + assert(ssh_pki_import_pubkey_base64(KEY_BASE64, SSH_KEYTYPE_ED25519, + &key) == SSH_OK); + assert(identity_fingerprint_from_key(key, fingerprint, + sizeof(fingerprint)) == 0); + assert(strcmp(fingerprint, KEY_SHA256) == 0); + assert(identity_fingerprint_from_key(key, fingerprint, 20) < 0); + ssh_key_free(key); +} + +TEST(parses_and_skips_invalid_lines) { + char path[PATH_MAX]; + identity_table_t table; + + state_path(path, sizeof(path), "parse"); + write_text(path, + "alice " KEY_SHA256 " not-a-key\n" + "\n" + "# comment\n" + "Bob\n" + "toolongtoolongtoolong1\n" + "carol\t" OTHER_SHA256 "\n" + "dave " KEY_SHA256 "\n" + "alice " OTHER_SHA256 "\n"); + assert(identity_table_load(&table, path) == 0); + assert(table.count == 3); + assert(strcmp(table.entries[0].handle, "alice") == 0); + assert(table.entries[0].key_count == 1); + assert(strcmp(table.entries[1].handle, "carol") == 0); + assert(table.entries[1].key_count == 1); + assert(strcmp(table.entries[2].handle, "dave") == 0); + assert(table.entries[2].key_count == 0); + assert(identity_table_find(&table, "ALICE") == &table.entries[0]); + assert(identity_table_find(&table, "bob") == NULL); + assert(strcmp(identity_table_handle_for_key(&table, KEY_SHA256), + "alice") == 0); + identity_table_free(&table); +} + +TEST(missing_file_is_empty) { + char path[PATH_MAX]; + identity_table_t table; + + state_path(path, sizeof(path), "missing"); + assert(identity_table_load(&table, path) == 0); + assert(table.count == 0); + identity_table_free(&table); +} + +TEST(adds_moves_and_forgets_keys) { + identity_table_t table = {0}; + + assert(identity_table_add(&table, "alice", KEY_SHA256) == 0); + assert(identity_table_add(&table, "bob", NULL) == 0); + assert(identity_table_add(&table, "Bad", NULL) < 0); + assert(identity_table_add(&table, "bob", "SHA256:x") < 0); + assert(identity_table_add(&table, "bob", KEY_SHA256) == 0); + assert(table.count == 2); + assert(table.entries[0].key_count == 0); + assert(strcmp(identity_table_handle_for_key(&table, KEY_SHA256), + "bob") == 0); + assert(identity_table_forget_key(&table, KEY_SHA256)); + assert(!identity_table_forget_key(&table, KEY_SHA256)); + assert(identity_table_handle_for_key(&table, KEY_SHA256) == NULL); + assert(identity_table_find(&table, "bob") != NULL); + identity_table_free(&table); +} + +TEST(keeps_the_newest_keys) { + identity_table_t table = {0}; + char key[TNT_IDENTITY_FINGERPRINT_SIZE]; + + for (int i = 0; i <= TNT_IDENTITY_MAX_KEYS; i++) { + snprintf(key, sizeof(key), "SHA256:%043d", i); + assert(identity_table_add(&table, "alice", key) == 0); + } + assert(table.entries[0].key_count == TNT_IDENTITY_MAX_KEYS); + snprintf(key, sizeof(key), "SHA256:%043d", 0); + assert(identity_table_handle_for_key(&table, key) == NULL); + snprintf(key, sizeof(key), "SHA256:%043d", TNT_IDENTITY_MAX_KEYS); + assert(identity_table_handle_for_key(&table, key) != NULL); + identity_table_free(&table); +} + +TEST(saves_atomically_with_mode_0600) { + char path[PATH_MAX]; + char text[512]; + identity_table_t table = {0}; + struct stat st; + + state_path(path, sizeof(path), "saved"); + assert(identity_table_add(&table, "alice", KEY_SHA256) == 0); + assert(identity_table_add(&table, "alice", OTHER_SHA256) == 0); + assert(identity_table_add(&table, "bob", NULL) == 0); + assert(identity_table_save(&table, path) == 0); + identity_table_free(&table); + + assert(stat(path, &st) == 0); + assert((st.st_mode & 0777) == 0600); + read_text(path, text, sizeof(text)); + assert(strcmp(text, "alice " KEY_SHA256 " " OTHER_SHA256 "\nbob\n") == 0); +} + +TEST(store_reserves_and_reloads_on_change) { + char path[PATH_MAX]; + char temp[PATH_MAX + 8]; + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + + state_path(path, sizeof(path), TNT_IDENTITY_FILE); + assert(!identity_is_reserved("alice")); + assert(identity_reserve("alice", KEY_SHA256) == 0); + assert(identity_is_reserved("Alice")); + assert(identity_handle_for_key(KEY_SHA256, handle, sizeof(handle))); + assert(strcmp(handle, "alice") == 0); + + assert(identity_forget_key(KEY_SHA256) == 0); + assert(!identity_handle_for_key(KEY_SHA256, handle, sizeof(handle))); + assert(identity_is_reserved("alice")); + + /* Another process rewrites the file the same way TNT does. */ + snprintf(temp, sizeof(temp), "%s.new", path); + write_text(temp, "carol " OTHER_SHA256 "\n"); + assert(rename(temp, path) == 0); + assert(!identity_is_reserved("alice")); + assert(identity_is_reserved("carol")); + assert(identity_handle_for_key(OTHER_SHA256, handle, sizeof(handle))); +} + +int main(void) { + char template[] = "/tmp/tnt-identity-test.XXXXXX"; + + printf("Running identity unit tests...\n\n"); + assert(mkdtemp(template)); + snprintf(state_dir, sizeof(state_dir), "%s", template); + setenv("TNT_STATE_DIR", state_dir, 1); + + RUN_TEST(validates_handles); + RUN_TEST(validates_fingerprints); + RUN_TEST(formats_fingerprints_like_ssh_keygen); + RUN_TEST(parses_and_skips_invalid_lines); + RUN_TEST(missing_file_is_empty); + RUN_TEST(adds_moves_and_forgets_keys); + RUN_TEST(keeps_the_newest_keys); + RUN_TEST(saves_atomically_with_mode_0600); + RUN_TEST(store_reserves_and_reloads_on_change); + + for (size_t i = 0; i < 3; i++) { + static const char *const files[] = {"parse", "saved", + TNT_IDENTITY_FILE}; + char path[PATH_MAX]; + + state_path(path, sizeof(path), files[i]); + unlink(path); + } + rmdir(state_dir); + printf("\nAll %d identity tests passed.\n", tests_passed); + return 0; +} diff --git a/tests/unit/test_module_protocol.c b/tests/unit/test_module_protocol.c index 973bd5b..323a23a 100644 --- a/tests/unit/test_module_protocol.c +++ b/tests/unit/test_module_protocol.c @@ -36,16 +36,86 @@ TEST(appends_message_created_jsonl_with_escaping) { snprintf(msg.content, sizeof(msg.content), "%s", "hello \"core\""); assert(tnt_module_append_message_created(out, sizeof(out), &pos, - "local-1", &msg) == 0); + "local-1", &msg, false) == 0); assert(strstr(out, "\"type\":\"message.created\"") != NULL); assert(strstr(out, "\"id\":\"local-1\"") != NULL); assert(strstr(out, "\"timestamp\":\"1970-01-01T00:00:00Z\"") != NULL); assert(strstr(out, "\"sender\":\"alice\"") != NULL); assert(strstr(out, "\"kind\":\"text\"") != NULL); assert(strstr(out, "\"plain_text\":\"hello \\\"core\\\"\"") != NULL); + assert(strstr(out, "\"metadata\":{}}}\n") != NULL); assert(out[strlen(out) - 1] == '\n'); } +TEST(appends_verified_metadata) { + char out[512] = ""; + size_t pos = 0; + message_t msg = { + .timestamp = 0, + }; + + snprintf(msg.username, sizeof(msg.username), "%s", "alice"); + snprintf(msg.content, sizeof(msg.content), "%s", "hi"); + assert(tnt_module_append_message_created(out, sizeof(out), &pos, + "local-2", &msg, true) == 0); + assert(strstr(out, "\"metadata\":{\"verified\":true}}}\n") != NULL); +} + +TEST(appends_identity_requested_jsonl) { + char out[256] = ""; + size_t pos = 0; + + assert(tnt_module_append_identity_requested(out, sizeof(out), &pos, + "s-7", "al\"ice") == 0); + assert(strcmp(out, "{\"type\":\"identity.requested\",\"session\":\"s-7\"," + "\"nickname\":\"al\\\"ice\"}\n") == 0); +} + +TEST(parse_identity_records) { + tnt_module_identity_record_t record; + + assert(tnt_module_parse_identity_record( + "{\"type\":\"identity.notice\",\"session\":\"s-1\"," + "\"plain_text\":\"Visit https://auth.m1ng.space\\nCode: ABCD\"}", + &record)); + assert(record.kind == TNT_MODULE_IDENTITY_NOTICE); + assert(strcmp(record.session, "s-1") == 0); + assert(strcmp(record.plain_text, + "Visit https://auth.m1ng.space\nCode: ABCD") == 0); + + assert(tnt_module_parse_identity_record( + "{\"type\":\"identity.verified\",\"session\":\"s-1\"," + "\"handle\":\"alice_2\"}", &record)); + assert(record.kind == TNT_MODULE_IDENTITY_VERIFIED); + assert(strcmp(record.handle, "alice_2") == 0); + + assert(tnt_module_parse_identity_record( + "{\"type\":\"identity.reserve\",\"handle\":\"bob\"}", &record)); + assert(record.kind == TNT_MODULE_IDENTITY_RESERVE); + assert(strcmp(record.handle, "bob") == 0); +} + +TEST(parse_identity_records_rejects_invalid) { + tnt_module_identity_record_t record; + + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.verified\",\"session\":\"s-1\"," + "\"handle\":\"Alice\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.verified\",\"handle\":\"alice\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.reserve\"," + "\"handle\":\"abcdefghijklmnopqrstu\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.notice\",\"session\":\"s-1\"," + "\"plain_text\":\"\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.notice\",\"session\":\"s-1\"," + "\"plain_text\":\"\\u001b[2J\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.forget\",\"handle\":\"alice\"}", &record)); +} + TEST(parse_message_create_accepts_valid_plain_text) { tnt_module_message_create_t response; @@ -207,6 +277,10 @@ int main(void) { RUN_TEST(appends_handshake_jsonl); RUN_TEST(appends_message_created_jsonl_with_escaping); + RUN_TEST(appends_verified_metadata); + RUN_TEST(appends_identity_requested_jsonl); + RUN_TEST(parse_identity_records); + RUN_TEST(parse_identity_records_rejects_invalid); RUN_TEST(parse_message_create_accepts_valid_plain_text); RUN_TEST(parse_message_create_accepts_utf8_plain_text); RUN_TEST(parse_message_create_rejects_wrong_type); diff --git a/tests/unit/test_module_runtime.c b/tests/unit/test_module_runtime.c index 12a3f6d..ecbaf80 100644 --- a/tests/unit/test_module_runtime.c +++ b/tests/unit/test_module_runtime.c @@ -47,6 +47,18 @@ void notify_mentions(const char *content, const void *sender) { (void)sender; } +bool input_identity_notice(const char *session, const char *plain_text) { + (void)session; + (void)plain_text; + return false; +} + +bool input_identity_verified(const char *session, const char *handle) { + (void)session; + (void)handle; + return false; +} + static void cleanup_module_dir(void) { if (module_dir[0] == '\0') return; @@ -569,7 +581,7 @@ TEST(presence_saturation_logs_outside_the_notifier) { assert(tnt_module_runtime_init() == 0); assert(dup2(log_fd, STDERR_FILENO) >= 0); - tnt_module_runtime_publish_message_created(&msg); + tnt_module_runtime_publish_message_created(&msg, false); sleep_millis(200); for (int i = 0; i < TNT_MODULE_QUEUE_LIMIT + 72; i++) { snprintf(nickname, sizeof(nickname), "user%d", i); diff --git a/tnt-chat.7 b/tnt-chat.7 index 48b2117..7d33af0 100644 --- a/tnt-chat.7 +++ b/tnt-chat.7 @@ -1,4 +1,4 @@ -.TH TNT-CHAT 7 "2026-09-22" "TNT 1.4.0" +.TH TNT-CHAT 7 "2026-10-14" "TNT 1.5.0" .SH NAME tnt-chat \- use the TNT interactive terminal interface .SH DESCRIPTION @@ -25,7 +25,11 @@ It does not select the interactive nickname. TNT prompts for a nickname after login. An empty or invalid nickname becomes -.BR anonymous . +.BR anonymous , +or the first free +.BR anonymous2 , +.BR anonymous3 , +and so on when that is taken. Nicknames are limited to 20 Unicode characters. They must be unique among active sessions without regard to ASCII letter case. @@ -36,10 +40,62 @@ The names and the .B module: prefix are reserved for internal messages. +A handle of a signed-in member is reserved too; see +.BR "SIGNING IN" . .P If the administrator configured an access token, enter it as the SSH password. The token is shared and does not authenticate the nickname. +.SH SIGNING IN +The room stays anonymous: anyone can connect and pick any free name. +A member of m1ngsama ID can also prove who they are, and their handle then +belongs to them. +.P +.B :login +starts the sign-in when the server runs an identity module. +The module shows what to do next, usually a link and a code. +Once it confirms the member, the session is renamed to the handle and +shows +.BI "Signed in as @" handle . +A session that was using that name without signing in is renamed to +.BR anonymous . +Several sessions may be signed in to the same handle at once. +.P +TNT recognises a member by SSH key only when the SSH login name is their +handle: +.P +.EX +ssh \-p 2222 alice@chat.example.com +.EE +.P +Signing in from such a connection binds the key it authenticated with to +the handle. +From then on, connecting the same way with the same key signs the session +in at once, without the nickname prompt. +After signing in from a connection without a key, TNT says how to connect +next time; run +.B :login +once more from that connection to bind the key. +Under any other login name, nothing about the key is used. +.B :logout +unbinds the key, and the session becomes +.B anonymous +again. +.P +Nobody else can take a reserved handle, at the nickname prompt or with +.BR :nick , +without regard to ASCII letter case. +The prompt falls back to +.BR anonymous , +or to the first free +.BR anonymous2 , +.BR anonymous3 , +and so on, and both say: +.P +.EX +@alice belongs to a signed-in member. +Use :login if it is you, or pick another name. +.EE .SH KEYS Typing inserts at the caret. Nothing a user types moves the session into a different keyboard state. @@ -332,6 +388,18 @@ Clear the inbox and the current reply target. .TQ .BI ":name " name Change the current nickname. +A signed-in session must +.B :logout +first, and a handle another member signed in with is refused. +.TP +.B :login +Sign in with m1ngsama ID. +When the server has no identity module, it says that sign-in is not +available. +.TP +.B :logout +Sign out, unbind this session's SSH key, and become +.BR anonymous . .TP .BI ":last " [count] Display up to @@ -417,8 +485,9 @@ Private messages are not written to .IR messages.log , do not appear in public history or exec output, and are lost at disconnect. .P -Public nicknames are labels, not authenticated identities. -Another session may reuse a nickname after its owner disconnects. +A nickname that is not a signed-in handle is a label, not an identity. +Another session may reuse it after its owner disconnects. +A signed-in handle stays reserved after its owner disconnects or signs out. .P The terminal must support the ANSI control sequences used by the interface. .SH EXAMPLES diff --git a/tnt-exec.7 b/tnt-exec.7 index f655b93..6d7fc75 100644 --- a/tnt-exec.7 +++ b/tnt-exec.7 @@ -1,4 +1,4 @@ -.TH TNT-EXEC 7 "2026-09-22" "TNT 1.4.0" +.TH TNT-EXEC 7 "2026-10-14" "TNT 1.5.0" .SH NAME tnt-exec \- TNT SSH exec command and output protocol .SH DESCRIPTION @@ -166,6 +166,15 @@ otherwise it is .BR anonymous . The displayed name is limited to 20 Unicode characters. .P +A login name that is the handle of a signed-in member, without regard to +ASCII letter case, may post only when the SSH key the connection +authenticated with is bound to that handle; see +.BR tnt\-chat (7). +The message is then posted under the handle. +Otherwise +.B post +fails with status 1 and posts nothing. +.P A message beginning with .B "/me " creates an action. @@ -183,7 +192,8 @@ The command succeeded. .B 1 A runtime, allocation, I/O, or persistence operation failed. .B post -also returns 1 for invalid UTF-8 or a prohibited control character. +also returns 1 for invalid UTF-8, a prohibited control character, or a +reserved login name without its key. .TP .B 64 The command name, argument shape, count, or message length was invalid. @@ -205,7 +215,7 @@ and error wording, is not stable. .SH CAVEATS In anonymous-access mode, the SSH login name used by .B post -is not authenticated. +is not authenticated unless it is a signed-in member's handle. A shared access token authenticates possession of the token. It does not establish ownership of the login name. diff --git a/tnt-message-log.5 b/tnt-message-log.5 index fd751af..d555a2e 100644 --- a/tnt-message-log.5 +++ b/tnt-message-log.5 @@ -1,4 +1,4 @@ -.TH TNT-MESSAGE-LOG 5 "2026-09-22" "TNT 1.4.0" +.TH TNT-MESSAGE-LOG 5 "2026-10-14" "TNT 1.5.0" .SH NAME tnt-message-log \- TNT public message log format .SH DESCRIPTION diff --git a/tnt-module-protocol.7 b/tnt-module-protocol.7 index 97ab7f1..2800d71 100644 --- a/tnt-module-protocol.7 +++ b/tnt-module-protocol.7 @@ -1,4 +1,4 @@ -.TH TNT-MODULE-PROTOCOL 7 "2026-09-22" "TNT 1.4.0" +.TH TNT-MODULE-PROTOCOL 7 "2026-10-14" "TNT 1.5.0" .SH NAME tnt-module-protocol \- implement an external TNT message module .SH DESCRIPTION @@ -9,8 +9,8 @@ The supported protocol identifier is .P Modules receive selected public message events and may create public plain-text messages. -Modules the operator grants may also post as a chat user and observe -presence. +Modules the operator grants may also post as a chat user, observe +presence, and sign sessions in. The core chat path continues when an individual module is invalid, slow, stopped, or saturated. .SH ACTIVATION @@ -28,13 +28,18 @@ directory. No command-line arguments are supplied. .P A module whose manifest requests -.B message:post +.BR message:post , +.BR presence:read , or -.B presence:read +.B identity:write is enabled only when its name appears in .BR TNT_MODULE_GRANTS , a colon-separated list of module names. Otherwise TNT reports the refusal on standard error and skips the module. +At most one enabled module holds +.BR identity:write ; +TNT skips every later module that requests it and says so on standard +error. .SH MANIFEST The manifest is a JSON object of at most 4094 bytes. The following fields are required: @@ -64,9 +69,10 @@ Array containing both and .BR message:create . It may also contain -.B message:post +.BR message:post , +.BR presence:read , and -.BR presence:read ; +.BR identity:write ; see .BR "GRANTED PERMISSIONS" . .TP @@ -159,7 +165,7 @@ This covers chat messages, actions (sender system notices (sender .B system or -.BR \(co\(fc\(cy ), +.BR 系统 ), and replies from other modules (sender .BR module:NAME ). A module never receives the messages it created or posted itself. @@ -186,7 +192,19 @@ String The public plain-text content. .TP .B metadata -An object, currently empty. +An object. +It holds +.B verified +set to +.B true +when the sender proved its identity: a signed-in interactive session, an +exec +.B post +under its handle, or a +.B message.post +from the module holding +.BR identity:write . +Otherwise it is empty. .P For example, the following is one JSON Lines record: .P @@ -273,6 +291,10 @@ It must also not be or its Chinese equivalent; the colon restriction already excludes the .BI module: name form TNT reserves for its own messages. +Unless the module also holds +.BR identity:write , +it must not be a reserved handle either; see +.BR identity:write . .B plain_text follows the .B message.create @@ -320,9 +342,9 @@ for the new one: .P Presence records are notifications and must not be answered. They open no response window. -For a module holding either permission, any record other than a valid +For a module holding any granted permission, any record other than a valid .B message.post -that arrives while no +or identity record that arrives while no .B message.created event is waiting is a protocol failure. Around startup a joined record can name a user the snapshot already listed, @@ -339,6 +361,77 @@ dropped on standard error: .EX module runtime: event queue full for NAME, dropped N presence events .EE +.SS identity:write +The module holding +.B identity:write +signs interactive sessions in to m1ngsama ID on TNT's behalf; TNT itself +makes no network requests. +A signed-in session's nickname is its handle, and handles are reserved +in the +.I identities +file described in +.BR tnt (8). +A handle is 1 through 20 ASCII lowercase letters, digits, and underscores. +.P +When a user runs +.BR :login , +TNT sends the module one notification: +.P +.EX +{"type":"identity.requested","session":"s-1","nickname":"bob"} +.EE +.P +.B session +is an opaque string, unique for the life of the server process, that +names the interactive session. +Like presence records, this notification opens no response window and must +not be answered with +.BR event.ok . +.P +The module may write these records at any time, as with +.BR message.post : +.TP +.B identity.notice +.EX +{"type":"identity.notice","session":"s-1",\c +"plain_text":"Open https://auth.m1ng.space/device"} +.EE +.IP +Show +.B plain_text +to that session alone. +It is not logged or broadcast. +.B plain_text +follows the +.B message.create +rules. +TNT ignores a session that is unknown or has closed. +.TP +.B identity.verified +.EX +{"type":"identity.verified","session":"s-1","handle":"alice"} +.EE +.IP +Sign the session in as +.BR handle . +TNT reserves the handle, binds the SSH key the session authenticated with, +if any, to it and to no other handle, renames the session, and announces +the rename to the room. +Another session using the name without signing in is renamed to +.BR anonymous . +TNT ignores a session that is unknown or has closed. +.TP +.B identity.reserve +.EX +{"type":"identity.reserve","handle":"alice"} +.EE +.IP +Reserve +.B handle +without binding a key, for a member who signed in elsewhere. +.P +An identity record with a missing or invalid member, or an invalid handle, +is a protocol failure like any other invalid response. .SH FAILURE HANDLING A handshake failure skips the affected module. After activation, a write failure, write timeout, read failure, response @@ -414,8 +507,12 @@ modules. .P A module holding .B message:post -can post under any valid nickname, including one an SSH user is using. -Grant it only to a module you trust as much as the server. +can post under any valid nickname, including one an SSH user is using, +except a reserved handle. +A module holding +.B identity:write +can post under, and sign any session in as, any handle. +Grant either only to a module you trust as much as the server. .P The child inherits most of the server environment. TNT removes diff --git a/tnt.8 b/tnt.8 index 2c02816..fc4364f 100644 --- a/tnt.8 +++ b/tnt.8 @@ -1,4 +1,4 @@ -.TH TNT 8 "2026-09-22" "TNT 1.4.0" +.TH TNT 8 "2026-10-14" "TNT 1.5.0" .SH NAME tnt \- run the TNT SSH chat server .SH SYNOPSIS @@ -208,9 +208,19 @@ The default is .TP .B TNT_ACCESS_TOKEN Require this shared token as the SSH password. -When it is unset or empty, passwordless, password, and signed public-key -authentication are accepted. -When it is nonempty, +When it is unset or empty, passwordless, password, signed public-key, and +keyboard-interactive authentication that asks no questions are accepted. +When the SSH login name is a signed-in member's handle, without regard to +ASCII letter case, passwordless authentication is refused so that the client +offers its public key; see +.BR tnt\-chat (7). +A client without a key then gets in through keyboard-interactive or +password authentication, unless +.BR ssh (1) +runs with +.BR BatchMode , +which disables both. +When the token is nonempty, passwordless and public-key authentication are rejected. Only the first 255 bytes are effective. .TP @@ -262,9 +272,10 @@ See .TP .B TNT_MODULE_GRANTS Colon-separated module names allowed to use the -.B message:post +.BR message:post , +.BR presence:read , and -.B presence:read +.B identity:write permissions. A granted module can post under any valid nickname. See @@ -303,6 +314,22 @@ See .I messages.log.1 The previous log produced by automatic rotation. .TP +.I identities +Handles reserved by signed-in members, created with mode 0600 and replaced +atomically. +Each line holds one handle followed by zero or more space-separated SSH key +fingerprints in the form +.B ssh\-keygen \-l +prints: +.IP +.EX +alice SHA256:UMSdY3XS0sbuWW4MC+rV6SEa0OanWxNxnFV35VM5Sng +.EE +.IP +A handle is 1 through 20 ASCII lowercase letters, digits, and underscores; +invalid lines are skipped. +TNT rereads the file when it changes. +.TP .I motd.txt Optional text displayed before the chat screen for each new interactive session. diff --git a/tntctl.1 b/tntctl.1 index cccd8f2..72e0d81 100644 --- a/tntctl.1 +++ b/tntctl.1 @@ -1,4 +1,4 @@ -.TH TNTCTL 1 "2026-09-22" "TNT 1.4.0" +.TH TNTCTL 1 "2026-10-14" "TNT 1.5.0" .SH NAME tntctl \- run TNT exec commands through SSH .SH SYNOPSIS