From ec4a17068293ce300f345cf8bc4683ea917f1c41 Mon Sep 17 00:00:00 2001 From: Didactyl User Date: Thu, 30 Jul 2026 18:16:06 -0400 Subject: [PATCH] v0.2.55 - Fix wizard quit bug, add nostr_index selector, fix relay flapping backoff defeat, add NOSTR_ENABLE_NSIGNER_CLIENT to Dockerfile --- Dockerfile.alpine-musl | 1 + README.md | 4 +- src/config.c | 11 ++++ src/config.h | 1 + src/main.c | 23 ++++++++- src/main.h | 4 +- src/setup_wizard.c | 115 +++++++++++++++++++++++++++++------------ 7 files changed, 121 insertions(+), 38 deletions(-) diff --git a/Dockerfile.alpine-musl b/Dockerfile.alpine-musl index 042defe..45a15d3 100644 --- a/Dockerfile.alpine-musl +++ b/Dockerfile.alpine-musl @@ -112,6 +112,7 @@ RUN NOSTR_LIB=$(ls /build/nostr_core_lib/libnostr_core_*.a 2>/dev/null | head -1 OPENSSL_LIBS="$(pkg-config --static --libs openssl)" && \ gcc -static $CFLAGS -Wall -Wextra -std=c99 \ -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN \ + -DNOSTR_ENABLE_NSIGNER_CLIENT=1 \ -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \ -I. -Isrc -Isrc/tools -Inostr_core_lib -Inostr_core_lib/nostr_core \ -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \ diff --git a/README.md b/README.md index 319cb6a..d27cffb 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers. -## Current Status — v0.2.54 +## Current Status — v0.2.55 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.2.54 — Full n_signer transport support in setup wizard: added nsigner_serial, nsigner_fds, and nsigner_qrexec (Qubes cross-qube) modes. Updated nostr_core_lib to v0.6.10 (qrexec transport). Extended signer_config_t with serial_device, fds_read_fd/fds_write_fd, target_qube, service_name fields. Added --signer-serial, --signer-fds, --signer-qrexec, --signer-service CLI flags. Wizard transport picker now offers all 5 remote transports with auto-discovery for unix/serial. Existing-agent flow supports signer-based identity (no nsec required) with NIP-44 decrypt/encrypt routed through signer verbs. Systemd install supports serial/qrexec; fds rejected with clear message. Connectivity-check errors now pause for Enter before re-rendering. Deleted nostr_core_lib.old. Added docs/SIGNER.md. +> Last release update: v0.2.55 — Fix wizard quit bug, add nostr_index selector, fix relay flapping backoff defeat, add NOSTR_ENABLE_NSIGNER_CLIENT to Dockerfile - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/src/config.c b/src/config.c index 583839c..50aa30c 100644 --- a/src/config.c +++ b/src/config.c @@ -280,6 +280,16 @@ static int parse_signer_config(cJSON* root, didactyl_config_t* config) { } } + /* Optional nostr_index: numeric key index selector. When set (>= 0) it + * overrides role on the n_signer side. -1 (default) means use role. */ + cJSON* nostr_index = cJSON_GetObjectItemCaseSensitive(signer, "nostr_index"); + if (nostr_index && cJSON_IsNumber(nostr_index)) { + int idx = (int)nostr_index->valuedouble; + if (idx >= 0) { + config->signer.nostr_index = idx; + } + } + if (copy_json_string(signer, "auth_privkey_hex", config->signer.auth_privkey_hex, sizeof(config->signer.auth_privkey_hex), 0) != 0) { config_set_error("signer.auth_privkey_hex must be a string when provided"); @@ -1615,6 +1625,7 @@ int config_load(const char* path, didactyl_config_t* config) { snprintf(config->signer.mode, sizeof(config->signer.mode), "%s", "local"); config->signer.socket_name[0] = '\0'; snprintf(config->signer.role, sizeof(config->signer.role), "%s", "main"); + config->signer.nostr_index = -1; /* -1 = unset; use role selector */ config->signer.timeout_ms = 15000; config->signer.auth_privkey_hex[0] = '\0'; config->signer.tcp_host[0] = '\0'; diff --git a/src/config.h b/src/config.h index cca7f7c..a5c8fef 100644 --- a/src/config.h +++ b/src/config.h @@ -149,6 +149,7 @@ typedef struct { char mode[OW_MAX_SIGNER_MODE_LEN]; /* "local" | "nsigner_unix" | "nsigner_tcp" | "nsigner_serial" | "nsigner_fds" | "nsigner_qrexec" */ char socket_name[OW_MAX_SIGNER_SOCKET_LEN]; /* nsigner_unix: abstract socket name (without @); "" = auto-discover */ char role[OW_MAX_SIGNER_ROLE_LEN]; /* n_signer role selector (default "main") */ + int nostr_index; /* n_signer key index selector (-1 = unset; use role). When set, overrides role. */ int timeout_ms; /* per-call timeout (default 15000) */ char auth_privkey_hex[OW_MAX_SIGNER_AUTH_HEX_LEN]; /* optional, TCP auth envelope only */ /* nsigner_tcp parsed host/port (populated from --signer-tcp or config) */ diff --git a/src/main.c b/src/main.c index 1f15ee0..138b8db 100644 --- a/src/main.c +++ b/src/main.c @@ -188,6 +188,8 @@ static void print_usage(const char* prog) { " qube name (service defaults to qubes.NsignerRpc).\n" " --signer-service \n" " qrexec service name for nsigner_qrexec (default: qubes.NsignerRpc).\n" + " --signer-index \n" + " n_signer key index selector (overrides --signer-role). -1 = use role.\n" " --dump-schemas\n" " Print tool schemas JSON and exit.\n" " --test-tool \n" @@ -308,7 +310,8 @@ static int apply_signer_overrides(didactyl_config_t* cfg, const char* cli_serial, const char* cli_fds, const char* cli_qrexec, - const char* cli_service) { + const char* cli_service, + int cli_index) { if (!cfg) { return -1; } @@ -397,6 +400,11 @@ static int apply_signer_overrides(didactyl_config_t* cfg, if (cli_timeout > 0) { cfg->signer.timeout_ms = cli_timeout; } + /* --signer-index overrides the genesis nostr_index. -1 means unset (use role). + * Any non-negative value selects a specific key index on the n_signer side. */ + if (cli_index >= 0) { + cfg->signer.nostr_index = cli_index; + } return 0; } @@ -502,6 +510,14 @@ static nostr_signer_t* construct_signer(didactyl_config_t* cfg) { return NULL; } + /* Apply nostr_index selector when set (overrides role on the n_signer side). */ + if (signer && cfg->signer.nostr_index >= 0) { + if (nostr_signer_nsigner_set_nostr_index(signer, cfg->signer.nostr_index) != NOSTR_SUCCESS) { + fprintf(stderr, "Warning: failed to set nostr_index=%d on signer (mode=%s)\n", + cfg->signer.nostr_index, mode); + } + } + signer_health_set_mode(mode); if (!signer) { const char* sock = cfg->signer.socket_name[0] ? cfg->signer.socket_name : ""; @@ -1586,6 +1602,7 @@ int main(int argc, char** argv) { const char* cli_signer_fds = NULL; /* read_fd:write_fd shorthand */ const char* cli_signer_qrexec = NULL; /* target qube shorthand */ const char* cli_signer_service = NULL; /* qrexec service name */ + int cli_signer_index = -1; /* n_signer key index (-1 = unset) */ didactyl_config_t cfg; memset(&cfg, 0, sizeof(cfg)); nostr_signer_t* g_signer = NULL; @@ -1668,6 +1685,8 @@ int main(int argc, char** argv) { cli_signer_qrexec = argv[++i]; } else if (strcmp(argv[i], "--signer-service") == 0 && i + 1 < argc) { cli_signer_service = argv[++i]; + } else if (strcmp(argv[i], "--signer-index") == 0 && i + 1 < argc) { + cli_signer_index = atoi(argv[++i]); } else if (strcmp(argv[i], "--dump-schemas") == 0) { dump_schemas = 1; } else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) { @@ -1718,7 +1737,7 @@ int main(int argc, char** argv) { cli_signer_role, cli_signer_timeout, cli_signer_tcp, cli_signer_serial, cli_signer_fds, cli_signer_qrexec, - cli_signer_service) != 0) { + cli_signer_service, cli_signer_index) != 0) { config_free(&cfg); nostr_cleanup(); return 1; diff --git a/src/main.h b/src/main.h index 26abb3e..2530082 100644 --- a/src/main.h +++ b/src/main.h @@ -12,8 +12,8 @@ // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define DIDACTYL_VERSION_MAJOR 0 #define DIDACTYL_VERSION_MINOR 2 -#define DIDACTYL_VERSION_PATCH 54 -#define DIDACTYL_VERSION "v0.2.54" +#define DIDACTYL_VERSION_PATCH 55 +#define DIDACTYL_VERSION "v0.2.55" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/setup_wizard.c b/src/setup_wizard.c index ed732b6..46dc59d 100644 --- a/src/setup_wizard.c +++ b/src/setup_wizard.c @@ -95,9 +95,15 @@ static int derive_keys_from_nsec_local(const char* nsec_or_hex, didactyl_config_ static int line_is_quit(const char* s) { if (!s) return 0; + /* Trim leading whitespace. */ while (*s && isspace((unsigned char)*s)) s++; + /* Require an exact match of "q" or "x" (case-insensitive), so values + * like "qubes.NsignerRpc" or "x25519..." are not mistaken for quit. */ char c = (char)tolower((unsigned char)*s); - return c == 'q' || c == 'x'; + if (c != 'q' && c != 'x') return 0; + /* The next character must be end-of-string or whitespace. */ + if (s[1] != '\0' && !isspace((unsigned char)s[1])) return 0; + return 1; } static void trim_line(char* s) { @@ -325,6 +331,7 @@ static void config_set_defaults(didactyl_config_t* cfg) { * signer.mode after the wizard returns BOOTSTRAP for local paths. */ snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "local"); snprintf(cfg->signer.role, sizeof(cfg->signer.role), "%s", "main"); + cfg->signer.nostr_index = -1; /* -1 = unset; use role selector */ cfg->signer.timeout_ms = 15000; } @@ -1274,6 +1281,9 @@ static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg, cJSON_AddStringToObject(signer_obj, "mode", cfg->signer.mode); cJSON_AddStringToObject(signer_obj, "socket_name", cfg->signer.socket_name); cJSON_AddStringToObject(signer_obj, "role", cfg->signer.role); + if (cfg->signer.nostr_index >= 0) { + cJSON_AddNumberToObject(signer_obj, "nostr_index", cfg->signer.nostr_index); + } cJSON_AddNumberToObject(signer_obj, "timeout_ms", cfg->signer.timeout_ms); cJSON_AddItemToObject(user_settings, "signer", signer_obj); } @@ -2095,35 +2105,45 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c * runtime-only); rejected by the caller before reaching here. * The agent pubkey, admin, LLM and relays are recovered from Nostr at boot. */ char key_args[768] = {0}; + /* Build the optional --signer-index tail once; appended to all remote modes. */ + char index_tail[32] = {0}; + if (cfg->signer.nostr_index >= 0) { + snprintf(index_tail, sizeof(index_tail), " --signer-index %d", cfg->signer.nostr_index); + } + if (strcmp(cfg->signer.mode, "nsigner_unix") == 0) { snprintf(key_args, sizeof(key_args), - "--signer nsigner_unix --signer-role %s --signer-timeout %d%s%s", + "--signer nsigner_unix --signer-role %s --signer-timeout %d%s%s%s", cfg->signer.role[0] ? cfg->signer.role : "main", cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000, cfg->signer.socket_name[0] ? " --signer-socket " : "", - cfg->signer.socket_name[0] ? cfg->signer.socket_name : ""); + cfg->signer.socket_name[0] ? cfg->signer.socket_name : "", + index_tail); } else if (strcmp(cfg->signer.mode, "nsigner_tcp") == 0) { char host_port[OW_MAX_SIGNER_HOST_LEN + 16] = {0}; snprintf(host_port, sizeof(host_port), "%s:%d", cfg->signer.tcp_host, cfg->signer.tcp_port); snprintf(key_args, sizeof(key_args), - "--signer-tcp %s --signer-role %s --signer-timeout %d", + "--signer-tcp %s --signer-role %s --signer-timeout %d%s", host_port, cfg->signer.role[0] ? cfg->signer.role : "main", - cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000); + cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000, + index_tail); } else if (strcmp(cfg->signer.mode, "nsigner_serial") == 0) { snprintf(key_args, sizeof(key_args), - "--signer nsigner_serial --signer-serial %s --signer-role %s --signer-timeout %d", + "--signer nsigner_serial --signer-serial %s --signer-role %s --signer-timeout %d%s", cfg->signer.serial_device, cfg->signer.role[0] ? cfg->signer.role : "main", - cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000); + cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000, + index_tail); } else if (strcmp(cfg->signer.mode, "nsigner_qrexec") == 0) { const char* svc = cfg->signer.service_name[0] ? cfg->signer.service_name : "qubes.NsignerRpc"; snprintf(key_args, sizeof(key_args), - "--signer nsigner_qrexec --signer-qrexec %s --signer-service %s --signer-role %s --signer-timeout %d", + "--signer nsigner_qrexec --signer-qrexec %s --signer-service %s --signer-role %s --signer-timeout %d%s", cfg->signer.target_qube, svc, cfg->signer.role[0] ? cfg->signer.role : "main", - cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000); + cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000, + index_tail); } else if (strcmp(cfg->signer.mode, "nsigner_fds") == 0) { /* Defensive: callers reject this before install, but guard anyway. */ fprintf(stderr, "%snsigner_fds mode cannot be installed as a systemd service " @@ -2292,6 +2312,11 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign char c = read_menu_choice(opts, 7); if (c == 'q') return -1; if (c == 'b') return 1; + if (c == '\0') { + fprintf(stderr, "%sUnknown option. Please pick one of u/t/s/f/e/b/q.%s\n", + ANSI_RED, ANSI_RESET); + continue; + } char chosen_mode[OW_MAX_SIGNER_MODE_LEN] = {0}; @@ -2412,24 +2437,26 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign if (c == 'e') { snprintf(chosen_mode, sizeof(chosen_mode), "%s", "nsigner_qrexec"); char qube[WIZARD_LINE_MAX] = {0}; - if (read_line_prompt(" Target qube (e.g. nostr_signer): ", qube, sizeof(qube)) != 0) return -1; + if (read_line_prompt(" Target qube [nostr_signer]: ", qube, sizeof(qube)) != 0) return -1; if (line_is_quit(qube)) return -1; - if (qube[0] == '\0') { - fprintf(stderr, "%sA target qube name is required.%s\n", ANSI_RED, ANSI_RESET); - continue; - } - snprintf(cfg->signer.target_qube, sizeof(cfg->signer.target_qube), "%s", qube); + snprintf(cfg->signer.target_qube, sizeof(cfg->signer.target_qube), "%s", qube[0] ? qube : "nostr_signer"); char svc[WIZARD_LINE_MAX] = {0}; if (read_line_prompt(" qrexec service [qubes.NsignerRpc]: ", svc, sizeof(svc)) != 0) return -1; if (line_is_quit(svc)) return -1; snprintf(cfg->signer.service_name, sizeof(cfg->signer.service_name), "%s", svc[0] ? svc : "qubes.NsignerRpc"); } - /* Common: role + timeout. */ - char role_buf[OW_MAX_SIGNER_ROLE_LEN] = {0}; - if (read_line_prompt(" Role [main]: ", role_buf, sizeof(role_buf)) != 0) return -1; - if (line_is_quit(role_buf)) return -1; - snprintf(cfg->signer.role, sizeof(cfg->signer.role), "%s", role_buf[0] ? role_buf : "main"); + if (chosen_mode[0] == '\0') { + /* No transport matched (should not happen after the '\0' guard + * above, but defensive). */ + fprintf(stderr, "%sNo transport selected.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + + /* Common: key index + timeout. The key index selects which managed + * key n_signer uses (default 0). Role is left empty since the + * nostr_signer qube addresses keys by index. */ + cfg->signer.role[0] = '\0'; char timeout_buf[32] = {0}; if (read_line_prompt(" Timeout ms [15000]: ", timeout_buf, sizeof(timeout_buf)) != 0) return -1; @@ -2437,10 +2464,24 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign cfg->signer.timeout_ms = (timeout_buf[0] != '\0') ? atoi(timeout_buf) : 15000; if (cfg->signer.timeout_ms <= 0) cfg->signer.timeout_ms = 15000; + char index_buf[32] = {0}; + if (read_line_prompt(" Key index [0]: ", index_buf, sizeof(index_buf)) != 0) return -1; + if (line_is_quit(index_buf)) return -1; + cfg->signer.nostr_index = (index_buf[0] != '\0') ? atoi(index_buf) : 0; + if (cfg->signer.nostr_index < 0) { + fprintf(stderr, "%sIndex must be >= 0; using 0.%s\n", ANSI_RED, ANSI_RESET); + cfg->signer.nostr_index = 0; + } + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", chosen_mode); /* Connectivity check. */ - fprintf(stderr, " Checking connectivity to n_signer (mode=%s)...\n", chosen_mode); + fprintf(stderr, " Checking connectivity to n_signer (mode=%s, target=%s, service=%s, index=%d)...\n", + chosen_mode, + cfg->signer.target_qube[0] ? cfg->signer.target_qube : "", + cfg->signer.service_name[0] ? cfg->signer.service_name : "", + cfg->signer.nostr_index); + fflush(stderr); #if defined(NOSTR_ENABLE_NSIGNER_CLIENT) nostr_signer_t* signer = NULL; if (strcmp(chosen_mode, "nsigner_unix") == 0) { @@ -2467,6 +2508,11 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign cfg->signer.role, cfg->signer.timeout_ms); } + /* Apply nostr_index selector when set (overrides role on the n_signer side). */ + if (signer && cfg->signer.nostr_index >= 0) { + (void)nostr_signer_nsigner_set_nostr_index(signer, cfg->signer.nostr_index); + } + if (!signer) { fprintf(stderr, "%sFailed to initialize n_signer client (mode=%s).%s\n", ANSI_RED, chosen_mode, ANSI_RESET); @@ -2514,34 +2560,39 @@ static nostr_signer_t* wizard_construct_ephemeral_signer(const didactyl_config_t if (!cfg) return NULL; #if defined(NOSTR_ENABLE_NSIGNER_CLIENT) const char* mode = cfg->signer.mode; + nostr_signer_t* s = NULL; if (strcmp(mode, "nsigner_unix") == 0) { - return nostr_signer_nsigner_unix(cfg->signer.socket_name[0] ? cfg->signer.socket_name : NULL, - cfg->signer.role, cfg->signer.timeout_ms); + s = nostr_signer_nsigner_unix(cfg->signer.socket_name[0] ? cfg->signer.socket_name : NULL, + cfg->signer.role, cfg->signer.timeout_ms); } else if (strcmp(mode, "nsigner_tcp") == 0) { if (cfg->signer.tcp_host[0] == '\0' || cfg->signer.tcp_port <= 0) return NULL; - nostr_signer_t* s = nostr_signer_nsigner_tcp(cfg->signer.tcp_host, cfg->signer.tcp_port, - cfg->signer.role, cfg->signer.timeout_ms); + s = nostr_signer_nsigner_tcp(cfg->signer.tcp_host, cfg->signer.tcp_port, + cfg->signer.role, cfg->signer.timeout_ms); if (s && cfg->signer.auth_privkey_hex[0] != '\0') { unsigned char auth_key[32]; if (nostr_hex_to_bytes(cfg->signer.auth_privkey_hex, auth_key, 32) == 0) { (void)nostr_signer_nsigner_set_auth(s, auth_key, "didactyl"); } } - return s; } else if (strcmp(mode, "nsigner_serial") == 0) { if (cfg->signer.serial_device[0] == '\0') return NULL; - return nostr_signer_nsigner_serial(cfg->signer.serial_device, - cfg->signer.role, cfg->signer.timeout_ms); + s = nostr_signer_nsigner_serial(cfg->signer.serial_device, + cfg->signer.role, cfg->signer.timeout_ms); } else if (strcmp(mode, "nsigner_fds") == 0) { if (cfg->signer.fds_read_fd < 0 || cfg->signer.fds_write_fd < 0) return NULL; - return nostr_signer_nsigner_fds(cfg->signer.fds_read_fd, cfg->signer.fds_write_fd, - cfg->signer.role, cfg->signer.timeout_ms); + s = nostr_signer_nsigner_fds(cfg->signer.fds_read_fd, cfg->signer.fds_write_fd, + cfg->signer.role, cfg->signer.timeout_ms); } else if (strcmp(mode, "nsigner_qrexec") == 0) { if (cfg->signer.target_qube[0] == '\0') return NULL; const char* svc = cfg->signer.service_name[0] ? cfg->signer.service_name : "qubes.NsignerRpc"; - return nostr_signer_nsigner_qrexec(cfg->signer.target_qube, svc, - cfg->signer.role, cfg->signer.timeout_ms); + s = nostr_signer_nsigner_qrexec(cfg->signer.target_qube, svc, + cfg->signer.role, cfg->signer.timeout_ms); } + /* Apply nostr_index selector when set (overrides role on the n_signer side). */ + if (s && cfg->signer.nostr_index >= 0) { + (void)nostr_signer_nsigner_set_nostr_index(s, cfg->signer.nostr_index); + } + return s; #else (void)cfg; #endif