diff --git a/plans/interactive_transport_selection.md b/plans/interactive_transport_selection.md new file mode 100644 index 0000000..af6619a --- /dev/null +++ b/plans/interactive_transport_selection.md @@ -0,0 +1,143 @@ +# Plan: Interactive multi-transport selection at startup + +Status: design / ready for review. + +Related: +- [`README.md`](../README.md) §7 Transport, §8.3 Qubes OS qube, §9 Usage +- [`src/main.c`](../src/main.c) — argument parsing, main loop, TUI +- [`src/server.c`](../src/server.c) — `server_ctx_t`, `server_start`, `server_handle_one` +- [`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md) — bridge design + +--- + +## 1. Motivation + +The current CLI has many transport flags (`--listen`, `--socket-name`, `--bridge-source-trusted`, `--auth`, `--allow-all`) that the user must know in advance. For interactive use, this is unfriendly — the user just wants to answer "how should other programs reach this signer?" + +Additionally, the user may want **multiple transports active simultaneously** (e.g. unix socket for local clients + TCP for FIPS mesh clients + qrexec bridge for other qubes). Today only one `--listen` mode is supported at a time. + +--- + +## 2. Design + +### 2.1 Interactive transport menu at startup + +After mnemonic entry and before the running phase, if no `--listen` flag was given (i.e. interactive mode), present a multi-select menu: + +```text +Transport — how should other programs reach this signer? +Select one or more (space to toggle, enter to confirm): + + [x] 1. Local Unix socket (same machine/qube) + [ ] 2. Qubes qrexec bridge (other qubes via qrexec, no network) + [ ] 3. TCP listener (FIPS mesh or local network) + [ ] 4. Qrexec one-shot (legacy, one request per invocation) + + [a] select all + + (at least one must be selected) +``` + +Each selected transport gets configured with sensible defaults: + +| Choice | What it starts | Defaults | +|---|---|---| +| 1. Unix socket | `--listen unix` | socket name `nsigner` (or random if collision) | +| 2. Qrexec bridge | `--listen unix --bridge-source-trusted` | socket name `nsigner`, accepts qrexec preamble | +| 3. TCP | `--listen tcp:[::]:8080` | bind all interfaces, port 8080 | +| 4. Qrexec one-shot | `--listen qrexec` | one request via stdin, then exit | + +**Choices 1 and 2 can coexist** — they're both unix listeners, just with different identity handling. Choice 2 implies choice 1's socket. If both are selected, the bridge-source-trusted flag is set on the single unix listener (it handles both local and bridge connections). + +**Choice 3 (TCP) can coexist with 1+2** — it's a separate listener on a different fd. + +**Choice 4 (qrexec one-shot) is mutually exclusive** with all others — it uses stdin/stdout and exits after one request. If selected alone, it runs the one-shot path. If selected with others, it's ignored with a warning (or: the user is told it can't combine). + +### 2.2 Multi-listener architecture + +Currently `main()` creates one `server_ctx_t` and polls `server.listen_fd` + `STDIN_FILENO`. To support multiple listeners: + +- Create an array of `server_ctx_t` (up to 3: unix, tcp, and optionally a second unix for bridge — though 1+2 collapse into one). +- Each `server_ctx_t` gets its own `server_start()`. +- The poll loop expands to poll all listener fds + STDIN_FILENO. +- When a listener fd has activity, call `server_handle_one()` on that context. +- The TUI status display shows all active transports. + +```text +Connections +listen: unix @nsigner (bridge-source-trusted) +listen: tcp [::]:8080 +client: nsigner --socket-name nsigner client '' +``` + +### 2.3 Coexistence with CLI flags + +- If `--listen` is given on the command line, **skip the interactive menu** (automation/scripting path unchanged). +- If no `--listen` is given and stdin is a TTY, **show the interactive menu**. +- If no `--listen` is given and stdin is NOT a TTY, **default to unix socket** (current behavior, for `--mnemonic-stdin` / `--mnemonic-fd` supervised launches). + +This preserves backward compatibility: all existing flags and scripts work unchanged. The menu is purely an interactive convenience. + +### 2.4 TUI implementation + +The menu uses the existing TUI rendering helpers (`tui_render_content_screen`, `tui_print`). It appears after mnemonic acceptance and before the status display. Navigation: + +- `1`-`4` or space: toggle a choice +- `a`: select all (except 4, which is mutually exclusive) +- `a`: select all (except 4, which is mutually exclusive) +- `enter`: confirm and proceed to running phase (at least one must be selected) + +After confirmation, the selected listeners are started and the status display renders. + +### 2.5 Security considerations + +- The menu is purely a UX layer — it doesn't change the security model. Each transport still goes through the same policy/approval pipeline. +- `--bridge-source-trusted` is only set if the user explicitly selects the qrexec bridge option. It's never silently enabled. +- TCP listener still requires auth envelopes (existing behavior). +- The menu doesn't offer `--allow-all`; that remains a CLI flag for users who want it. + +--- + +## 3. Implementation checklist + +### 3.1 Multi-listener support in `main.c` +- [ ] Replace single `server_ctx_t server` with an array (e.g. `server_ctx_t servers[3]`). +- [ ] Add a `server_count` variable. +- [ ] Expand the poll loop to poll all `servers[i].listen_fd` + STDIN_FILENO. +- [ ] On activity, call `server_handle_one(&servers[i], ...)` for the matching fd. +- [ ] `server_stop()` all on shutdown. + +### 3.2 Interactive menu +- [ ] Add `prompt_transport_selection()` function in `main.c` (after mnemonic load, before server start). +- [ ] Returns a bitmask of selected transports. +- [ ] Only called when `listen_mode` was not set by CLI and stdin is a TTY. +- [ ] Uses `tui_render_content_screen` + `read_line_stdin` for input. + +### 3.3 Transport setup from menu selection +- [ ] Unix: `server_init` + `server_start` with `NSIGNER_LISTEN_UNIX`, socket name `nsigner`. +- [ ] Qrexec bridge: same as unix + `server_set_bridge_source_trusted(&server, 1)`. +- [ ] TCP: `server_init` + `server_start` with `NSIGNER_LISTEN_TCP`, target `tcp:[::]:8080`. +- [ ] Qrexec one-shot: existing `NSIGNER_LISTEN_QREXEC` path (stdin, one request, exit). + +### 3.4 TUI status display +- [ ] Update `render_status()` to show multiple active listeners. +- [ ] Show `(bridge-source-trusted)` annotation on unix listeners that have it. + +### 3.5 Testing +- [ ] Interactive: start nsigner with no `--listen`, select unix+TCP, verify both listeners work. +- [ ] Interactive: select qrexec bridge, verify bridge connections get `qubes:` identity. +- [ ] CLI: `--listen tcp:[::]:8080` still works (skips menu). +- [ ] CLI: `--listen unix --bridge-source-trusted` still works (skips menu). +- [ ] Non-TTY: `--mnemonic-stdin` without `--listen` defaults to unix (no menu). + +--- + +## 4. Open questions + +1. **Should the menu remember the last selection?** n_signer has no config files (by design). We could store the preference in an env var or a `/tmp` file, but that violates the zero-filesystem-footprint principle. **Decision: no persistence — the menu appears fresh each time.** + +2. **Should there be a hotkey to add/remove transports at runtime?** E.g. press `t` in the TUI to bring up the transport menu again. This is a nice-to-have but adds complexity. **Decision: defer — the menu is startup-only for now.** + +3. **TCP port selection in the menu?** The menu could ask for a port number if TCP is selected. **Decision: default to 8080, let the user override with `--listen tcp:...` if they need a different port. Keep the menu simple.** + +4. **Socket name selection?** Same — default to `nsigner`, override with `--socket-name` if needed. **Decision: default, no prompt.** diff --git a/src/main.c b/src/main.c index d0f5df2..bc5440e 100644 --- a/src/main.c +++ b/src/main.c @@ -494,8 +494,8 @@ int socket_name_random(char *out, size_t out_len); /* Version information (auto-updated by build/version tooling) */ #define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MINOR 0 -#define NSIGNER_VERSION_PATCH 36 -#define NSIGNER_VERSION "v0.0.36" +#define NSIGNER_VERSION_PATCH 37 +#define NSIGNER_VERSION "v0.0.37" /* NSIGNER_HEADERLESS_DECLS_END */ @@ -1591,6 +1591,88 @@ static void apply_test_overrides(policy_table_t *policy) { } } +/* Transport selection bitmask for interactive menu */ +#define TRANSPORT_UNIX 0x01 +#define TRANSPORT_QREXEC_BRIDGE 0x02 +#define TRANSPORT_TCP 0x04 +#define TRANSPORT_QREXEC_ONESHOT 0x08 + +/* + * Interactive transport selection menu. + * Shown after mnemonic entry when no --listen flag was given and stdin is a TTY. + * Returns a bitmask of selected transports, or 0 on error/no selection. + */ +static int prompt_transport_selection(void) { + int selected = TRANSPORT_UNIX; /* default: local unix socket */ + char input[32]; + + for (;;) { + tui_render_content_screen(NULL, "Transport — how should other programs reach this signer?"); + printf("Select one or more (type a number to toggle, 'a' for all, Enter to confirm):\n\n"); + printf(" [%s] 1. Local Unix socket (same machine/qube)\n", + (selected & TRANSPORT_UNIX) ? "x" : " "); + printf(" [%s] 2. Qubes qrexec bridge (other qubes via qrexec, no network)\n", + (selected & TRANSPORT_QREXEC_BRIDGE) ? "x" : " "); + printf(" [%s] 3. TCP listener (FIPS mesh or local network)\n", + (selected & TRANSPORT_TCP) ? "x" : " "); + printf(" [%s] 4. Qrexec one-shot (legacy, one request per invocation)\n", + (selected & TRANSPORT_QREXEC_ONESHOT) ? "x" : " "); + printf("\n [a] select all (except 4) Enter = confirm\n"); + printf("> "); + fflush(stdout); + + if (read_line_stdin(input, sizeof(input)) != 0) { + return 0; + } + + /* Trim trailing whitespace */ + { + size_t len = strlen(input); + while (len > 0 && (input[len-1] == '\n' || input[len-1] == '\r' || + input[len-1] == ' ' || input[len-1] == '\t')) { + input[--len] = '\0'; + } + } + + if (input[0] == '\0') { + /* Enter pressed — confirm current selection */ + if (selected == 0) { + printf("At least one transport must be selected.\n"); + continue; + } + /* Qrexec one-shot is mutually exclusive with persistent listeners */ + if ((selected & TRANSPORT_QREXEC_ONESHOT) && (selected & ~TRANSPORT_QREXEC_ONESHOT)) { + printf("Qrexec one-shot cannot combine with other transports. Deselecting it.\n"); + selected &= ~TRANSPORT_QREXEC_ONESHOT; + if (selected == 0) { + selected = TRANSPORT_UNIX; + } + continue; + } + break; + } + + if (input[0] == 'a' || input[0] == 'A') { + selected = TRANSPORT_UNIX | TRANSPORT_QREXEC_BRIDGE | TRANSPORT_TCP; + continue; + } + + if (input[0] == '1') { + selected ^= TRANSPORT_UNIX; + } else if (input[0] == '2') { + selected ^= TRANSPORT_QREXEC_BRIDGE; + } else if (input[0] == '3') { + selected ^= TRANSPORT_TCP; + } else if (input[0] == '4') { + selected ^= TRANSPORT_QREXEC_ONESHOT; + } else { + printf("Invalid input. Type 1-4, 'a', or Enter to confirm.\n"); + } + } + + return selected; +} + int main(int argc, char *argv[]) { mnemonic_state_t mnemonic; role_table_t role_table; @@ -1598,13 +1680,14 @@ int main(int argc, char *argv[]) { policy_table_t policy; server_ctx_t server; key_store_t key_store; - struct pollfd pfds[2]; + struct pollfd pfds[3]; /* max: 2 listeners (unix+tcp) + stdin */ uid_t owner_uid; int derived_count; const char *socket_name = NSIGNER_DEFAULT_SOCKET_NAME; char generated_socket_name[SERVER_SOCKET_NAME_MAX]; int socket_name_explicit = 0; int listen_mode = NSIGNER_LISTEN_UNIX; + int listen_mode_explicit = 0; const char *listen_target = NSIGNER_DEFAULT_SOCKET_NAME; int argi = 1; const char *preapprove_specs[POLICY_MAX_ENTRIES]; @@ -1633,6 +1716,7 @@ int main(int argc, char *argv[]) { fprintf(stderr, "Missing value for %s\n", argv[argi]); return 1; } + listen_mode_explicit = 1; if (strcmp(argv[argi + 1], "unix") == 0) { listen_mode = NSIGNER_LISTEN_UNIX; } else if (strcmp(argv[argi + 1], "stdio") == 0) { @@ -1853,6 +1937,57 @@ int main(int argc, char *argv[]) { dispatcher_init(&dispatcher, &role_table, &mnemonic, &key_store); + /* + * Interactive transport selection: if no --listen flag was given and + * stdin is a TTY, show the multi-select transport menu. This lets the + * user choose how other programs reach the signer without memorizing + * CLI flags. CLI flags skip the menu (backward compatible). + */ + int transport_mask = 0; + int multi_listen = 0; /* set when multiple persistent listeners are active */ + server_ctx_t servers[2]; /* max: unix + tcp */ + int server_count = 0; + int unix_server_idx = -1; + int tcp_server_idx = -1; + + if (!listen_mode_explicit && isatty(STDIN_FILENO) && + mnemonic_source.kind == MNEMONIC_SOURCE_TUI) { + transport_mask = prompt_transport_selection(); + if (transport_mask == 0) { + fprintf(stderr, "No transport selected. Exiting.\n"); + crypto_wipe(&key_store); + nostr_cleanup(); + mnemonic_unload(&mnemonic); + return 1; + } + + /* Qrexec one-shot: use existing single-listener path */ + if (transport_mask & TRANSPORT_QREXEC_ONESHOT) { + listen_mode = NSIGNER_LISTEN_QREXEC; + listen_mode_explicit = 1; + } else { + /* Persistent listeners — configure from menu selection */ + multi_listen = 1; + + /* Unix socket (with or without bridge-source-trusted) */ + if (transport_mask & (TRANSPORT_UNIX | TRANSPORT_QREXEC_BRIDGE)) { + /* Use fixed name "nsigner" for scripted access */ + socket_name = "nsigner"; + socket_name_explicit = 1; + listen_mode = NSIGNER_LISTEN_UNIX; + listen_target = socket_name; + if (transport_mask & TRANSPORT_QREXEC_BRIDGE) { + bridge_source_trusted = 1; + } + } + + /* TCP listener */ + if (transport_mask & TRANSPORT_TCP) { + /* Will be started as a second listener */ + } + } + } + if (listen_mode == NSIGNER_LISTEN_UNIX && !socket_name_explicit) { if (socket_name_random(generated_socket_name, sizeof(generated_socket_name)) != 0) { fprintf(stderr, "Failed to generate random socket name\n"); @@ -1914,6 +2049,41 @@ int main(int argc, char *argv[]) { return 1; } + /* + * Multi-listener: if interactive menu selected TCP in addition to unix, + * start a second server context for TCP. The primary server (unix) is + * already running. Both share the same dispatcher and policy. + */ + if (multi_listen && (transport_mask & TRANSPORT_TCP)) { + const char *tcp_target = "tcp:[::]:8080"; + tcp_server_idx = server_count; + server_init(&servers[tcp_server_idx], + tcp_target, + 0, /* socket_name_explicit = 0 for TCP */ + NSIGNER_LISTEN_TCP, + NSIGNER_AUTH_REQUIRED, + auth_skew_seconds, + &dispatcher, + &policy); + if (server_start(&servers[tcp_server_idx]) != 0) { + fprintf(stderr, "Failed to start TCP server on %s: %s\n", + tcp_target, server_last_error(&servers[tcp_server_idx])); + server_stop(&server); + crypto_wipe(&key_store); + nostr_cleanup(); + mnemonic_unload(&mnemonic); + return 1; + } + server_count++; + } + + /* Copy the primary (unix) server into the array for unified polling */ + if (multi_listen) { + unix_server_idx = server_count; + servers[unix_server_idx] = server; /* struct copy */ + server_count++; + } + (void)signal(SIGINT, handle_signal); (void)signal(SIGTERM, handle_signal); /* @@ -1935,7 +2105,22 @@ int main(int argc, char *argv[]) { connection_info_clear(); - if (listen_mode == NSIGNER_LISTEN_UNIX) { + if (multi_listen) { + /* Multi-listener mode: show all active transports */ + if (transport_mask & (TRANSPORT_UNIX | TRANSPORT_QREXEC_BRIDGE)) { + if (bridge_source_trusted) { + connection_info_add("listen: unix @%s (bridge-source-trusted)", socket_name); + } else { + connection_info_add("listen: unix @%s", socket_name); + } + connection_info_add("client: nsigner --socket-name %s client ''", socket_name); + printf("System is ready and waiting for connections on @%s.\n", socket_name); + } + if (transport_mask & TRANSPORT_TCP) { + connection_info_add("listen: tcp [::]:8080"); + printf("System is ready and waiting for connections on tcp:[::]:8080.\n"); + } + } else if (listen_mode == NSIGNER_LISTEN_UNIX) { connection_info_add("listen: unix @%s", socket_name); connection_info_add("client: nsigner --socket-name %s client ''", socket_name); printf("System is ready and waiting for connections on @%s.\n", socket_name); @@ -2013,13 +2198,26 @@ int main(int argc, char *argv[]) { tui_install_resize_handler(); render_status(&role_table, &mnemonic, derived_count, socket_name); - pfds[0].fd = server.listen_fd; - pfds[0].events = POLLIN; - pfds[1].fd = STDIN_FILENO; - pfds[1].events = POLLIN; + /* Set up poll fds: listener(s) + stdin */ + int npfds = 0; + if (multi_listen) { + for (int si = 0; si < server_count; si++) { + pfds[npfds].fd = servers[si].listen_fd; + pfds[npfds].events = POLLIN; + npfds++; + } + } else { + pfds[npfds].fd = server.listen_fd; + pfds[npfds].events = POLLIN; + npfds++; + } + pfds[npfds].fd = STDIN_FILENO; + pfds[npfds].events = POLLIN; + npfds++; + int stdin_pfd_idx = npfds - 1; while (g_running && server.running) { - int prc = poll(pfds, 2, 200); + int prc = poll(pfds, npfds, 200); if (prc < 0) { if (errno == EINTR) { continue; @@ -2031,15 +2229,31 @@ int main(int argc, char *argv[]) { render_status(&role_table, &mnemonic, derived_count, socket_name); } - if (prc > 0 && (pfds[0].revents & POLLIN)) { - int hrc = server_handle_one(&server, activity_log_cb, NULL); - if (hrc < 0) { - break; + /* Check all listener fds for activity */ + if (prc > 0) { + if (multi_listen) { + for (int si = 0; si < server_count; si++) { + if (pfds[si].revents & POLLIN) { + int hrc = server_handle_one(&servers[si], activity_log_cb, NULL); + if (hrc < 0) { + g_running = 0; + break; + } + render_status(&role_table, &mnemonic, derived_count, socket_name); + } + } + } else { + if (pfds[0].revents & POLLIN) { + int hrc = server_handle_one(&server, activity_log_cb, NULL); + if (hrc < 0) { + break; + } + render_status(&role_table, &mnemonic, derived_count, socket_name); + } } - render_status(&role_table, &mnemonic, derived_count, socket_name); } - if (prc > 0 && (pfds[1].revents & POLLIN)) { + if (prc > 0 && (pfds[stdin_pfd_idx].revents & POLLIN)) { char ch = '\0'; if (read(STDIN_FILENO, &ch, 1) > 0) { char lower = (char)tolower((unsigned char)ch); @@ -2083,7 +2297,13 @@ int main(int argc, char *argv[]) { server_set_approval_cb(NULL, NULL); } - server_stop(&server); + if (multi_listen) { + for (int si = 0; si < server_count; si++) { + server_stop(&servers[si]); + } + } else { + server_stop(&server); + } crypto_wipe(&key_store); nostr_cleanup(); mnemonic_unload(&mnemonic);