diff --git a/README.md b/README.md index 001f461..1e7d89e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Build a bootable NixOS ISO with the installer preinstalled: nix build .#iso ``` -Write `result/iso/nixos-zfs-installer.iso` to a USB stick (for example with `dd`) or boot it in a VM. The image is the minimal NixOS installation CD with ZFS enabled and `nixos-installer` on `PATH`, so once booted you can run: +Write `result/iso/nixos-zfs-installer.iso` to a USB stick (for example with `dd`) or boot it in a VM. The image is the minimal NixOS installation CD with ZFS enabled and `nixos-installer` on `PATH`. SSH is enabled and the `nixos` and `root` accounts use the password `nixos`, so you can also run the installer remotely. Once booted: ```sh sudo nixos-installer @@ -42,7 +42,7 @@ Validate the live environment first with: ./nixos-installer --check ``` -Set `ENCRYPT_KEY` to enable LUKS during install: +Paste a value into the TUI encryption password field to enable LUKS during install, or set `ENCRYPT_KEY`: ```sh sudo env ENCRYPT_KEY='change-me' ./nixos-installer diff --git a/python/installer/__main__.py b/python/installer/__main__.py index 7c107e3..ddf3c68 100644 --- a/python/installer/__main__.py +++ b/python/installer/__main__.py @@ -6,7 +6,7 @@ import curses import logging import sys from argparse import ArgumentParser -from os import getenv +from os import environ, getenv from pathlib import Path from random import getrandbits from subprocess import run @@ -38,6 +38,29 @@ REQUIRED_COMMANDS = ( ) +def configure_terminal() -> None: + """Force a terminal type every live system has a terminfo entry for. + + Terminals such as kitty advertise TERM values (xterm-kitty) that live + systems have no terminfo entry for, so setupterm fails before the TUI + can start. + """ + environ["TERM"] = "xterm-256color" + + if getenv("TERMINFO") or getenv("TERMINFO_DIRS"): + return + terminfo_fallback_directories = ( + Path("/run/current-system/sw/share/terminfo"), + Path("/usr/share/terminfo"), + Path("/etc/terminfo"), + Path("/lib/terminfo"), + ) + + existing_directories = [str(directory) for directory in terminfo_fallback_directories if directory.is_dir()] + if existing_directories: + environ["TERMINFO_DIRS"] = ":".join(existing_directories) + + def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None: """Partition a disk. @@ -299,8 +322,8 @@ def installer( check=True, ) - # Fixed mount point for the new system; the installer runs as root on a fresh disk - mnt_dir = "/tmp/nix_install" # noqa: S108 + # nixos-install rejects mount points under world-writable paths like /tmp + mnt_dir = "/mnt" Path(mnt_dir).mkdir(parents=True, exist_ok=True) @@ -336,10 +359,14 @@ def main(argv: Sequence[str] | None = None) -> None: logger.info("installer runtime dependencies are available") return + configure_terminal() state = curses.wrapper(draw_menu) encrypt_key = getenv("ENCRYPT_KEY") + if not encrypt_key: + encrypt_key = state.encryption_password + if not state.selected_device_ids: logger.error("No disks selected; exiting without installing") sys.exit(1) diff --git a/python/installer/tui.py b/python/installer/tui.py index d66efc9..e8f0698 100644 --- a/python/installer/tui.py +++ b/python/installer/tui.py @@ -10,6 +10,8 @@ from python.process import run_output logger = logging.getLogger(__name__) +BYTE_MAX = 255 + class Cursor: """Cursor class.""" @@ -101,6 +103,9 @@ class State: self.reserve_size = 0 self.show_reserve_input = False + self.encryption_password = None + self.show_encryption_password_input = False + self.selected_device_ids: set[str] = set() def get_selected_devices(self) -> tuple[str, ...]: @@ -155,7 +160,22 @@ def debug_menu(std_screen: curses.window, key: int) -> None: std_screen.addstr(height - 2, i * 3, f"{i}██", curses.color_pair(i)) -def get_text_input(std_screen: curses.window, prompt: str, y: int, x: int) -> str: +def draw_input_line(std_screen: curses.window, prompt: str, input_str: str, y: int, x: int, mask: str | None) -> None: + """Draw an input line without leaking masked values.""" + _, width = std_screen.getmaxyx() + displayed_input = input_str if mask is None else mask * len(input_str) + line = f"{prompt}{displayed_input}" + available_width = max(0, width - x - 1) + + std_screen.move(y, x) + if available_width > 0: + std_screen.addstr(y, x, line[:available_width]) + std_screen.clrtoeol() + std_screen.move(y, min(x + len(line), width - 1)) + std_screen.refresh() + + +def get_text_input(std_screen: curses.window, prompt: str, y: int, x: int, mask: str | None = None) -> str | None: """Get text input. Args: @@ -163,27 +183,27 @@ def get_text_input(std_screen: curses.window, prompt: str, y: int, x: int) -> st prompt (str): The prompt. y (int): The y position. x (int): The x position. + mask (str | None, optional): The character used to mask displayed input. Defaults to None. Returns: - str: The input string. + str | None: The input string, or None if input was cancelled. """ esc_key = 27 - curses.echo() - std_screen.addstr(y, x, prompt) + curses.noecho() input_str = "" + draw_input_line(std_screen, prompt, input_str, y, x, mask) while True: key = std_screen.getch() - if key == ord("\n"): + if key in (ord("\n"), ord("\r")): break if key == esc_key: - input_str = "" - break + curses.noecho() + return None if key in (curses.KEY_BACKSPACE, ord("\b"), 127): input_str = input_str[:-1] - std_screen.addstr(y, x + len(prompt), input_str + " ") - else: + elif 0 <= key <= BYTE_MAX: input_str += chr(key) - std_screen.refresh() + draw_input_line(std_screen, prompt, input_str, y, x, mask) curses.noecho() return input_str @@ -210,6 +230,9 @@ def swap_size_input( if state.show_swap_input: swap_size_str = get_text_input(std_screen, swap_size_text, swap_offset, 0) + if swap_size_str is None: + state.show_swap_input = False + return state try: state.swap_size = int(swap_size_str) state.show_swap_input = False @@ -243,6 +266,9 @@ def reserve_size_input( if state.show_reserve_input: reserve_size_str = get_text_input(std_screen, reserve_size_text, reserve_offset, 0) + if reserve_size_str is None: + state.show_reserve_input = False + return state try: state.reserve_size = int(reserve_size_str) state.show_reserve_input = False @@ -254,6 +280,37 @@ def reserve_size_input( return state +def encryption_password_input( + std_screen: curses.window, + state: State, + password_offset: int, +) -> State: + """Encryption password input. + + Args: + std_screen (curses.window): The curses window. + state (State): The state object. + password_offset (int): The password offset. + + Returns: + State: The updated state object. + """ + password_status = "set" if state.encryption_password else "unset" + encryption_label = "Encryption password: " + encryption_prompt = "Encryption password (blank disables LUKS): " + std_screen.addstr(password_offset, 0, f"{encryption_label}{password_status}") + if state.key == ord("\n") and state.cursor.get_y() == password_offset: + state.show_encryption_password_input = True + + if state.show_encryption_password_input: + password = get_text_input(std_screen, encryption_prompt, password_offset, 0) + if password is not None: + state.encryption_password = password + state.show_encryption_password_input = False + + return state + + def status_bar( std_screen: curses.window, cursor: Cursor, @@ -464,6 +521,11 @@ def draw_menu(std_screen: curses.window) -> State: state=state, reserve_offset=swap_offset + 1, ) + encryption_password_input( + std_screen=std_screen, + state=state, + password_offset=swap_offset + 2, + ) status_bar(std_screen, state.cursor, width, height) diff --git a/systems/iso/default.nix b/systems/iso/default.nix index b8399aa..d0f5e2f 100644 --- a/systems/iso/default.nix +++ b/systems/iso/default.nix @@ -24,11 +24,32 @@ networking.hostName = "installer"; + # On flake-built systems resolves through the flake registry, + # so nixos-install fails without these features enabled. + nix.settings.experimental-features = [ + "flakes" + "nix-command" + ]; + environment.systemPackages = [ outputs.packages.${pkgs.stdenv.hostPlatform.system}.installer-nixos ]; + # Live-media only: sshd is already enabled by the installation profile, + # but ssh logins need a non-empty password. + users.users = { + nixos = { + password = "nixos"; + initialHashedPassword = lib.mkForce null; + }; + root = { + password = "nixos"; + initialHashedPassword = lib.mkForce null; + }; + }; + services.getty.helpLine = '' Run "sudo nixos-installer" to install NixOS onto a ZFS root pool. + SSH is enabled; the "nixos" and "root" passwords are "nixos". ''; }