Files
n_os_tr/plans/tui_service_menu.md
2026-05-02 09:21:19 -04:00

29 KiB

TUI Service Menu — Post-Identity Service Selection

Status: Design. This document extends plans/tui_login.md with a second screen inside the same nostr-id-tui binary: after the user has successfully loaded an identity (mnemonic / generated / imported / bunker / amnesia), the TUI presents a menu of optional services and lets the user toggle each one on or off before getty@tty1 is allowed to start.

Depends on: plans/tui_login.md, plans/identity_subsystem.md, plans/iso_architecture.md, plans/nostr_config_projection.md, /home/user/lt/aesthetics/TUI.md.

0. Policy

  1. n-OS-tr always installs and sets up every service. Binaries, unit files, config templates, data directories — all present on the image.
  2. No service starts automatically at boot except the identity/boot pipeline itself. Exactly these three are enabled image-wide: n-os-tr-firstboot.service, nostr-id.service (identity-agent), nostr-id-tui.service. Everything else ships disabled via the live-build preset policy.
  3. The user decides what turns on. After the user authenticates via nostr-id-tui, the TUI presents a service-toggle screen. The user picks what they want running this session.
  4. Selections are session-local in v1. No persistence across reboots. Every boot starts with the same defaults. A future revision may project selections to a Nostr config slot (§10).
  5. One binary, one ceremony. The service menu is a new screen inside nostr-id-tui, not a separate unit. Rationale discussed in the architect session: the user is already in one authenticated terminal flow; we do not want two back-to-back ceremonies before login.
  6. Default-off by default. Every toggleable service starts the session in the "off" state. The user must explicitly enable each one they want running. This matches the privacy-forward posture of the project.
  7. Style: strict adherence to /home/user/lt/aesthetics/TUI.md — single-key actions, terminal-width headers, ANSI underline for hotkeys, canonical menu template (§12 of that doc).

1. Scope

1.1 Services in the menu (v1)

Key Label systemd unit Default What it does
r c-relay c-relay.service off Local Nostr relay (NIP-01/42). Admin key = index 2.
g ginxsom ginxsom.service off Blossom blob server behind nginx. Server key = index 3.
f fips fips.service off Mesh-routing daemon; npub-IPv6 overlay. Key = index 4.
n nginx nginx.service off TLS front door / reverse proxy. Needed by c-relay ws, ginxsom uploads.
l config-loader nostr-config-loader.service off Fetch + materialize user config slots (§ nostr_config_projection.md).
w config-writer nostr-config-writer.service off Save tracked home paths back to Nostr on shutdown.
s ssh ssh.service off OpenSSH server. Keys regenerated at firstboot.
t tor tor.service off Tor daemon. Default posture = SOCKS-only (client), not exit/relay.

1.2 Services explicitly NOT in the menu

  • nostr-id.service — identity-agent is always required; it's the thing the TUI talks to.
  • n-os-tr-firstboot.service — oneshot, runs before the TUI, not user-controlled.
  • nostr-id-tui.service — the TUI itself.
  • systemd-networkd/NetworkManager/dbus — core system plumbing; managed by distro defaults.

1.3 Service dependencies the TUI must respect

Some services only make sense together. The TUI enforces these dependencies at toggle time (not at the systemd level — systemd's own Requires=/Wants= handles the actual startup ordering):

Service Implicit deps (auto-enabled when this is turned on)
c-relay nginx (wss termination and cert)
ginxsom nginx (serves blobs, proxies uploads)
config-writer config-loader (must have run at boot to know the slots)

If the user toggles c-relay on and nginx is off, the TUI:

  • Shows a one-line prompt: c-relay needs nginx. Turn nginx on too? [Y/n].
  • Default y on Enter. If n, the toggle is cancelled (we do not start a service that won't be reachable).

Reverse direction (turning nginx off while c-relay is on): prompt c-relay will become unreachable without nginx. Stop c-relay too? [Y/n].

2. Placement in the existing TUI flow

2.1 Updated state diagram

stateDiagram-v2
    [*] --> Menu

    Menu --> Enter: 1 / E
    Menu --> Generate: 2 / G
    Menu --> Import: 3 / I
    Menu --> Bunker: 4 / B
    Menu --> Amnesia: 5 / A

    Enter --> SendAgent: BIP-39 ok
    Generate --> SendAgent: confirm words ok
    Import --> SendAgent: file read + wiped
    Bunker --> SendAgent: NIP-46 connected
    Amnesia --> SendAgent: ephemeral key

    SendAgent --> Services: agent returns ok
    SendAgent --> FatalError: agent returns error

    Services --> Services: user toggles
    Services --> Confirm: user presses C (commit)
    Services --> Services: user presses X (skip, all off)

    Confirm --> Apply: user presses Y
    Confirm --> Services: user presses N

    Apply --> Apply: start/stop each selected unit
    Apply --> Exit: all units reached terminal state
    Apply --> ApplyError: any unit failed

    ApplyError --> Services: user reviews, retries

    FatalError --> [*]
    Exit --> [*]

The only new states are Services, Confirm, Apply, and ApplyError. Everything before SendAgent is unchanged from plans/tui_login.md §3.1.

2.2 Updated boot sequence

sequenceDiagram
    participant SD as systemd
    participant IA as nostr-id.service
    participant TUI as nostr-id-tui.service
    participant Svc as toggleable services
    participant GT as getty@tty1.service

    SD->>IA: start
    IA-->>SD: listening on control.sock
    SD->>TUI: start (owns tty1)
    Note over TUI: identity screens
    TUI->>IA: load_mnemonic/bunker/amnesia
    IA-->>TUI: ok, main_npub
    Note over TUI: service menu
    TUI->>TUI: user toggles, presses Commit
    TUI->>SD: StartUnit/StopUnit via dbus
    SD->>Svc: start selected services
    Svc-->>SD: active
    SD-->>TUI: job results
    TUI->>SD: exit 0
    SD->>GT: start (ordering allows)
    GT->>User: n-os-tr login:

3. Screen design

Strict compliance with /home/user/lt/aesthetics/TUI.md. Every screen follows the canonical template in §12 of that doc.

3.1 Main services screen

===================== SERVICES =====================
                (expands to terminal width)

Identity: npub1abcd…wxyz   (main, index 0)
Session:  live (in-memory, no persistence)

  Key  Service         State       Default  Depends on
  ---  -------------   ---------   -------  -----------
   R   c-relay         [ off ]     off      nginx
   G   ginxsom         [ off ]     off      nginx
   F   fips            [ off ]     off      —
   N   nginx           [ off ]     off      —
   L   config-loader   [ off ]     off      —
   W   config-writer   [ off ]     off      config-loader
   S   ssh             [ off ]     off      —
   T   tor             [ off ]     off      —

Actions:
 R/G/F/N/L/W/S/T) Toggle service
 A) Turn All on
 Z) Turn all off (reset)
 C) Commit and continue
 X) Skip (all off, continue)

Select action: >

Formatting rules (all from TUI.md):

  • Header expands to full terminal width using the === SERVICES === pattern from ui.c:print_centered_header.
  • Column widths are fixed; when terminal is narrower than 60 cols, drop the "Default" and "Depends on" columns (the state is what matters).
  • State column is either [ off ] or [ on ]. A third transient state [ ... ] appears during the Apply phase.
  • The first letter of each service name in the Key column is rendered with ANSI underline via \033[4m<letter>\033[0m, matching TUI.md §7.
  • Prompt at bottom is Select action: > per the canonical template.

3.2 Confirm screen

Before actually doing anything, we show what will happen:

===================== CONFIRM =====================

The following changes will be applied:

  START   nginx.service
  START   c-relay.service
  START   fips.service

  (5 services remain off)

Proceed? [Y/n] >

Only services whose session state differs from their default appear. Pure-default sessions (no changes) go straight from the services screen's C/X directly to Apply with an empty plan and exit.

3.3 Apply screen

Live progress. We iterate the plan serially (not concurrently — systemd handles that anyway, and serial output is easier to read):

===================== APPLYING =====================

 [ OK  ]  nginx.service            started in 0.42s
 [ OK  ]  c-relay.service          started in 1.14s
 [ ... ]  fips.service             starting...
 [     ]  (waiting)

Press Ctrl-C to abort remaining jobs (already-started services stay up).

Each row shows one of:

  • [ ] queued
  • [ ... ] in progress (animated dot after 250 ms; ANSI cursor save/restore to re-render only the affected row)
  • [ OK ] finished successfully
  • [FAIL ] finished with an error
  • [SKIP ] not applied (e.g. dependency refused earlier)

After all jobs complete:

  • All green → advance to Exit immediately (print All services started. Handing control to login... for 1 second, then the TUI exits and getty takes over).
  • Any failures → advance to ApplyError screen.

3.4 Apply-error screen

===================== PARTIAL FAILURE =====================

Some services did not start cleanly:

 [FAIL ]  ginxsom.service
          SubState: failed
          ExecMainStatus: 1
          Last log line: "/usr/local/bin/ginxsom/ginxsom-fcgi: socket bind failed"

Actions:
 V) View journal for failed services
 R) Retry failed services only
 B) Back to service menu
 I) Ignore and continue to login anyway

Select action: >

The journal view (V) shells out to journalctl -u <unit> -n 50 --no-pager via a pipe (we do not embed a log viewer). less-style paging is acceptable if available; otherwise we just print and prompt to continue.

4. Service manifest

The TUI does not hard-code the service list in C. It reads /etc/n-os-tr/services.conf at startup. This lets us add/remove services (especially tor, which lands later) without rebuilding the binary.

4.1 File format

A small line-based format, one record per service, fields tab-separated:

# key  unit                        label          default  deps
r      c-relay.service             c-relay        off      nginx.service
g      ginxsom.service             ginxsom        off      nginx.service
f      fips.service                fips           off      -
n      nginx.service               nginx          off      -
l      nostr-config-loader.service config-loader  off      -
w      nostr-config-writer.service config-writer  off      nostr-config-loader.service
s      ssh.service                 ssh            off      -
t      tor.service                 tor            off      -

Comments start with #. Blank lines ignored. Unknown fields ignored for forward compatibility. Multiple deps separated by ,. A literal - means no deps.

Parsing happens in stack/nostr-id-tui/src/services_conf.c. On parse error the TUI falls back to a hardcoded minimal list (c-relay, ginxsom, fips, nginx) and logs the parse error to stderr (captured by journald, per the service unit's StandardError=journal).

4.2 Install location

Shipped in the chroot at iso/config/includes.chroot/etc/n-os-tr/services.conf. Mode 0644, owner root:root.

5. Control path: talking to systemd

5.1 Transport choice

The TUI needs to:

  1. Query each unit's current state (LoadState, ActiveState, SubState).
  2. Start and stop units.
  3. Receive job-completion signals.

Two realistic options in a static musl C binary with no glibc:

Option Notes Verdict
Shell out to systemctl via fork + execve Simple. Exit codes are well-defined. Parse show --property=ActiveState=. No linking. v1 default.
Speak the D-Bus wire protocol directly to /run/dbus/system_bus_socket Avoids forking. Enables real-time JobRemoved signals. Needs a tiny D-Bus marshaller (~1 KLOC) or the vendored cjson-style lib. Later optimization.
Link libsystemd.a Would pull in a chunk of systemd source into Alpine build, and libsystemd isn't available as -static in Alpine. Rejected.

v1 uses systemctl. The binary path is hardcoded to /bin/systemctl (present on every Debian live image — systemctl itself is part of systemd which is in base). The wrapper module is stack/nostr-id-tui/src/systemd_client.c and exposes:

typedef enum {
    SVC_INACTIVE = 0,
    SVC_ACTIVATING,
    SVC_ACTIVE,
    SVC_DEACTIVATING,
    SVC_FAILED,
    SVC_UNKNOWN
} svc_state_t;

svc_state_t systemd_active_state(const char *unit);  /* systemctl show -p ActiveState */
int         systemd_start(const char *unit, int timeout_ms, char *err_out, size_t err_len);
int         systemd_stop (const char *unit, int timeout_ms, char *err_out, size_t err_len);
int         systemd_journal_tail(const char *unit, int n_lines, FILE *out); /* exec journalctl */

Each call spawns systemctl / journalctl with posix_spawn into a pipe, waits with a timeout, captures stdout/stderr, and returns.

5.2 Privilege

nostr-id-tui.service currently runs as root on tty1 (see the service unit). That gives it direct systemctl privilege — no polkit rule needed. This matches the existing threat-model stance in plans/tui_login.md §6.1: the TUI is already privileged because it owns the tty and talks to identity-agent over a root-only socket.

If we ever demote the TUI to run as the nostr-id user, we'll need /etc/polkit-1/rules.d/50-n-os-tr-services.rules granting that user the org.freedesktop.systemd1.manage-units action only for the whitelisted units listed in services.conf. That's future work.

5.3 Timeouts and cancellation

  • Per-unit start/stop timeout: 30 s (covers nginx startup, fips mesh ready, etc.). Larger than any real service needs.
  • TUI-level overall budget: 2 minutes. If exceeded, Apply falls through to ApplyError with any unfinished jobs marked [TIME] and the user is prompted.
  • Ctrl-C in the Apply screen sends SIGINT to the current systemctl child, marks the remaining jobs [SKIP ], and goes to ApplyError.

6. Source tree changes

Extending the layout declared in plans/tui_login.md §2.2:

stack/nostr-id-tui/
├── src/
│   ├── main.c                       # now dispatches identity→services→exit
│   ├── screen_menu.c                # unchanged
│   ├── screen_enter.c               # unchanged
│   ├── screen_generate.c            # unchanged
│   ├── screen_import.c              # unchanged
│   ├── screen_bunker.c              # unchanged
│   ├── screen_services.c            # NEW: main service toggle screen
│   ├── screen_services_confirm.c    # NEW: confirm + apply screens
│   ├── services_conf.c              # NEW: parser for /etc/n-os-tr/services.conf
│   ├── systemd_client.c             # NEW: fork/exec systemctl + journalctl
│   ├── curses_helpers.c             # unchanged
│   ├── agent_client.c               # unchanged
│   └── memhygiene.c                 # unchanged
├── include/
│   └── n_os_tr_tui.h                # adds svc_* decls + ui_backend hooks
└── tests/
    ├── test_screen_services.c       # NEW: state-machine unit test
    ├── test_services_conf.c         # NEW: parser fuzz/table tests
    ├── test_systemd_client.c        # NEW: runs against a fake systemctl shim
    └── test_integration.sh          # extended to toggle a service in QEMU

No ncurses API leaks into the new state machines. All rendering goes through the same ui_backend_t abstraction established in plans/tui_login.md §7. The Pi LCD port will get a stripped service menu for free.

7. Systemd preset policy

7.1 What "installed but not enabled" means

For each toggleable unit we ship:

  • The unit file in iso/config/includes.chroot/etc/systemd/system/<unit> or (for distro-packaged units like ssh, tor, nginx) in the package itself.
  • No symlink in multi-user.target.wants/ — i.e. no systemctl enable.
  • A preset file that explicitly forbids enabling at image-build time.

7.2 Preset file

New file: iso/config/includes.chroot/etc/systemd/system-preset/50-n-os-tr.preset:

# n-OS-tr service posture: only the identity/boot pipeline auto-starts.
# Everything else is explicitly disabled. The TUI turns them on at the
# user's request after identity load.
enable nostr-id.service
enable nostr-id-tui.service
enable n-os-tr-firstboot.service

disable c-relay.service
disable ginxsom.service
disable fips.service
disable nginx.service
disable nostr-config-loader.service
disable nostr-config-writer.service
disable ssh.service
disable tor.service

The 50- prefix lands before Debian defaults (typically 90-*.preset), so our disable lines override e.g. nginx's upstream preset-enable.

7.3 Rework of the enable-services hook

Current hook iso/config/hooks/live/0020-enable-services.hook.chroot enables the services directly. Rework it to be explicit about the new posture:

#!/bin/sh
set -eu

# Enable the always-on trio
systemctl enable n-os-tr-firstboot.service
systemctl enable nostr-id.service
systemctl enable nostr-id-tui.service

# Explicitly disable everything else in the service manifest.
# Idempotent; safe to re-run. `systemctl disable` is a no-op for units
# that aren't enabled yet, which is what we want.
for unit in \
    c-relay.service \
    ginxsom.service \
    fips.service \
    nginx.service \
    nostr-config-loader.service \
    nostr-config-writer.service \
    ssh.service \
    tor.service ; do
    systemctl disable "$unit" 2>/dev/null || true
done

# Run the preset policy for anything we didn't list above (defensive).
systemctl preset-all --preset-mode=enable-only 2>/dev/null || true

7.4 Package-list additions

tor is new to the image. Add to iso/config/package-lists/base.list.chroot:

tor

No other new packages: nginx, openssh-server, ca-certificates, systemd, etc. are already pulled.

8. Interaction with identity modes

8.1 Normal, Bunker, and Amnesia modes

  • Normal (mnemonic loaded): full menu, full defaults as in §1.1.
  • Bunker mode: services that require a raw local nsec are disabled in the menu (greyed out with [ n/a ] in the state column). Those are:
    • c-relay — admin key comes from identity-agent via NIP-46 signing for event-based config only; for the admin nsec path the service fails. The menu shows [ n/a ] with a tooltip line "requires local key; bunker mode cannot provide".
    • ginxsom — same. Key material is needed at process start (--server-privkey in ginxsom.service).
    • fips — same.
    • nginx, config-loader, config-writer, ssh, tor — available in bunker mode.
  • Amnesia mode: all services available, but config-loader/config-writer are explicitly disabled with label "amnesia session — config not persisted" (we don't pull anything from Nostr for an ephemeral identity).

The rules live in services_conf.c as a small matrix; the file format in §4.1 can be extended later with modes=normal,bunker,amnesia but v1 hardcodes the matrix in C.

8.2 Re-entry

A future option from the Services menu: Q) Reset identity and return to identity menu. Deferred — not in v1. For v1 the only path out of Services is C (commit) or X (skip), both of which lead to Exit.

9. Testing

9.1 Unit (in Alpine/musl builder)

  • test_services_conf — feeds valid and malformed manifests, asserts parser produces correct structs and correct fallback on bad input.
  • test_systemd_client — sets $PATH to a directory with a shim systemctl that records its argv and emits canned output; asserts our client parses ActiveState=active|inactive|failed correctly and times out when the shim sleeps.
  • test_screen_services — drives the services state machine through a mock ui_backend_t; asserts:
    • Default state is all-off.
    • Toggling R then G then R ends with G on and R off.
    • Toggling R prompts for N enable; answering y enables both.
    • Z (reset) returns to defaults.
    • X (skip) goes directly to Apply with an empty plan.
    • C (commit) goes to Confirm.

9.2 Integration (QEMU)

Extend stack/nostr-id-tui/tests/test_integration.sh with a new scripted run:

  1. Boot the ISO.
  2. At the identity screen, feed the Trezor test-vector mnemonic.
  3. At the services screen, press N (enable nginx), then C, then Y.
  4. After the TUI exits and getty appears, log in as the default user.
  5. Assert systemctl is-active nginx = active and systemctl is-active c-relay = inactive.

9.3 Smoketest extensions

Extend iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest to also verify:

  • Preset posture at image build time: for each toggleable unit, systemctl is-enabled <unit> returns disabled.
  • The manifest file /etc/n-os-tr/services.conf exists and parses (re-invoke the parser from a test helper).
  • /bin/systemctl is present (the TUI depends on it).

10. Non-persistence and the future Nostr slot

v1: selections are session-local. Reboot → all services back to off, user re-selects.

Future: one new entry in the manifest scheme from plans/nostr_config_projection.md §2.1:

{
  "name": "n-os-tr/services",
  "path": "/run/nostr-id/services.state.json",
  "mode": "0600",
  "encrypted": true
}

Content schema (draft):

{
  "version": 1,
  "enabled": ["nginx.service", "c-relay.service", "fips.service"]
}

When a session starts, if a services slot exists, the TUI pre-loads the toggle state from it. User still has to press C to commit — we don't auto-apply without the user seeing the menu. This preserves the "the user decides when to turn them on" rule.

Out of scope for v1 implementation.

11. Failure modes

Failure Behavior
/etc/n-os-tr/services.conf missing or unparseable Log to stderr, fall back to hardcoded minimal list (c-relay, ginxsom, fips, nginx).
/bin/systemctl missing FatalError (as in plans/tui_login.md §9). The ISO is malformed.
Unit start returns non-zero Row shows [FAIL ], ApplyError screen is reached after all jobs settle.
Unit start hangs past 30 s Timeout, row shows [TIME], ApplyError.
User hits Ctrl-C during Apply SIGINT to current systemctl, remaining jobs [SKIP ], ApplyError.
Bunker mode, user tries to toggle c-relay Toggle is ignored; status line shows "requires local key; bunker mode".
User toggles all off and presses Commit Apply plan is empty; TUI prints No services requested. Handing control to login... and exits.
Identity agent dies during service menu We're not calling the agent during this phase — selections are in TUI memory only. User is unaffected until they finish service menu and the TUI exits; if the agent is dead when services start, those services will fail on their own dependency chain.

12. Docs to update

  • plans/tui_login.md — add a one-line reference in §3.1 to the new Services state after SendAgent; add screen_services.c, screen_services_confirm.c, services_conf.c, systemd_client.c to the source tree listing in §2.2; update §13 implementation order with the service-menu steps.
  • plans/iso_architecture.md — update §4 boot sequence to show the identity+services TUI phase before the parallel fips/c-relay/ginxsom/nginx block, and note that those services are started by the TUI, not auto-started by systemd.
  • iso/config/includes.chroot/usr/share/doc/n-os-tr/README — describe the new login→services flow for end users.
  • docs/RUNNING.md — add a section showing the service menu and how to toggle.

13. Implementation order (Phase 3a.2)

Small, independently verifiable steps. Each one leaves the repo in a runnable state.

  1. Land the preset file and rework the enable hook. After this commit, a fresh ISO boots to a system where only nostr-id, nostr-id-tui, and firstboot are enabled; every other service is present on disk but inactive. Verify with systemctl list-unit-files --state=disabled in a QEMU boot.
  2. Add the service manifest file. Ship /etc/n-os-tr/services.conf with the v1 table from §1.1. No TUI change yet; verifies that the file reaches the image.
  3. Add tor to the base package list. Confirm the built ISO includes tor.service as a disabled unit.
  4. Implement services_conf.c + tests. Parser plus a 40-line sanity test in the Alpine builder.
  5. Implement systemd_client.c + tests. Fork/exec + pipe capture + shim-based test.
  6. Implement screen_services.c state machine + test_screen_services. No rendering yet — verifies transitions against a mock backend.
  7. Render the services screen in ui_backend_curses.c. First visible new screen.
  8. Implement screen_services_confirm.c — confirm/apply/apply-error screens. Wire them to systemd_client.
  9. Hook into main.c. After agent_client returns from load_mnemonic/load_bunker/load_amnesia with ok, transition to the services screen. Skip the screen entirely if /etc/n-os-tr/services.conf is missing and fallback is disabled via a --no-services flag (useful for tests that want only the identity flow).
  10. Extend the QEMU integration test. Script a toggle of one service, assert post-login that the chosen service is active and the others are not.
  11. Extend the smoketest. Add the preset-state check.
  12. Doc passes. Update plans/tui_login.md, plans/iso_architecture.md, docs/RUNNING.md, and the in-ISO README.

14. Open questions

  • tor.service posture out of the box. We ship the distro default torrc (SOCKS 9050 only, no relay). Do we want a second unit drop-in that enables the hidden-service onion for nginx when the user turns both on? Probably yes, as a follow-up; keep v1 minimal.
  • GUI flavor. Image flavor with a GUI will run the TUI on a text VT during early boot. Does it also allow the user to re-run the services menu later from the GUI? Probably yes via a GTK wrapper around the same systemd_client module, but that's post-v1.
  • Per-user vs system. All services here are system-level. "Which user's ssh/authorized_keys does ssh serve" is answered by the config-loader slot scheme. No per-user service toggles in v1.
  • Should toggling the menu's state affect default behavior for future reboots? Today: no; v1 is stateless. A future Nostr slot (§10) could record the last committed state and let the TUI show it as a "remembered" default, but that's explicit future work.