diff --git a/docs/DDIAL.md b/docs/DDIAL.md index 18f9b28..c8bbe12 100644 --- a/docs/DDIAL.md +++ b/docs/DDIAL.md @@ -181,6 +181,25 @@ and name keeps its original id. Pending requests are stored in `ddial_station_requests.dat` next to the member files and rewritten atomically whenever they change. +### Outbound request tracking + +Operators can submit the same request without putting `@request:` in the +reconnecting relay configuration: + +- `/ddial request ` opens a separate one-shot + connection, waits for the password prompt, sends the request and closes. + It does not change the configured DDial relay or trigger its reconnect + loop. +- `/ddial request-status` shows the tracked remote request id, station name + and hub address. +- `/ddial request-cancel` clears the local tracking record. It does not + delete the request from the remote hub; that hub's operator must deny it. + +The last outbound request is stored in `ddial_outbound_request.dat` next to +the member files and restored by reading that file. A new request requires +cancelling the previous local tracking first. After approval, use the issued +key with `/ddial connect` or `CHATTER_DDIAL_KEY` as usual. + Keys are stored as PBKDF2-SHA256 hashes in `ddial_station_keys.tsv` next to the member files (mode 0600). diff --git a/src/host/core/command_messages.c b/src/host/core/command_messages.c index 33ee1df..5d0e186 100644 --- a/src/host/core/command_messages.c +++ b/src/host/core/command_messages.c @@ -1743,6 +1743,150 @@ static const session_command_message_t kSessionCommandMessages[] = { [SESSION_UI_LANGUAGE_PL] = "Zatwierdzono zgłoszenie stacji #%u. Klucz stacji #%u dla „%s”: %s (widoczny tylko raz; przekaż go operatorowi tej stacji)", }, }, + { + "No outbound station request is being tracked.", + { + [SESSION_UI_LANGUAGE_KO] = "추적 중인 발신 스테이션 요청이 없습니다.", + [SESSION_UI_LANGUAGE_JP] = "追跡中の送信ステーション要求はありません。", + [SESSION_UI_LANGUAGE_ZH] = "没有正在跟踪的出站站点请求。", + [SESSION_UI_LANGUAGE_RU] = "Исходящий запрос станции не отслеживается.", + [SESSION_UI_LANGUAGE_DE] = "Keine ausgehende Stationsanfrage wird verfolgt.", + [SESSION_UI_LANGUAGE_FR] = "Aucune demande de station sortante n'est suivie.", + [SESSION_UI_LANGUAGE_PL] = "Nie śledzono wychodzącego zgłoszenia stacji.", + }, + }, + { + "Could not read outbound station request tracking.", + { + [SESSION_UI_LANGUAGE_KO] = "발신 스테이션 요청 추적 정보를 읽지 못했습니다.", + [SESSION_UI_LANGUAGE_JP] = "送信ステーション要求の追跡情報を読めませんでした。", + [SESSION_UI_LANGUAGE_ZH] = "无法读取出站站点请求跟踪信息。", + [SESSION_UI_LANGUAGE_RU] = "Не удалось прочитать данные отслеживания исходящего запроса.", + [SESSION_UI_LANGUAGE_DE] = "Ausgehende Stationsanfrage konnte nicht gelesen werden.", + [SESSION_UI_LANGUAGE_FR] = "Impossible de lire le suivi de la demande sortante.", + [SESSION_UI_LANGUAGE_PL] = "Nie udało się odczytać śledzenia wychodzącego zgłoszenia.", + }, + }, + { + "Tracked outbound station request:", + { + [SESSION_UI_LANGUAGE_KO] = "추적 중인 발신 스테이션 요청:", + [SESSION_UI_LANGUAGE_JP] = "追跡中の送信ステーション要求:", + [SESSION_UI_LANGUAGE_ZH] = "正在跟踪的出站站点请求:", + [SESSION_UI_LANGUAGE_RU] = "Отслеживаемый исходящий запрос станции:", + [SESSION_UI_LANGUAGE_DE] = "Verfolgte ausgehende Stationsanfrage:", + [SESSION_UI_LANGUAGE_FR] = "Demande de station sortante suivie :", + [SESSION_UI_LANGUAGE_PL] = "Śledzone wychodzące zgłoszenie stacji:", + }, + }, + { + "Could not save outbound station request tracking.", + { + [SESSION_UI_LANGUAGE_KO] = "발신 스테이션 요청 추적 정보를 저장하지 못했습니다.", + [SESSION_UI_LANGUAGE_JP] = "送信ステーション要求の追跡情報を保存できませんでした。", + [SESSION_UI_LANGUAGE_ZH] = "无法保存出站站点请求跟踪信息。", + [SESSION_UI_LANGUAGE_RU] = "Не удалось сохранить данные отслеживания исходящего запроса.", + [SESSION_UI_LANGUAGE_DE] = "Ausgehende Stationsanfrage konnte nicht gespeichert werden.", + [SESSION_UI_LANGUAGE_FR] = "Impossible d'enregistrer le suivi de la demande sortante.", + [SESSION_UI_LANGUAGE_PL] = "Nie udało się zapisać śledzenia wychodzącego zgłoszenia.", + }, + }, + { + "Outbound request tracking cleared; the remote hub request is unchanged.", + { + [SESSION_UI_LANGUAGE_KO] = "발신 요청 추적을 지웠습니다. 원격 허브의 요청은 변경되지 않습니다.", + [SESSION_UI_LANGUAGE_JP] = "送信要求の追跡を消去しました。リモートハブ側の要求は変更されません。", + [SESSION_UI_LANGUAGE_ZH] = "已清除出站请求跟踪;远程中心上的请求保持不变。", + [SESSION_UI_LANGUAGE_RU] = "Локальное отслеживание очищено; запрос на удалённом хабе не изменён.", + [SESSION_UI_LANGUAGE_DE] = "Lokale Anfragenverfolgung gelöscht; die Anfrage beim entfernten Hub bleibt unverändert.", + [SESSION_UI_LANGUAGE_FR] = "Suivi local effacé ; la demande sur le hub distant reste inchangée.", + [SESSION_UI_LANGUAGE_PL] = "Wyczyszczono lokalne śledzenie; zgłoszenie w zdalnym koncentratorze pozostaje bez zmian.", + }, + }, + { + "Usage: /ddial request ", + { + [SESSION_UI_LANGUAGE_KO] = "사용법: /ddial request <호스트> <포트> <스테이션 이름>", + [SESSION_UI_LANGUAGE_JP] = "使い方: /ddial request <ホスト> <ポート> <ステーション名>", + [SESSION_UI_LANGUAGE_ZH] = "用法:/ddial request <主机> <端口> <站点名称>", + [SESSION_UI_LANGUAGE_RU] = "Использование: /ddial request <хост> <порт> <имя станции>", + [SESSION_UI_LANGUAGE_DE] = "Verwendung: /ddial request ", + [SESSION_UI_LANGUAGE_FR] = "Utilisation : /ddial request ", + [SESSION_UI_LANGUAGE_PL] = "Użycie: /ddial request ", + }, + }, + { + "Station request #%u recorded at %s:%lu for '%s'.", + { + [SESSION_UI_LANGUAGE_KO] = "스테이션 요청 #%u을(를) %s:%lu에 '%s' 이름으로 등록했습니다.", + [SESSION_UI_LANGUAGE_JP] = "ステーション要求 #%u を %s:%lu に '%s' として登録しました。", + [SESSION_UI_LANGUAGE_ZH] = "站点请求 #%u 已在 %s:%lu 为 '%s' 登记。", + [SESSION_UI_LANGUAGE_RU] = "Запрос станции #%u зарегистрирован на %s:%lu для «%s».", + [SESSION_UI_LANGUAGE_DE] = "Stationsanfrage #%u bei %s:%lu für „%s“ registriert.", + [SESSION_UI_LANGUAGE_FR] = "Demande de station #%u enregistrée sur %s:%lu pour « %s ».", + [SESSION_UI_LANGUAGE_PL] = "Zarejestrowano zgłoszenie stacji #%u w %s:%lu dla „%s”.", + }, + }, + { + "Remote station request #%u was recorded, but local tracking could not be saved.", + { + [SESSION_UI_LANGUAGE_KO] = "원격 스테이션 요청 #%u은(는) 등록됐지만 로컬 추적 정보를 저장하지 못했습니다.", + [SESSION_UI_LANGUAGE_JP] = "リモートステーション要求 #%u は登録されましたが、ローカル追跡情報を保存できませんでした。", + [SESSION_UI_LANGUAGE_ZH] = "远程站点请求 #%u 已登记,但无法保存本地跟踪信息。", + [SESSION_UI_LANGUAGE_RU] = "Запрос #%u зарегистрирован удалённо, но локальное отслеживание не сохранено.", + [SESSION_UI_LANGUAGE_DE] = "Entfernte Stationsanfrage #%u registriert, lokale Verfolgung konnte nicht gespeichert werden.", + [SESSION_UI_LANGUAGE_FR] = "Demande distante #%u enregistrée, mais le suivi local n'a pas pu être sauvegardé.", + [SESSION_UI_LANGUAGE_PL] = "Zdalne zgłoszenie #%u zapisano, ale lokalnego śledzenia nie udało się zapisać.", + }, + }, + { + "An outbound station request is already tracked; check request-status or cancel it first.", + { + [SESSION_UI_LANGUAGE_KO] = "이미 추적 중인 발신 요청이 있습니다. request-status를 확인하거나 먼저 취소하세요.", + [SESSION_UI_LANGUAGE_JP] = "すでに送信要求を追跡中です。request-status を確認するか、先に取り消してください。", + [SESSION_UI_LANGUAGE_ZH] = "已有一个正在跟踪的出站请求;请先查看 request-status 或取消它。", + [SESSION_UI_LANGUAGE_RU] = "Исходящий запрос уже отслеживается; проверьте request-status или сначала отмените его.", + [SESSION_UI_LANGUAGE_DE] = "Eine ausgehende Anfrage wird bereits verfolgt; request-status prüfen oder zuerst abbrechen.", + [SESSION_UI_LANGUAGE_FR] = "Une demande sortante est déjà suivie ; consultez request-status ou annulez-la d'abord.", + [SESSION_UI_LANGUAGE_PL] = "Wychodzące zgłoszenie jest już śledzone; sprawdź request-status lub najpierw je anuluj.", + }, + }, + { + "Another outbound station request is already in progress.", + { + [SESSION_UI_LANGUAGE_KO] = "다른 발신 스테이션 요청이 이미 진행 중입니다.", + [SESSION_UI_LANGUAGE_JP] = "別の送信ステーション要求がすでに進行中です。", + [SESSION_UI_LANGUAGE_ZH] = "另一个出站站点请求已在处理中。", + [SESSION_UI_LANGUAGE_RU] = "Другой исходящий запрос станции уже выполняется.", + [SESSION_UI_LANGUAGE_DE] = "Eine andere ausgehende Stationsanfrage läuft bereits.", + [SESSION_UI_LANGUAGE_FR] = "Une autre demande de station sortante est déjà en cours.", + [SESSION_UI_LANGUAGE_PL] = "Inne wychodzące zgłoszenie stacji jest już w toku.", + }, + }, + { + "Could not read or save outbound station request tracking.", + { + [SESSION_UI_LANGUAGE_KO] = "발신 스테이션 요청 추적 정보를 읽거나 저장하지 못했습니다.", + [SESSION_UI_LANGUAGE_JP] = "送信ステーション要求の追跡情報を読み書きできませんでした。", + [SESSION_UI_LANGUAGE_ZH] = "无法读取或保存出站站点请求跟踪信息。", + [SESSION_UI_LANGUAGE_RU] = "Не удалось прочитать или сохранить отслеживание исходящего запроса.", + [SESSION_UI_LANGUAGE_DE] = "Ausgehende Stationsanfrage konnte nicht gelesen oder gespeichert werden.", + [SESSION_UI_LANGUAGE_FR] = "Impossible de lire ou d'enregistrer le suivi de la demande sortante.", + [SESSION_UI_LANGUAGE_PL] = "Nie udało się odczytać lub zapisać śledzenia wychodzącego zgłoszenia.", + }, + }, + { + "Could not register the request with the remote station hub.", + { + [SESSION_UI_LANGUAGE_KO] = "원격 스테이션 허브에 요청을 등록하지 못했습니다.", + [SESSION_UI_LANGUAGE_JP] = "リモートステーションハブに要求を登録できませんでした。", + [SESSION_UI_LANGUAGE_ZH] = "无法在远程站点中心登记请求。", + [SESSION_UI_LANGUAGE_RU] = "Не удалось зарегистрировать запрос на удалённом хабе.", + [SESSION_UI_LANGUAGE_DE] = "Anfrage konnte beim entfernten Stations-Hub nicht registriert werden.", + [SESSION_UI_LANGUAGE_FR] = "Impossible d'enregistrer la demande sur le hub distant.", + [SESSION_UI_LANGUAGE_PL] = "Nie udało się zarejestrować zgłoszenia w zdalnym koncentratorze.", + }, + }, }; // Returns the message in the session's UI language, or the English text when diff --git a/src/host/core/help_entries.c b/src/host/core/help_entries.c index 21eccb4..d75e76c 100644 --- a/src/host/core/help_entries.c +++ b/src/host/core/help_entries.c @@ -2305,6 +2305,45 @@ static const session_help_entry_t kSessionHelpOperator[] = { .label_arg_count = 0U, .label_args = {}, }, + { + .kind = SESSION_HELP_ENTRY_COMMAND, + .label = "ddial |request-status|request-cancel>", + .description = + { + "Submit a one-shot station request without changing the DDial " + "relay. request-status shows saved tracking; request-cancel " + "clears only local tracking, not the remote request (operator " + "only).", + "DDial 중계 설정을 바꾸지 않고 스테이션 요청을 한 번 별냅니다. " + "request-status는 저장된 추적 정보를 보여주고, request-cancel은 " + "원격 요청이 아닌 로컬 추적만 지웁니다 (운영자 전용).", + "DDial 中継を変更せずにステーション要求を一度だけ送信します。" + "request-status は保存した追跡情報を表示し、request-cancel は" + "リモート要求ではなくローカル追跡だけを消去します" + "(オペレーター専用)。", + "在不更改 DDial 中继的情况下发送一次性站点请求。" + "request-status 显示已保存的跟踪信息;request-cancel 仅清除" + "本地跟踪,不删除远程请求(仅限管理员)。", + "Отправить одноразовый запрос станции без изменения ретрансляции " + "DDial. request-status показывает сохранённое отслеживание; " + "request-cancel очищает только локальное отслеживание, не " + "удалённый запрос (только оператор).", + "Einmalige Stationsanfrage senden, ohne das DDial-Relay zu " + "ändern. request-status zeigt die gespeicherte Verfolgung; " + "request-cancel löscht nur die lokale Verfolgung, nicht die " + "entfernte Anfrage (nur Operatoren).", + "Envoyer une demande de station unique sans modifier le relais " + "DDial. request-status affiche le suivi enregistré ; " + "request-cancel efface uniquement le suivi local, pas la " + "demande distante (opérateur uniquement).", + "Wyślij jednorazowe zgłoszenie stacji bez zmiany przekaźnika " + "DDial. request-status pokazuje zapisane śledzenie; " + "request-cancel czyści tylko lokalne śledzenie, nie zdalne " + "zgłoszenie (tylko operator).", + }, + .label_arg_count = 0U, + .label_args = {}, + }, { .kind = SESSION_HELP_ENTRY_COMMAND, .label = "resetpw ", diff --git a/src/host/ddial/request.c b/src/host/ddial/request.c new file mode 100644 index 0000000..b9890ca --- /dev/null +++ b/src/host/ddial/request.c @@ -0,0 +1,404 @@ +/** + * @file request.c + * @desc One-shot outbound station requests and local request tracking. + */ + +#include "ssh_chatter/ddial_protocol.h" +#include "ssh_chatter/host.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DDIAL_OUTBOUND_REQUEST_TIMEOUT_SEC 10 +#define DDIAL_OUTBOUND_REQUEST_HOST_LEN 256U + +typedef struct ddial_outbound_request { + char host[DDIAL_OUTBOUND_REQUEST_HOST_LEN]; + int port; + char name[DDIAL_MAX_HANDLE_LEN]; + unsigned remote_id; + int64_t created; +} ddial_outbound_request_t; + +typedef enum ddial_outbound_request_result { + DDIAL_OUTBOUND_REQUEST_OK = 1, + DDIAL_OUTBOUND_REQUEST_NOT_FOUND = 0, + DDIAL_OUTBOUND_REQUEST_PENDING_EXISTS = 2, + DDIAL_OUTBOUND_REQUEST_BUSY = 3, + DDIAL_OUTBOUND_REQUEST_NETWORK_ERROR = 4, + DDIAL_OUTBOUND_REQUEST_PROTOCOL_ERROR = 5, + DDIAL_OUTBOUND_REQUEST_STORAGE_ERROR = 6, + DDIAL_OUTBOUND_REQUEST_INVALID = 7, +} ddial_outbound_request_result_t; + +static pthread_mutex_t g_ddial_outbound_request_lock = PTHREAD_MUTEX_INITIALIZER; +static bool g_ddial_outbound_request_busy = false; + +static bool ddial_outbound_request_path(host_t *host, char *path, size_t cap) +{ + if (host == nullptr || path == nullptr || cap == 0U) { + return false; + } + const char *root = + host->user_data_root[0] != '\0' ? host->user_data_root : "."; + const char *override = getenv("CHATTER_DDIAL_MEMBERS_DIR"); + if (override != nullptr && override[0] != '\0') { + root = override; + } + int written = snprintf(path, cap, "%s/ddial_outbound_request.dat", root); + return written >= 0 && (size_t)written < cap; +} + +static int ddial_outbound_request_load(host_t *host, + ddial_outbound_request_t *out) +{ + char path[PATH_MAX]; + if (!ddial_outbound_request_path(host, path, sizeof(path))) { + return -1; + } + FILE *fp = fopen(path, "r"); + if (fp == nullptr) { + return errno == ENOENT ? 0 : -1; + } + char line[512]; + ddial_outbound_request_t record; + memset(&record, 0, sizeof(record)); + bool found = false; + bool valid = true; + while (fgets(line, sizeof(line), fp) != nullptr) { + line[strcspn(line, "\r\n")] = '\0'; + if (line[0] == '#' || line[0] == '\0') { + continue; + } + char *fields[5] = {nullptr}; + char *save = nullptr; + size_t count = 0U; + for (char *tok = strtok_r(line, "\t", &save); + tok != nullptr && count < 5U; + tok = strtok_r(nullptr, "\t", &save)) { + fields[count++] = tok; + } + if (count != 5U) { + valid = false; + continue; + } + snprintf(record.host, sizeof(record.host), "%s", fields[0]); + record.port = (int)strtol(fields[1], nullptr, 10); + snprintf(record.name, sizeof(record.name), "%s", fields[2]); + record.remote_id = (unsigned)strtoul(fields[3], nullptr, 10); + record.created = strtoll(fields[4], nullptr, 10); + found = record.host[0] != '\0' && record.name[0] != '\0' && + record.port > 0 && record.port <= 65535 && + record.remote_id > 0U; + valid = found; + break; + } + fclose(fp); + if (!valid) { + return -1; + } + if (found && out != nullptr) { + *out = record; + } + return found ? 1 : 0; +} + +static bool ddial_outbound_request_save(host_t *host, + const ddial_outbound_request_t *record) +{ + char path[PATH_MAX]; + if (!ddial_outbound_request_path(host, path, sizeof(path))) { + return false; + } + char temp_path[PATH_MAX]; + int written = snprintf(temp_path, sizeof(temp_path), "%s.tmp", path); + if (written < 0 || (size_t)written >= sizeof(temp_path)) { + return false; + } + int fd = open(temp_path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, + S_IRUSR | S_IWUSR); + if (fd < 0) { + return false; + } + FILE *fp = fdopen(fd, "w"); + if (fp == nullptr) { + close(fd); + unlink(temp_path); + return false; + } + bool ok = fprintf(fp, "# outbound station request v1\n") > 0; + if (ok && record != nullptr) { + ok = fprintf(fp, "%s\t%d\t%s\t%u\t%lld\n", record->host, record->port, + record->name, record->remote_id, + (long long)record->created) > 0; + } + if (fflush(fp) != 0 || fsync(fileno(fp)) != 0) { + ok = false; + } + if (fclose(fp) != 0) { + ok = false; + } + if (!ok || rename(temp_path, path) != 0) { + unlink(temp_path); + return false; + } + return true; +} + +static int ddial_outbound_request_connect(const char *host, int port) +{ + char port_text[16]; + snprintf(port_text, sizeof(port_text), "%d", port); + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo *addresses = nullptr; + if (getaddrinfo(host, port_text, &hints, &addresses) != 0) { + return -1; + } + + int connected_fd = -1; + for (struct addrinfo *address = addresses; + address != nullptr && connected_fd < 0; address = address->ai_next) { + int fd = socket(address->ai_family, address->ai_socktype, + address->ai_protocol); + if (fd < 0) { + continue; + } + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) { + close(fd); + continue; + } + if (connect(fd, address->ai_addr, address->ai_addrlen) == 0) { + connected_fd = fd; + break; + } + if (errno != EINPROGRESS) { + close(fd); + continue; + } + struct pollfd pfd = {.fd = fd, .events = POLLOUT}; + int ready; + do { + ready = poll(&pfd, 1, DDIAL_OUTBOUND_REQUEST_TIMEOUT_SEC * 1000); + } while (ready < 0 && errno == EINTR); + int error = 0; + socklen_t error_len = sizeof(error); + if (ready > 0 && + getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &error_len) == 0 && + error == 0) { + connected_fd = fd; + break; + } + close(fd); + } + freeaddrinfo(addresses); + return connected_fd; +} + +static const char *ddial_outbound_request_find(const char *data, size_t len, + const char *marker) +{ + size_t marker_len = strlen(marker); + if (marker_len == 0U || len < marker_len) { + return nullptr; + } + for (size_t i = 0U; i + marker_len <= len; ++i) { + if (memcmp(data + i, marker, marker_len) == 0) { + return data + i; + } + } + return nullptr; +} + +static bool ddial_outbound_request_read_until(int fd, const char *marker, + char *buffer, size_t cap, + size_t *out_len) +{ + size_t used = 0U; + time_t deadline = time(nullptr) + DDIAL_OUTBOUND_REQUEST_TIMEOUT_SEC; + while (time(nullptr) <= deadline) { + if (ddial_outbound_request_find(buffer, used, marker) != nullptr) { + *out_len = used; + return true; + } + time_t remaining = deadline - time(nullptr); + if (remaining < 0) { + break; + } + struct pollfd pfd = {.fd = fd, .events = POLLIN}; + int ready = poll(&pfd, 1, (int)(remaining + 1) * 1000); + if (ready < 0 && errno == EINTR) { + continue; + } + if (ready <= 0) { + break; + } + if (used == cap) { + return false; + } + ssize_t n = recv(fd, buffer + used, cap - used, 0); + if (n < 0 && (errno == EINTR || errno == EAGAIN || + errno == EWOULDBLOCK)) { + continue; + } + if (n <= 0) { + break; + } + used += (size_t)n; + } + if (ddial_outbound_request_find(buffer, used, marker) != nullptr) { + *out_len = used; + return true; + } + return false; +} + +static ddial_outbound_request_result_t +host_ddial_outbound_request_submit(host_t *host, const char *remote_host, + int port, const char *station_name, + unsigned *out_remote_id) +{ + if (host == nullptr || remote_host == nullptr || remote_host[0] == '\0' || + station_name == nullptr || port <= 0 || port > 65535 || + strpbrk(remote_host, "\t\r\n") != nullptr) { + return DDIAL_OUTBOUND_REQUEST_INVALID; + } + char clean_name[DDIAL_MAX_HANDLE_LEN]; + snprintf(clean_name, sizeof(clean_name), "%s", station_name); + for (char *p = clean_name; *p != '\0'; ++p) { + if (*p == '\t' || *p == '\r' || *p == '\n') { + *p = ' '; + } + } + trim_whitespace_inplace(clean_name); + ddial_outbound_request_t record; + memset(&record, 0, sizeof(record)); + if (ddial_sanitize_handle(clean_name, strlen(clean_name), record.name, + sizeof(record.name)) == 0U) { + return DDIAL_OUTBOUND_REQUEST_INVALID; + } + + pthread_mutex_lock(&g_ddial_outbound_request_lock); + if (g_ddial_outbound_request_busy) { + pthread_mutex_unlock(&g_ddial_outbound_request_lock); + return DDIAL_OUTBOUND_REQUEST_BUSY; + } + int loaded = ddial_outbound_request_load(host, nullptr); + if (loaded > 0) { + pthread_mutex_unlock(&g_ddial_outbound_request_lock); + return DDIAL_OUTBOUND_REQUEST_PENDING_EXISTS; + } + if (loaded < 0) { + pthread_mutex_unlock(&g_ddial_outbound_request_lock); + return DDIAL_OUTBOUND_REQUEST_STORAGE_ERROR; + } + g_ddial_outbound_request_busy = true; + pthread_mutex_unlock(&g_ddial_outbound_request_lock); + + ddial_outbound_request_result_t result = + DDIAL_OUTBOUND_REQUEST_NETWORK_ERROR; + unsigned remote_id = 0U; + int fd = ddial_outbound_request_connect(remote_host, port); + if (fd >= 0) { + char buffer[2048]; + size_t response_len = 0U; + if (ddial_outbound_request_read_until(fd, "Enter Password", buffer, + sizeof(buffer), &response_len)) { + char wire[96]; + int wire_len = snprintf(wire, sizeof(wire), "@request:%s\r\n", + record.name); + if (wire_len > 0 && (size_t)wire_len < sizeof(wire) && + ddial_client_send_all(fd, wire, (size_t)wire_len) >= 0 && + ddial_outbound_request_read_until( + fd, "Station link request #", buffer, sizeof(buffer), + &response_len)) { + const char *response = ddial_outbound_request_find( + buffer, response_len, "Station link request #"); + char line[128]; + size_t remaining = response_len - (size_t)(response - buffer); + size_t copy = remaining < sizeof(line) - 1U + ? remaining + : sizeof(line) - 1U; + memcpy(line, response, copy); + line[copy] = '\0'; + if (sscanf(line, "Station link request #%u recorded", + &remote_id) == 1 && + remote_id > 0U) { + result = DDIAL_OUTBOUND_REQUEST_OK; + } else { + result = DDIAL_OUTBOUND_REQUEST_PROTOCOL_ERROR; + } + } else { + result = DDIAL_OUTBOUND_REQUEST_PROTOCOL_ERROR; + } + } + close(fd); + } + + pthread_mutex_lock(&g_ddial_outbound_request_lock); + g_ddial_outbound_request_busy = false; + if (result == DDIAL_OUTBOUND_REQUEST_OK) { + snprintf(record.host, sizeof(record.host), "%s", remote_host); + record.port = port; + record.remote_id = remote_id; + record.created = (int64_t)time(nullptr); + if (!ddial_outbound_request_save(host, &record)) { + result = DDIAL_OUTBOUND_REQUEST_STORAGE_ERROR; + } + } + pthread_mutex_unlock(&g_ddial_outbound_request_lock); + if (remote_id > 0U && out_remote_id != nullptr) { + *out_remote_id = remote_id; + } + return result; +} + +static ddial_outbound_request_result_t +host_ddial_outbound_request_status(host_t *host, char *out, size_t cap) +{ + if (out == nullptr || cap == 0U) { + return DDIAL_OUTBOUND_REQUEST_INVALID; + } + out[0] = '\0'; + pthread_mutex_lock(&g_ddial_outbound_request_lock); + ddial_outbound_request_t record; + int loaded = ddial_outbound_request_load(host, &record); + if (loaded > 0) { + snprintf(out, cap, "#%u %s (%s:%d)", record.remote_id, record.name, + record.host, record.port); + } + pthread_mutex_unlock(&g_ddial_outbound_request_lock); + return loaded > 0 ? DDIAL_OUTBOUND_REQUEST_OK + : loaded == 0 ? DDIAL_OUTBOUND_REQUEST_NOT_FOUND + : DDIAL_OUTBOUND_REQUEST_STORAGE_ERROR; +} + +static ddial_outbound_request_result_t +host_ddial_outbound_request_cancel(host_t *host) +{ + pthread_mutex_lock(&g_ddial_outbound_request_lock); + int loaded = ddial_outbound_request_load(host, nullptr); + ddial_outbound_request_result_t result = DDIAL_OUTBOUND_REQUEST_NOT_FOUND; + if (loaded > 0) { + result = ddial_outbound_request_save(host, nullptr) + ? DDIAL_OUTBOUND_REQUEST_OK + : DDIAL_OUTBOUND_REQUEST_STORAGE_ERROR; + } else if (loaded < 0) { + result = DDIAL_OUTBOUND_REQUEST_STORAGE_ERROR; + } + pthread_mutex_unlock(&g_ddial_outbound_request_lock); + return result; +} diff --git a/src/host/runtime/user_commands.c b/src/host/runtime/user_commands.c index 659aa01..46fe0e6 100644 --- a/src/host/runtime/user_commands.c +++ b/src/host/runtime/user_commands.c @@ -1070,7 +1070,7 @@ static void session_handle_ddial(session_ctx_t *ctx, const char *arguments) relay->port); session_send_system_line(ctx, status); session_send_system_line( - ctx, "Usage: /ddial [handle] [password]|raw |reconnect|disconnect|status|key|hub|requests|approve |deny >"); + ctx, "Usage: /ddial [handle] [password]|raw |reconnect|disconnect|status|key|hub|requests|approve |deny |request |request-status|request-cancel>"); return; } @@ -1088,17 +1088,123 @@ static void session_handle_ddial(session_ctx_t *ctx, const char *arguments) return; } - /* Station hub: requests and keys for inbound station links. */ + /* Station hub: outbound requests, inbound requests and link keys. */ if (strcasecmp(action, "key") == 0 || strcasecmp(action, "hub") == 0 || strcasecmp(action, "requests") == 0 || strcasecmp(action, "approve") == 0 || - strcasecmp(action, "deny") == 0) { + strcasecmp(action, "deny") == 0 || + strcasecmp(action, "request") == 0 || + strcasecmp(action, "request-status") == 0 || + strcasecmp(action, "request-cancel") == 0) { if (!ctx->user.is_operator) { session_send_system_line( ctx, session_command_localize( ctx, "Only operators can manage DDial station links.")); return; } + if (strcasecmp(action, "request-status") == 0) { + char listing[SSH_CHATTER_MESSAGE_LIMIT]; + ddial_outbound_request_result_t result = + host_ddial_outbound_request_status(ctx->owner, listing, + sizeof(listing)); + if (result == DDIAL_OUTBOUND_REQUEST_NOT_FOUND) { + session_send_system_line( + ctx, session_command_localize( + ctx, "No outbound station request is being tracked.")); + } else if (result != DDIAL_OUTBOUND_REQUEST_OK) { + session_send_system_line( + ctx, session_command_localize( + ctx, "Could not read outbound station request tracking.")); + } else { + session_send_system_line( + ctx, session_command_localize( + ctx, "Tracked outbound station request:")); + session_send_system_line(ctx, listing); + } + return; + } + if (strcasecmp(action, "request-cancel") == 0) { + ddial_outbound_request_result_t result = + host_ddial_outbound_request_cancel(ctx->owner); + if (result == DDIAL_OUTBOUND_REQUEST_NOT_FOUND) { + session_send_system_line( + ctx, session_command_localize( + ctx, "No outbound station request is being tracked.")); + } else if (result != DDIAL_OUTBOUND_REQUEST_OK) { + session_send_system_line( + ctx, session_command_localize( + ctx, "Could not save outbound station request tracking.")); + } else { + session_send_system_line( + ctx, session_command_localize( + ctx, "Outbound request tracking cleared; the remote hub request is unchanged.")); + } + return; + } + if (strcasecmp(action, "request") == 0) { + static const char *kUsage = + "Usage: /ddial request "; + char remote_host[256]; + char port_text[16]; + const char *name = session_consume_token(rest, remote_host, + sizeof(remote_host)); + name = session_consume_token(name, port_text, sizeof(port_text)); + char *end = nullptr; + unsigned long port = strtoul(port_text, &end, 10); + while (name != nullptr && (*name == ' ' || *name == '\t')) { + ++name; + } + if (remote_host[0] == '\0' || port_text[0] == '\0' || + end == nullptr || *end != '\0' || port == 0UL || + port > 65535UL || name == nullptr || name[0] == '\0') { + session_send_system_line( + ctx, session_command_localize(ctx, kUsage)); + return; + } + + unsigned remote_id = 0U; + ddial_outbound_request_result_t result = + host_ddial_outbound_request_submit(ctx->owner, remote_host, + (int)port, name, + &remote_id); + if (result == DDIAL_OUTBOUND_REQUEST_OK || + (result == DDIAL_OUTBOUND_REQUEST_STORAGE_ERROR && + remote_id > 0U)) { + char line[SSH_CHATTER_MESSAGE_LIMIT]; + if (result == DDIAL_OUTBOUND_REQUEST_OK) { + session_command_snprintf( + ctx, line, sizeof(line), + "Station request #%u recorded at %s:%lu for '%s'.", + remote_id, remote_host, port, name); + } else { + session_command_snprintf( + ctx, line, sizeof(line), + "Remote station request #%u was recorded, but local tracking could not be saved.", + remote_id); + } + session_send_system_line(ctx, line); + } else if (result == DDIAL_OUTBOUND_REQUEST_PENDING_EXISTS) { + session_send_system_line( + ctx, session_command_localize( + ctx, "An outbound station request is already tracked; check request-status or cancel it first.")); + } else if (result == DDIAL_OUTBOUND_REQUEST_BUSY) { + session_send_system_line( + ctx, session_command_localize( + ctx, "Another outbound station request is already in progress.")); + } else if (result == DDIAL_OUTBOUND_REQUEST_INVALID) { + session_send_system_line( + ctx, session_command_localize(ctx, kUsage)); + } else if (result == DDIAL_OUTBOUND_REQUEST_STORAGE_ERROR) { + session_send_system_line( + ctx, session_command_localize( + ctx, "Could not read or save outbound station request tracking.")); + } else { + session_send_system_line( + ctx, session_command_localize( + ctx, "Could not register the request with the remote station hub.")); + } + return; + } if (strcasecmp(action, "hub") == 0) { char listing[SSH_CHATTER_MESSAGE_LIMIT * 2U]; size_t count = @@ -1400,7 +1506,7 @@ static void session_handle_ddial(session_ctx_t *ctx, const char *arguments) session_send_system_line(ctx, "Unknown /ddial subcommand."); session_send_system_line( - ctx, "Usage: /ddial [handle] [password]|raw |reconnect|disconnect|status|key|hub|requests|approve |deny >"); + ctx, "Usage: /ddial [handle] [password]|raw |reconnect|disconnect|status|key|hub|requests|approve |deny |request |request-status|request-cancel>"); } void session_handle_ddial_slash(session_ctx_t *ctx, const char *line) diff --git a/src/host/transport.c b/src/host/transport.c index 62b71a0..c3ca56d 100644 --- a/src/host/transport.c +++ b/src/host/transport.c @@ -18,6 +18,7 @@ #include "ddial/display.c" #include "ddial/magviz.c" #include "ddial/client.c" +#include "ddial/request.c" #include "ddial/inject.c" #include "ddial/integration.c" #include "ddial/mailbridge.c" diff --git a/tests/unit/test_ddial_hub.c b/tests/unit/test_ddial_hub.c index 10f8875..b7c23cc 100644 --- a/tests/unit/test_ddial_hub.c +++ b/tests/unit/test_ddial_hub.c @@ -709,6 +709,114 @@ static bool history_contains(const char *text, uint64_t *id_out) return found; } +TEST(outbound_request_client_tracks_cancels_and_resubmits) +{ + int port = free_port(); + char spec[32]; + snprintf(spec, sizeof(spec), "127.0.0.1:%d", port); + setenv("CHATTER_DDIAL_HUB_LISTEN", spec, 1); + setenv("CHATTER_DDIAL_STATION", "RequestTestHub", 1); + host_ddial_hub_start(g_host); + int ready = connect_loopback(port); + CHECK(ready >= 0); + close(ready); + + char relay_host[sizeof(g_host->ddial_relay.host)]; + snprintf(relay_host, sizeof(relay_host), "%s", g_host->ddial_relay.host); + int relay_port = g_host->ddial_relay.port; + bool relay_enabled = g_host->ddial_relay.enabled; + + char output[16384]; + CHECK(command_output("/ddial request-status", output, sizeof(output))); + CHECK(strstr(output, "Only operators can manage DDial station links.") != + NULL); + CHECK(command_output_as("/ddial request-status", true, output, + sizeof(output))); + CHECK(strstr(output, "No outbound station request is being tracked.") != + NULL); + CHECK(command_output_as("/ddial request 127.0.0.1 nope Bad", true, output, + sizeof(output))); + CHECK(strstr(output, + "Usage: /ddial request ") != NULL); + + char command[512]; + snprintf(command, sizeof(command), + "/ddial request 127.0.0.1 %d Outbound One", port); + CHECK(command_output_as(command, true, output, sizeof(output))); + char *recorded = strstr(output, "Station request #"); + CHECK(recorded != NULL); + unsigned remote_id = 0U; + CHECK_EQ(sscanf(recorded, "Station request #%u", &remote_id), 1); + CHECK(remote_id > 0U); + CHECK(strstr(output, "for 'Outbound One'.") != NULL); + + char request_listing[4096]; + CHECK_EQ(host_ddial_hub_request_list(g_host, request_listing, + sizeof(request_listing)), + 1U); + CHECK(strstr(request_listing, "Outbound One [127.0.0.1]") != NULL); + + CHECK(command_output_as("/ddial request-status", true, output, + sizeof(output))); + char expected[512]; + snprintf(expected, sizeof(expected), "#%u Outbound One (127.0.0.1:%d)", + remote_id, port); + CHECK(strstr(output, expected) != NULL); + + host_t *restarted = new_host(); + CHECK(restarted != NULL); + char tracking[1024]; + CHECK_EQ(host_ddial_outbound_request_status(restarted, tracking, + sizeof(tracking)), + DDIAL_OUTBOUND_REQUEST_OK); + CHECK_STR_EQ(tracking, expected); + free(restarted); + + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s/ddial_outbound_request.dat", g_dir); + struct stat st; + CHECK_EQ(stat(path, &st), 0); + CHECK_EQ(st.st_mode & 0777, 0600); + FILE *fp = fopen(path, "r"); + CHECK(fp != NULL); + char contents[2048] = {0}; + size_t n = fread(contents, 1U, sizeof(contents) - 1U, fp); + fclose(fp); + contents[n] = '\0'; + CHECK(strstr(contents, "127.0.0.1") != NULL); + CHECK(strstr(contents, "Outbound One") != NULL); + + CHECK(command_output_as(command, true, output, sizeof(output))); + CHECK(strstr(output, "An outbound station request is already tracked") != + NULL); + CHECK_STR_EQ(g_host->ddial_relay.host, relay_host); + CHECK_EQ(g_host->ddial_relay.port, relay_port); + CHECK_EQ(g_host->ddial_relay.enabled, relay_enabled); + + CHECK(command_output_as("/ddial request-cancel", true, output, + sizeof(output))); + CHECK(strstr(output, "Outbound request tracking cleared") != NULL); + CHECK(command_output_as("/ddial request-status", true, output, + sizeof(output))); + CHECK(strstr(output, "No outbound station request is being tracked.") != + NULL); + CHECK_EQ(host_ddial_hub_request_list(g_host, request_listing, + sizeof(request_listing)), + 1U); + + CHECK(command_output_as(command, true, output, sizeof(output))); + recorded = strstr(output, "Station request #"); + CHECK(recorded != NULL); + unsigned resubmitted_id = 0U; + CHECK_EQ(sscanf(recorded, "Station request #%u", &resubmitted_id), 1); + CHECK_EQ(resubmitted_id, remote_id); + CHECK(command_output_as("/ddial request-cancel", true, output, + sizeof(output))); + CHECK_EQ(host_ddial_hub_request_deny(g_host, remote_id), + DDIAL_HUB_REQUEST_OK); + host_ddial_hub_stop(g_host); +} + TEST(station_links_with_key_and_relays_chat) { int port = free_port(); @@ -973,6 +1081,10 @@ static void remove_dir_files(const char *dir) unlink(path); snprintf(path, sizeof(path), "%s/ddial_station_requests.dat.tmp", dir); unlink(path); + snprintf(path, sizeof(path), "%s/ddial_outbound_request.dat", dir); + unlink(path); + snprintf(path, sizeof(path), "%s/ddial_outbound_request.dat.tmp", dir); + unlink(path); rmdir(dir); } @@ -1001,6 +1113,7 @@ int main(void) RUN_TEST(station_key_create_match_revoke); RUN_TEST(station_request_workflow_persists_and_manages_requests); RUN_TEST(failed_logins_block_an_address); + RUN_TEST(outbound_request_client_tracks_cancels_and_resubmits); RUN_TEST(station_links_with_key_and_relays_chat); RUN_TEST(scrollback_entry_visibility_check); RUN_TEST(scrollback_skips_consecutive_noise_entries);