feat(installer): enhance NixOS installer with SSH access and encryption password input

This commit is contained in:
2026-07-02 14:17:49 -04:00
committed by Richie
parent b252b0bf6e
commit ddea13b93a
4 changed files with 125 additions and 15 deletions
+2 -2
View File
@@ -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
+30 -3
View File
@@ -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)
+72 -10
View File
@@ -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)
+21
View File
@@ -24,11 +24,32 @@
networking.hostName = "installer";
# On flake-built systems <nixpkgs> 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".
'';
}