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

539 lines
29 KiB
Markdown

# 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<letter>\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 <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`](../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/<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`](../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 <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`](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.