Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions MAINTAINERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion include/chat_room.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;

Expand All @@ -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 */
Expand All @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions include/command_catalog.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions include/commands.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion include/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
10 changes: 10 additions & 0 deletions include/i18n.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
62 changes: 62 additions & 0 deletions include/identity.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#ifndef IDENTITY_H
#define IDENTITY_H

#include "common.h"
#include <libssh/libssh.h>

#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 */
5 changes: 5 additions & 0 deletions include/input.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
26 changes: 25 additions & 1 deletion include/module_protocol.h
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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];
Expand All @@ -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,
Expand All @@ -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 */
9 changes: 8 additions & 1 deletion include/module_runtime.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion include/ssh_server.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#define SSH_SERVER_H

#include "common.h"
#include "identity.h"
#include "keymap.h"
#include "chat_room.h"
#include <arpa/inet.h>
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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 */
6 changes: 3 additions & 3 deletions packaging/arch/.SRCINFO
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
4 changes: 2 additions & 2 deletions packaging/arch/PKGBUILD
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Maintainer: M1ng <contact@m1ng.space>

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')
Expand All @@ -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() {
Expand Down
12 changes: 12 additions & 0 deletions packaging/debian/debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <contact@m1ng.space> 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
Expand Down
4 changes: 2 additions & 2 deletions packaging/homebrew/tnt-chat.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading