Working "shill From Home" full network boot
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{ lib, pkgs, config, systems, ... }:
|
||||
{ lib, pkgs, config, ... }:
|
||||
let
|
||||
inherit (lib) mkMerge mkIf mkForce mkOption;
|
||||
inherit (lib) mkMerge mkIf mkForce genAttrs concatMapStringsSep;
|
||||
inherit (lib.my) mkOpt' mkBoolOpt';
|
||||
|
||||
cfg = config.my.netboot;
|
||||
@@ -16,31 +16,54 @@ let
|
||||
} ''
|
||||
substituteAll ${./menu.ipxe} "$out"
|
||||
'';
|
||||
|
||||
bootBuilder = pkgs.substituteAll {
|
||||
src = ./netboot-loader-builder.py;
|
||||
isExecutable = true;
|
||||
|
||||
inherit (pkgs) python3;
|
||||
bootspecTools = pkgs.bootspec;
|
||||
nix = config.nix.package.out;
|
||||
|
||||
inherit (config.system.nixos) distroName;
|
||||
systemName = config.system.name;
|
||||
inherit (cfg.client) configurationLimit;
|
||||
checkMountpoints = pkgs.writeShellScript "check-mountpoints" ''
|
||||
if ! ${pkgs.util-linuxMinimal}/bin/findmnt /boot > /dev/null; then
|
||||
echo "/boot is not a mounted partition. Is the path configured correctly?" >&2
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.my.netboot = with lib.types; {
|
||||
client = {
|
||||
enable = mkBoolOpt' false "Whether network booting should be enabled.";
|
||||
configurationLimit = mkOpt' ints.unsigned 10 "Max generations to show in boot menu.";
|
||||
};
|
||||
server = {
|
||||
enable = mkBoolOpt' false "Whether a netboot server should be enabled.";
|
||||
ip = mkOpt' str null "IP clients should connect to via TFTP.";
|
||||
host = mkOpt' str config.networking.fqdn "Hostname clients should connect to over HTTP.";
|
||||
host = mkOpt' str config.networking.fqdn "Hostname clients should connect to over HTTP / NFS.";
|
||||
allowedPrefixes = mkOpt' (listOf str) null "Prefixes clients should be allowed to connect from (NFS).";
|
||||
installer = {
|
||||
storeSize = mkOpt' str "16GiB" "Total allowed writable size of store.";
|
||||
};
|
||||
instances = mkOpt' (listOf str) [ ] "Systems to hold boot files for.";
|
||||
keaClientClasses = mkOption {
|
||||
type = listOf (attrsOf str);
|
||||
description = "Kea client classes for PXE boot.";
|
||||
readOnly = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = mkMerge [
|
||||
(mkIf cfg.client.enable {
|
||||
# TODO: Implement!
|
||||
boot.loader = {
|
||||
grub.enable = false;
|
||||
systemd-boot.enable = false;
|
||||
};
|
||||
system = {
|
||||
build.installBootLoader = bootBuilder;
|
||||
boot.loader.id = "ipxe-netboot";
|
||||
};
|
||||
})
|
||||
(mkIf cfg.server.enable {
|
||||
environment = {
|
||||
@@ -51,14 +74,21 @@ in
|
||||
};
|
||||
|
||||
systemd = {
|
||||
tmpfiles.settings."10-netboot" = genAttrs
|
||||
(map (i: "/srv/netboot/systems/${i}") cfg.server.instances)
|
||||
(p: {
|
||||
d = {
|
||||
user = "root";
|
||||
group = "root";
|
||||
mode = "0777";
|
||||
};
|
||||
});
|
||||
|
||||
services = {
|
||||
netboot-update = {
|
||||
description = "Update netboot images";
|
||||
after = [ "systemd-networkd-wait-online.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
serviceConfig.Type = "oneshot";
|
||||
path = with pkgs; [
|
||||
coreutils curl jq zstd gnutar
|
||||
];
|
||||
@@ -136,6 +166,15 @@ in
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nfs = {
|
||||
server = {
|
||||
enable = true;
|
||||
exports = ''
|
||||
/srv/netboot/systems ${concatMapStringsSep " " (p: "${p}(rw,all_squash)") cfg.server.allowedPrefixes}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
my = {
|
||||
@@ -143,22 +182,6 @@ in
|
||||
"/srv/netboot"
|
||||
{ directory = "/var/cache/netboot"; mode = "0700"; }
|
||||
];
|
||||
netboot.server.keaClientClasses = [
|
||||
{
|
||||
name = "ipxe";
|
||||
test = "substring(option[user-class].hex, 0, 4) == 'iPXE'";
|
||||
next-server = cfg.server.ip;
|
||||
server-hostname = cfg.server.host;
|
||||
boot-file-name = "http://${cfg.server.host}/boot.ipxe";
|
||||
}
|
||||
{
|
||||
name = "efi-x86_64";
|
||||
test = "option[client-system].hex == 0x0007";
|
||||
next-server = cfg.server.ip;
|
||||
server-hostname = cfg.server.host;
|
||||
boot-file-name = "ipxe-x86_64.efi";
|
||||
}
|
||||
];
|
||||
};
|
||||
})
|
||||
];
|
||||
|
280
nixos/modules/netboot/netboot-loader-builder.py
Executable file
280
nixos/modules/netboot/netboot-loader-builder.py
Executable file
@@ -0,0 +1,280 @@
|
||||
#! @python3@/bin/python3 -B
|
||||
# Based on `nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py`
|
||||
import argparse
|
||||
import datetime
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import json
|
||||
from typing import NamedTuple, Dict, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
BOOT_MOUNT_POINT = '/boot'
|
||||
STORE_DIR = 'nix'
|
||||
|
||||
# These values will be replaced with actual values during the package build
|
||||
BOOTSPEC_TOOLS = '@bootspecTools@'
|
||||
NIX = '@nix@'
|
||||
DISTRO_NAME = '@distroName@'
|
||||
SYSTEM_NAME = '@systemName@'
|
||||
CONFIGURATION_LIMIT = int('@configurationLimit@')
|
||||
CHECK_MOUNTPOINTS = "@checkMountpoints@"
|
||||
|
||||
@dataclass
|
||||
class BootSpec:
|
||||
init: str
|
||||
initrd: str
|
||||
kernel: str
|
||||
kernelParams: List[str]
|
||||
label: str
|
||||
system: str
|
||||
toplevel: str
|
||||
specialisations: Dict[str, 'BootSpec']
|
||||
sortKey: str
|
||||
initrdSecrets: str | None = None
|
||||
|
||||
class SystemIdentifier(NamedTuple):
|
||||
profile: str | None
|
||||
generation: int
|
||||
specialisation: str | None
|
||||
|
||||
def copy_if_not_exists(source: str, dest: str) -> None:
|
||||
if not os.path.exists(dest):
|
||||
shutil.copyfile(source, dest)
|
||||
|
||||
def generation_dir(profile: str | None, generation: int) -> str:
|
||||
if profile:
|
||||
return f'/nix/var/nix/profiles/system-profiles/{profile}-{generation}-link'
|
||||
else:
|
||||
return f'/nix/var/nix/profiles/system-{generation}-link'
|
||||
|
||||
def system_dir(i: SystemIdentifier) -> str:
|
||||
d = generation_dir(i.profile, i.generation)
|
||||
if i.specialisation:
|
||||
return os.path.join(d, 'specialisation', i.specialisation)
|
||||
else:
|
||||
return d
|
||||
|
||||
def entry_key(i: SystemIdentifier) -> str:
|
||||
pieces = [
|
||||
'nixos',
|
||||
i.profile or None,
|
||||
'generation',
|
||||
str(i.generation),
|
||||
f'specialisation-{i.specialisation}' if i.specialisation else None,
|
||||
]
|
||||
return '-'.join(p for p in pieces if p)
|
||||
|
||||
def bootspec_from_json(bootspec_json: Dict) -> BootSpec:
|
||||
specialisations = bootspec_json['org.nixos.specialisation.v1']
|
||||
specialisations = {k: bootspec_from_json(v) for k, v in specialisations.items()}
|
||||
systemdBootExtension = bootspec_json.get('org.nixos.systemd-boot', {})
|
||||
sortKey = systemdBootExtension.get('sortKey', 'nixos')
|
||||
return BootSpec(
|
||||
**bootspec_json['org.nixos.bootspec.v1'],
|
||||
specialisations=specialisations,
|
||||
sortKey=sortKey
|
||||
)
|
||||
|
||||
bootspecs = {}
|
||||
def get_bootspec(profile: str | None, generation: int) -> BootSpec:
|
||||
k = (profile, generation)
|
||||
if k in bootspecs:
|
||||
return bootspecs[k]
|
||||
|
||||
system_directory = system_dir(SystemIdentifier(profile, generation, None))
|
||||
boot_json_path = os.path.realpath(f'{system_directory}/boot.json')
|
||||
if os.path.isfile(boot_json_path):
|
||||
boot_json_f = open(boot_json_path, 'r')
|
||||
bootspec_json = json.load(boot_json_f)
|
||||
else:
|
||||
boot_json_str = subprocess.check_output([
|
||||
f'{BOOTSPEC_TOOLS}/bin/synthesize',
|
||||
'--version',
|
||||
'1',
|
||||
system_directory,
|
||||
'/dev/stdout',
|
||||
],
|
||||
universal_newlines=True)
|
||||
bootspec_json = json.loads(boot_json_str)
|
||||
|
||||
bs = bootspec_from_json(bootspec_json)
|
||||
bootspecs[k] = bs
|
||||
return bs
|
||||
|
||||
def copy_from_file(file: str, dry_run: bool = False) -> str:
|
||||
store_file_path = os.path.realpath(file)
|
||||
suffix = os.path.basename(store_file_path)
|
||||
store_dir = os.path.basename(os.path.dirname(store_file_path))
|
||||
dst_path = f'/{STORE_DIR}/{store_dir}-{suffix}'
|
||||
if not dry_run:
|
||||
copy_if_not_exists(store_file_path, f'{BOOT_MOUNT_POINT}{dst_path}')
|
||||
return dst_path
|
||||
|
||||
MENU_ITEM = 'item {gen_key} {title} Generation {generation} {description}'
|
||||
|
||||
BOOT_ENTRY = ''':{gen_key}
|
||||
kernel ${{server}}/systems/{system_name}{kernel} {kernel_params} boothost=${{boothost}}
|
||||
initrd ${{server}}/systems/{system_name}{initrd}
|
||||
boot
|
||||
'''
|
||||
|
||||
def gen_entry(i: SystemIdentifier) -> (str, str):
|
||||
bootspec = get_bootspec(i.profile, i.generation)
|
||||
if i.specialisation:
|
||||
bootspec = bootspec.specialisations[i.specialisation]
|
||||
kernel = copy_from_file(bootspec.kernel)
|
||||
initrd = copy_from_file(bootspec.initrd)
|
||||
|
||||
gen_key = entry_key(i)
|
||||
title = '{name}{profile}{specialisation}'.format(
|
||||
name=DISTRO_NAME,
|
||||
profile=' [' + i.profile + ']' if i.profile else '',
|
||||
specialisation=f' ({i.specialisation})' if i.specialisation else '')
|
||||
|
||||
kernel_params = f'init={bootspec.init} '
|
||||
|
||||
kernel_params = kernel_params + ' '.join(bootspec.kernelParams)
|
||||
build_time = int(os.path.getctime(system_dir(i)))
|
||||
build_date = datetime.datetime.fromtimestamp(build_time).strftime('%F')
|
||||
|
||||
return MENU_ITEM.format(
|
||||
gen_key=gen_key,
|
||||
title=title,
|
||||
description=f'{bootspec.label}, built on {build_date}',
|
||||
generation=i.generation,
|
||||
), BOOT_ENTRY.format(
|
||||
gen_key=gen_key,
|
||||
generation=i.generation,
|
||||
system_name=SYSTEM_NAME,
|
||||
kernel=kernel,
|
||||
kernel_params=kernel_params,
|
||||
initrd=initrd,
|
||||
)
|
||||
|
||||
def get_generations(profile: str | None = None) -> list[SystemIdentifier]:
|
||||
gen_list = subprocess.check_output([
|
||||
f'{NIX}/bin/nix-env',
|
||||
'--list-generations',
|
||||
'-p',
|
||||
'/nix/var/nix/profiles/' + ('system-profiles/' + profile if profile else 'system')],
|
||||
universal_newlines=True)
|
||||
gen_lines = gen_list.split('\n')
|
||||
gen_lines.pop()
|
||||
|
||||
configurationLimit = CONFIGURATION_LIMIT
|
||||
configurations = [
|
||||
SystemIdentifier(
|
||||
profile=profile,
|
||||
generation=int(line.split()[0]),
|
||||
specialisation=None
|
||||
)
|
||||
for line in gen_lines
|
||||
]
|
||||
return configurations[-configurationLimit:]
|
||||
|
||||
def remove_old_files(gens: list[SystemIdentifier]) -> None:
|
||||
known_paths = []
|
||||
for gen in gens:
|
||||
bootspec = get_bootspec(gen.profile, gen.generation)
|
||||
known_paths.append(copy_from_file(bootspec.kernel, True))
|
||||
known_paths.append(copy_from_file(bootspec.initrd, True))
|
||||
for path in glob.iglob(f'{BOOT_MOUNT_POINT}/{STORE_DIR}/*'):
|
||||
if not path in known_paths and not os.path.isdir(path):
|
||||
os.unlink(path)
|
||||
|
||||
def get_profiles() -> list[str]:
|
||||
if os.path.isdir('/nix/var/nix/profiles/system-profiles/'):
|
||||
return [x
|
||||
for x in os.listdir('/nix/var/nix/profiles/system-profiles/')
|
||||
if not x.endswith('-link')]
|
||||
else:
|
||||
return []
|
||||
|
||||
MENU = '''#!ipxe
|
||||
# Server hostname option
|
||||
set boothost ${{66:string}}
|
||||
set server http://${{boothost}}
|
||||
|
||||
:start
|
||||
menu {distro} boot menu
|
||||
item --gap -- Generations
|
||||
{generation_items}
|
||||
item --gap -- Other
|
||||
item --key m main Main netboot menu
|
||||
choose --timeout 5000 --default {menu_default} selected || goto cancel
|
||||
goto ${{selected}}
|
||||
|
||||
:cancel
|
||||
shell
|
||||
goto start
|
||||
|
||||
:error
|
||||
echo Booting failed, dropping to shell
|
||||
shell
|
||||
goto start
|
||||
|
||||
:main
|
||||
chain ${{server}}/boot.ipxe || goto error
|
||||
'''
|
||||
|
||||
def write_menu(gens: list[SystemIdentifier], default: SystemIdentifier) -> None:
|
||||
gen_menu_items = []
|
||||
gen_cmds = []
|
||||
|
||||
for g in gens:
|
||||
bootspec = get_bootspec(g.profile, g.generation)
|
||||
specialisations = [
|
||||
SystemIdentifier(profile=g.profile, generation=g.generation, specialisation=s) for s in bootspec.specialisations]
|
||||
for i in [g] + specialisations:
|
||||
mi, cmds = gen_entry(i)
|
||||
gen_menu_items.append(mi)
|
||||
gen_cmds.append(cmds)
|
||||
|
||||
menu_file = f'{BOOT_MOUNT_POINT}/menu.ipxe'
|
||||
with open(f'{menu_file}.tmp', 'w') as f:
|
||||
f.write(MENU.format(
|
||||
distro=DISTRO_NAME,
|
||||
generation_items='\n'.join(gen_menu_items),
|
||||
menu_default=entry_key(default),
|
||||
))
|
||||
|
||||
print(file=f)
|
||||
print('\n\n'.join(gen_cmds), file=f)
|
||||
|
||||
os.rename(f'{menu_file}.tmp', menu_file)
|
||||
|
||||
def install_bootloader(args: argparse.Namespace) -> None:
|
||||
os.makedirs(f'{BOOT_MOUNT_POINT}/{STORE_DIR}', exist_ok=True)
|
||||
|
||||
gens = get_generations()
|
||||
for profile in get_profiles():
|
||||
gens += get_generations(profile)
|
||||
|
||||
gens = sorted(gens, key=lambda g: entry_key(g), reverse=True)
|
||||
|
||||
remove_old_files(gens)
|
||||
|
||||
for g in gens:
|
||||
if os.path.dirname(get_bootspec(g.profile, g.generation).init) == os.path.realpath(args.default_config):
|
||||
default = g
|
||||
break
|
||||
else:
|
||||
assert False, 'No default generation found'
|
||||
|
||||
write_menu(gens, default)
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=f'Update {DISTRO_NAME}-related netboot files')
|
||||
parser.add_argument('default_config', metavar='DEFAULT-CONFIG', help=f'The default {DISTRO_NAME} config to boot')
|
||||
args = parser.parse_args()
|
||||
|
||||
subprocess.check_call(CHECK_MOUNTPOINTS)
|
||||
|
||||
install_bootloader(args)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
Reference in New Issue
Block a user