29 KiB
TUI Service Menu — Post-Identity Service Selection
Status: Design. This document extends
plans/tui_login.mdwith a second screen inside the samenostr-id-tuibinary: 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 beforegetty@tty1is 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
- n-OS-tr always installs and sets up every service. Binaries, unit files, config templates, data directories — all present on the image.
- 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 shipsdisabledvia the live-build preset policy. - 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. - 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).
- 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. - 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.
- 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
yon Enter. Ifn, 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 fromui.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:
- Query each unit's current state (
LoadState,ActiveState,SubState). - Start and stop units.
- 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
systemctlchild, 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 likessh,tor,nginx) in the package itself. - No symlink in
multi-user.target.wants/— i.e. nosystemctl 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-privkeyinginxsom.service).fips— same.nginx,config-loader,config-writer,ssh,tor— available in bunker mode.
- Amnesia mode: all services available, but
config-loader/config-writerare 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$PATHto a directory with a shimsystemctlthat records its argv and emits canned output; asserts our client parsesActiveState=active|inactive|failedcorrectly and times out when the shim sleeps.test_screen_services— drives the services state machine through a mockui_backend_t; asserts:- Default state is all-off.
- Toggling
RthenGthenRends withGon andRoff. - Toggling
Rprompts forNenable; answeringyenables 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:
- Boot the ISO.
- At the identity screen, feed the Trezor test-vector mnemonic.
- At the services screen, press
N(enable nginx), thenC, thenY. - After the TUI exits and getty appears, log in as the default user.
- Assert
systemctl is-active nginx=activeandsystemctl 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>returnsdisabled. - The manifest file
/etc/n-os-tr/services.confexists and parses (re-invoke the parser from a test helper). /bin/systemctlis 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 newServicesstate afterSendAgent; addscreen_services.c,screen_services_confirm.c,services_conf.c,systemd_client.cto 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 parallelfips/c-relay/ginxsom/nginxblock, 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.
- 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, andfirstbootare enabled; every other service is present on disk but inactive. Verify withsystemctl list-unit-files --state=disabledin a QEMU boot. - Add the service manifest file. Ship
/etc/n-os-tr/services.confwith the v1 table from §1.1. No TUI change yet; verifies that the file reaches the image. - Add
torto the base package list. Confirm the built ISO includestor.serviceas a disabled unit. - Implement
services_conf.c+ tests. Parser plus a 40-line sanity test in the Alpine builder. - Implement
systemd_client.c+ tests. Fork/exec + pipe capture + shim-based test. - Implement
screen_services.cstate machine +test_screen_services. No rendering yet — verifies transitions against a mock backend. - Render the services screen in
ui_backend_curses.c. First visible new screen. - Implement
screen_services_confirm.c— confirm/apply/apply-error screens. Wire them tosystemd_client. - Hook into
main.c. Afteragent_clientreturns fromload_mnemonic/load_bunker/load_amnesiawith ok, transition to the services screen. Skip the screen entirely if/etc/n-os-tr/services.confis missing and fallback is disabled via a--no-servicesflag (useful for tests that want only the identity flow). - 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.
- Extend the smoketest. Add the preset-state check.
- Doc passes. Update
plans/tui_login.md,plans/iso_architecture.md,docs/RUNNING.md, and the in-ISO README.
14. Open questions
tor.serviceposture out of the box. We ship the distro defaulttorrc(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_clientmodule, but that's post-v1. - Per-user vs system. All services here are system-level. "Which user's
ssh/authorized_keysdoessshserve" 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.