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
|
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
|
```sh
|
||||||
sudo nixos-installer
|
sudo nixos-installer
|
||||||
@@ -42,7 +42,7 @@ Validate the live environment first with:
|
|||||||
./nixos-installer --check
|
./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
|
```sh
|
||||||
sudo env ENCRYPT_KEY='change-me' ./nixos-installer
|
sudo env ENCRYPT_KEY='change-me' ./nixos-installer
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import curses
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
from os import getenv
|
from os import environ, getenv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from random import getrandbits
|
from random import getrandbits
|
||||||
from subprocess import run
|
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:
|
def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None:
|
||||||
"""Partition a disk.
|
"""Partition a disk.
|
||||||
|
|
||||||
@@ -299,8 +322,8 @@ def installer(
|
|||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fixed mount point for the new system; the installer runs as root on a fresh disk
|
# nixos-install rejects mount points under world-writable paths like /tmp
|
||||||
mnt_dir = "/tmp/nix_install" # noqa: S108
|
mnt_dir = "/mnt"
|
||||||
|
|
||||||
Path(mnt_dir).mkdir(parents=True, exist_ok=True)
|
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")
|
logger.info("installer runtime dependencies are available")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
configure_terminal()
|
||||||
state = curses.wrapper(draw_menu)
|
state = curses.wrapper(draw_menu)
|
||||||
|
|
||||||
encrypt_key = getenv("ENCRYPT_KEY")
|
encrypt_key = getenv("ENCRYPT_KEY")
|
||||||
|
|
||||||
|
if not encrypt_key:
|
||||||
|
encrypt_key = state.encryption_password
|
||||||
|
|
||||||
if not state.selected_device_ids:
|
if not state.selected_device_ids:
|
||||||
logger.error("No disks selected; exiting without installing")
|
logger.error("No disks selected; exiting without installing")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
+72
-10
@@ -10,6 +10,8 @@ from python.process import run_output
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BYTE_MAX = 255
|
||||||
|
|
||||||
|
|
||||||
class Cursor:
|
class Cursor:
|
||||||
"""Cursor class."""
|
"""Cursor class."""
|
||||||
@@ -101,6 +103,9 @@ class State:
|
|||||||
self.reserve_size = 0
|
self.reserve_size = 0
|
||||||
self.show_reserve_input = False
|
self.show_reserve_input = False
|
||||||
|
|
||||||
|
self.encryption_password = None
|
||||||
|
self.show_encryption_password_input = False
|
||||||
|
|
||||||
self.selected_device_ids: set[str] = set()
|
self.selected_device_ids: set[str] = set()
|
||||||
|
|
||||||
def get_selected_devices(self) -> tuple[str, ...]:
|
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))
|
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.
|
"""Get text input.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -163,27 +183,27 @@ def get_text_input(std_screen: curses.window, prompt: str, y: int, x: int) -> st
|
|||||||
prompt (str): The prompt.
|
prompt (str): The prompt.
|
||||||
y (int): The y position.
|
y (int): The y position.
|
||||||
x (int): The x position.
|
x (int): The x position.
|
||||||
|
mask (str | None, optional): The character used to mask displayed input. Defaults to None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The input string.
|
str | None: The input string, or None if input was cancelled.
|
||||||
"""
|
"""
|
||||||
esc_key = 27
|
esc_key = 27
|
||||||
curses.echo()
|
curses.noecho()
|
||||||
std_screen.addstr(y, x, prompt)
|
|
||||||
input_str = ""
|
input_str = ""
|
||||||
|
draw_input_line(std_screen, prompt, input_str, y, x, mask)
|
||||||
while True:
|
while True:
|
||||||
key = std_screen.getch()
|
key = std_screen.getch()
|
||||||
if key == ord("\n"):
|
if key in (ord("\n"), ord("\r")):
|
||||||
break
|
break
|
||||||
if key == esc_key:
|
if key == esc_key:
|
||||||
input_str = ""
|
curses.noecho()
|
||||||
break
|
return None
|
||||||
if key in (curses.KEY_BACKSPACE, ord("\b"), 127):
|
if key in (curses.KEY_BACKSPACE, ord("\b"), 127):
|
||||||
input_str = input_str[:-1]
|
input_str = input_str[:-1]
|
||||||
std_screen.addstr(y, x + len(prompt), input_str + " ")
|
elif 0 <= key <= BYTE_MAX:
|
||||||
else:
|
|
||||||
input_str += chr(key)
|
input_str += chr(key)
|
||||||
std_screen.refresh()
|
draw_input_line(std_screen, prompt, input_str, y, x, mask)
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
return input_str
|
return input_str
|
||||||
|
|
||||||
@@ -210,6 +230,9 @@ def swap_size_input(
|
|||||||
|
|
||||||
if state.show_swap_input:
|
if state.show_swap_input:
|
||||||
swap_size_str = get_text_input(std_screen, swap_size_text, swap_offset, 0)
|
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:
|
try:
|
||||||
state.swap_size = int(swap_size_str)
|
state.swap_size = int(swap_size_str)
|
||||||
state.show_swap_input = False
|
state.show_swap_input = False
|
||||||
@@ -243,6 +266,9 @@ def reserve_size_input(
|
|||||||
|
|
||||||
if state.show_reserve_input:
|
if state.show_reserve_input:
|
||||||
reserve_size_str = get_text_input(std_screen, reserve_size_text, reserve_offset, 0)
|
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:
|
try:
|
||||||
state.reserve_size = int(reserve_size_str)
|
state.reserve_size = int(reserve_size_str)
|
||||||
state.show_reserve_input = False
|
state.show_reserve_input = False
|
||||||
@@ -254,6 +280,37 @@ def reserve_size_input(
|
|||||||
return state
|
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(
|
def status_bar(
|
||||||
std_screen: curses.window,
|
std_screen: curses.window,
|
||||||
cursor: Cursor,
|
cursor: Cursor,
|
||||||
@@ -464,6 +521,11 @@ def draw_menu(std_screen: curses.window) -> State:
|
|||||||
state=state,
|
state=state,
|
||||||
reserve_offset=swap_offset + 1,
|
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)
|
status_bar(std_screen, state.cursor, width, height)
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,32 @@
|
|||||||
|
|
||||||
networking.hostName = "installer";
|
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 = [
|
environment.systemPackages = [
|
||||||
outputs.packages.${pkgs.stdenv.hostPlatform.system}.installer-nixos
|
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 = ''
|
services.getty.helpLine = ''
|
||||||
Run "sudo nixos-installer" to install NixOS onto a ZFS root pool.
|
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