feat(installer): enhance NixOS installer with SSH access and encryption password input
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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".
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user