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
+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)