# TUI Service Menu — Post-Identity Service Selection > **Status:** Design. This document extends [`plans/tui_login.md`](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`](tui_login.md), [`plans/identity_subsystem.md`](identity_subsystem.md), [`plans/iso_architecture.md`](iso_architecture.md), [`plans/nostr_config_projection.md`](nostr_config_projection.md), [`/home/user/lt/aesthetics/TUI.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`](../iso/config/includes.chroot/etc/systemd/system/n-os-tr-firstboot.service), `nostr-id.service` (identity-agent), [`nostr-id-tui.service`](../iso/config/includes.chroot/etc/systemd/system/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`](/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`](nostr_config_projection.md#3-boot-flow)). | | `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 ```mermaid 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`](tui_login.md#31-state-diagram). ### 2.2 Updated boot sequence ```mermaid 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`](/home/user/lt/aesthetics/TUI.md). Every screen follows the canonical template in §12 of that doc. ### 3.1 Main services screen ```text ===================== 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](/home/user/lt/aesthetics/TUI.md)): - Header expands to full terminal width using the `=== SERVICES ===` pattern from [`ui.c:print_centered_header`](/home/user/lt/aesthetics/TUI.md:88). - 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\033[0m`, matching [TUI.md §7](/home/user/lt/aesthetics/TUI.md). - Prompt at bottom is `Select action: >` per the canonical template. ### 3.2 Confirm screen Before actually doing anything, we show what will happen: ```text ===================== 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): ```text ===================== 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 ```text ===================== 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 -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`](../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: ```c 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`](tui_login.md#61-etcsystemdsystemnostr-id-tuiservice): 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`](tui_login.md#22-code-that-lives-in-this-repo-n_os_tr): ``` 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`](tui_login.md#7-per-flavor-abstraction). 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/` 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`](../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`](../iso/config/hooks/live/0020-enable-services.hook.chroot) enables the services directly. Rework it to be explicit about the new posture: ```sh #!/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`](../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`](../iso/config/includes.chroot/etc/systemd/system/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`](../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`](../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 ` 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`](nostr_config_projection.md#21-slots): ```json { "name": "n-os-tr/services", "path": "/run/nostr-id/services.state.json", "mode": "0600", "encrypted": true } ``` Content schema (draft): ```json { "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`](tui_login.md#9-failure-modes-summary)). 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`](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`](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`](../iso/config/includes.chroot/usr/share/doc/n-os-tr/README) — describe the new login→services flow for end users. - [`docs/RUNNING.md`](../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`](tui_login.md), [`plans/iso_architecture.md`](iso_architecture.md), [`docs/RUNNING.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.