From 5fa86c7cefd7498a6b36e67ad762270ed84b75e5 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 10 May 2024 02:52:31 +0200 Subject: [PATCH 1/6] refactor: bash+jq instead of python closure --- nix/kexec-installer/module.nix | 5 +- nix/kexec-installer/restore_routes.py | 118 ------------------------- nix/kexec-installer/restore_routes.sh | 121 ++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 120 deletions(-) delete mode 100644 nix/kexec-installer/restore_routes.py create mode 100755 nix/kexec-installer/restore_routes.sh diff --git a/nix/kexec-installer/module.nix b/nix/kexec-installer/module.nix index a10e6a5..5ae42c2 100644 --- a/nix/kexec-installer/module.nix +++ b/nix/kexec-installer/module.nix @@ -1,7 +1,7 @@ { config, lib, modulesPath, pkgs, ... }: let - restore-network = pkgs.writers.writePython3 "restore-network" { flakeIgnore = [ "E501" ]; } - ./restore_routes.py; + + restore-network = pkgs.writers.writeBash "restore-network" ./restore_routes.sh; # does not link with iptables enabled iprouteStatic = pkgs.pkgsStatic.iproute2.override { iptables = null; }; @@ -56,6 +56,7 @@ in environment.etc.is_kexec.text = "true"; systemd.services.restore-network = { + path = [pkgs.jq]; before = [ "network-pre.target" ]; wants = [ "network-pre.target" ]; wantedBy = [ "multi-user.target" ]; diff --git a/nix/kexec-installer/restore_routes.py b/nix/kexec-installer/restore_routes.py deleted file mode 100644 index 1635376..0000000 --- a/nix/kexec-installer/restore_routes.py +++ /dev/null @@ -1,118 +0,0 @@ -import json -import sys -from pathlib import Path -from typing import Any - - -def filter_interfaces(network: list[dict[str, Any]]) -> list[dict[str, Any]]: - output = [] - for net in network: - if net.get("link_type") == "loopback": - continue - if not net.get("address"): - # We need a mac address to match devices reliable - continue - addr_info = [] - has_dynamic_address = False - for addr in net.get("addr_info", []): - # no link-local ipv4/ipv6 - if addr.get("scope") == "link": - continue - # do not explicitly configure addresses from dhcp or router advertisement - if addr.get("dynamic", False): - has_dynamic_address = True - continue - else: - addr_info.append(addr) - if addr_info != [] or has_dynamic_address: - net["addr_info"] = addr_info - output.append(net) - - return output - - -def filter_routes(routes: list[dict[str, Any]]) -> list[dict[str, Any]]: - filtered = [] - for route in routes: - # Filter out routes set by addresses with subnets, dhcp and router advertisement - if route.get("protocol") in ["dhcp", "kernel", "ra"]: - continue - filtered.append(route) - - return filtered - - -def generate_networkd_units( - interfaces: list[dict[str, Any]], routes: list[dict[str, Any]], directory: Path -) -> None: - directory.mkdir(exist_ok=True) - for interface in interfaces: - name = f"00-{interface['ifname']}.network" - addresses = [ - f"Address = {addr['local']}/{addr['prefixlen']}" - for addr in interface.get("addr_info", []) - ] - - route_sections = [] - for route in routes: - if route.get("dev", "nodev") != interface.get("ifname", "noif"): - continue - - route_section = "[Route]\n" - if route.get("dst") != "default": - # can be skipped for default routes - route_section += f"Destination = {route['dst']}\n" - gateway = route.get("gateway") - if gateway: - route_section += f"Gateway = {gateway}\n" - - # we may ignore on-link default routes here, but I don't see how - # they would be useful for internet connectivity anyway - route_sections.append(route_section) - - # FIXME in some networks we might not want to trust dhcp or router advertisements - unit = f""" -[Match] -MACAddress = {interface["address"]} - -[Network] -# both ipv4 and ipv6 -DHCP = yes -# lets us discover the switch port we're connected to -LLDP = yes -# ipv6 router advertisements -IPv6AcceptRA = yes -# allows us to ping "nixos.local" -MulticastDNS = yes - -""" - unit += "\n".join(addresses) - unit += "\n" + "\n".join(route_sections) - (directory / name).write_text(unit) - - -def main() -> None: - if len(sys.argv) < 5: - print( - f"USAGE: {sys.argv[0]} addresses routes-v4 routes-v6 networkd-directory", - file=sys.stderr, - ) - sys.exit(1) - - with open(sys.argv[1]) as f: - addresses = json.load(f) - with open(sys.argv[2]) as f: - v4_routes = json.load(f) - with open(sys.argv[3]) as f: - v6_routes = json.load(f) - - networkd_directory = Path(sys.argv[4]) - - relevant_interfaces = filter_interfaces(addresses) - relevant_routes = filter_routes(v4_routes) + filter_routes(v6_routes) - - generate_networkd_units(relevant_interfaces, relevant_routes, networkd_directory) - - -if __name__ == "__main__": - main() diff --git a/nix/kexec-installer/restore_routes.sh b/nix/kexec-installer/restore_routes.sh new file mode 100755 index 0000000..f9ce3f8 --- /dev/null +++ b/nix/kexec-installer/restore_routes.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +# filter_interfaces function +filter_interfaces() { + # This function takes a list of network interfaces as input and filters + # out loopback interfaces, interfaces without a MAC address, and addresses + # with a "link" scope or marked as dynamic (from DHCP or router + # advertisements). The filtered interfaces are returned as an array. + local network=("$@") + + for net in "${network[@]}"; do + local link_type="$(jq -r '.link_type' <<< "$net")" + local address="$(jq -r '.address // ""' <<< "$net")" + local addr_info="$(jq -r '.addr_info | map(select(.scope != "link" and (.dynamic | not)))' <<< "$net")" + local has_dynamic_address=$(jq -r '.addr_info | any(.dynamic)' <<< "$net") + + # echo "Link Type: $link_type -- Address: $address -- Has Dynamic Address: $has_dynamic_address -- Addr Info: $addr_info" + + if [[ "$link_type" != "loopback" && -n "$address" && ("$addr_info" != "[]" || "$has_dynamic_address" == "true") ]]; then + net=$(jq -c --argjson addr_info "$addr_info" '.addr_info = $addr_info' <<< "$net") + echo "$net" # "return" + fi + done +} + +# filter_routes function +filter_routes() { + # This function takes a list of routes as input and filters out routes + # with protocols "dhcp", "kernel", or "ra". The filtered routes are + # returned as an array. + local routes=("$@") + + for route in "${routes[@]}"; do + local protocol=$(jq -r '.protocol' <<< "$route") + if [[ $protocol != "dhcp" && $protocol != "kernel" && $protocol != "ra" ]]; then + echo "$route" # "return" + fi + done +} + +# generate_networkd_units function +generate_networkd_units() { + # This function takes the filtered interfaces and routes, along with a + # directory path. It generates systemd-networkd unit files for each interface, + # including the configured addresses and routes. The unit files are written + # to the specified directory with the naming convention 00-.network. + local -n interfaces=$1 + local -n routes=$2 + local directory="$3" + + mkdir -p "$directory" + + for interface in "${interfaces[@]}"; do + local ifname=$(jq -r '.ifname' <<< "$interface") + local address=$(jq -r '.address' <<< "$interface") + local addresses=$(jq -r '.addr_info | map("Address = \(.local)/\(.prefixlen)") | join("\n")' <<< "$interface") + local route_sections=() + + for route in "${routes[@]}"; do + local dev=$(jq -r '.dev' <<< "$route") + if [[ $dev == $ifname ]]; then + local route_section="[Route]" + local dst=$(jq -r '.dst' <<< "$route") + if [[ $dst != "default" ]]; then + route_section+="\nDestination = $dst" + fi + local gateway=$(jq -r '.gateway // ""' <<< "$route") + if [[ -n $gateway ]]; then + route_section+="\nGateway = $gateway" + fi + route_sections+=("$route_section") + fi + done + + local unit=$(cat <<-EOF +[Match] +MACAddress = $address + +[Network] +DHCP = yes +LLDP = yes +IPv6AcceptRA = yes +MulticastDNS = yes + +$addresses +$(printf '%s\n' "${route_sections[@]}") +EOF +) + echo -e "$unit" > "$directory/00-$ifname.network" + done +} + +# main function +main() { + if [[ $# -lt 4 ]]; then + echo "USAGE: $0 addresses routes-v4 routes-v6 networkd-directory" >&2 + # exit 1 + return 1 + fi + + local addresses + readarray -t addresses < <(jq -c '.[]' "$1") # Read JSON data into array + + local v4_routes + readarray -t v4_routes < <(jq -c '.[]' "$2") + + local v6_routes + readarray -t v6_routes < <(jq -c '.[]' "$3") + + local networkd_directory="$4" + + local relevant_interfaces + readarray -t relevant_interfaces < <(filter_interfaces "${addresses[@]}") + + local relevant_routes + readarray -t relevant_routes < <(filter_routes "${v4_routes[@]}" "${v6_routes[@]}") + + generate_networkd_units relevant_interfaces relevant_routes "$networkd_directory" +} + +main "$@" From be435b1f2c2dd3469c3c5976adc7d2021c02a26a Mon Sep 17 00:00:00 2001 From: David Date: Fri, 10 May 2024 12:39:00 +0200 Subject: [PATCH 2/6] chore: anticipate expected delay in tests --- nix/kexec-installer/test.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nix/kexec-installer/test.nix b/nix/kexec-installer/test.nix index a11354d..25c507f 100644 --- a/nix/kexec-installer/test.nix +++ b/nix/kexec-installer/test.nix @@ -135,6 +135,8 @@ makeTest' { node1.succeed('/root/kexec/kexec --version >&2') node1.succeed('/root/kexec/run >&2') + time.sleep(6) + # wait for kexec to finish while ssh(["true"], check=False).returncode == 0: print("Waiting for kexec to finish...") From 87f8fb00e7d925f191b84dcb8b98130721057a88 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 10 May 2024 12:39:48 +0200 Subject: [PATCH 3/6] feat: free caches and log free mem before kexec --- nix/kexec-installer/kexec-run.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nix/kexec-installer/kexec-run.sh b/nix/kexec-installer/kexec-run.sh index 5d4a1ad..b099713 100644 --- a/nix/kexec-installer/kexec-run.sh +++ b/nix/kexec-installer/kexec-run.sh @@ -74,6 +74,9 @@ if ! "$SCRIPT_DIR/kexec" --load "$SCRIPT_DIR/bzImage" \ exit 1 fi +sync; echo 3 > /proc/sys/vm/drop_caches +echo "current available memory: $(free -h | awk '/^Mem/ {print $7}')" + # Disconnect our background kexec from the terminal echo "machine will boot into nixos in 6s..." if test -e /dev/kmsg; then From dcbc47c34b1800925ae1dce6e1e300d6b41ff627 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 10 May 2024 00:03:43 +0200 Subject: [PATCH 4/6] remove some extra weight --- nix/installer.nix | 43 +++++++++++++++++++++++++++++++- nix/kexec-installer/module.nix | 1 + nix/netboot-installer/module.nix | 1 + nix/no-grub.nix | 10 ++++++++ nix/noninteractive.nix | 3 +++ 5 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 nix/no-grub.nix diff --git a/nix/installer.nix b/nix/installer.nix index 3fed13d..5d17c7f 100644 --- a/nix/installer.nix +++ b/nix/installer.nix @@ -11,13 +11,54 @@ }).latestCompatibleLinuxPackages; boot.zfs.removeLinuxDRM = lib.mkDefault pkgs.hostPlatform.isAarch64; - documentation.enable = false; + documentation.enable = lib.mkForce false; + + # reduce closure size through package set crafting + # where there's no otherwise globally effective + # config setting available + # TODO: some are candidates for a long-term upstream solution + nixpkgs.overlays = [ + (final: prev: { + # save ~12MB by not bundling manpages + coreutils-full = prev.coreutils; + # save ~20MB by making them minimal + util-linux = prev.util-linux.override { + nlsSupport = false; + ncursesSupport = false; + systemdSupport = false; + translateManpages = false; + }; + # save ~6MB by removing one bash + bashInteractive = prev.bash; + # saves ~25MB + systemd = prev.systemd.override { + pname = "systemd-slim"; + withDocumentation = false; + withCoredump = false; + withFido2 = false; + withRepart = false; + withMachined = false; + withRemote = false; + withTpm2Tss = false; + withLibBPF = false; + withAudit = false; + withCompression = false; + withImportd = false; + withPortabled = false; + }; + }) + ]; + systemd.coredump.enable = false; + environment.systemPackages = [ # for zapping of disko pkgs.jq # for copying extra files of nixos-anywhere pkgs.rsync + # for installing nixos via nixos-anywhere + config.system.build.nixos-enter + config.system.build.nixos-install ]; imports = [ diff --git a/nix/kexec-installer/module.nix b/nix/kexec-installer/module.nix index 5ae42c2..9b9fcb5 100644 --- a/nix/kexec-installer/module.nix +++ b/nix/kexec-installer/module.nix @@ -13,6 +13,7 @@ in ../networkd.nix ../serial.nix ../restore-remote-access.nix + ../no-grub.nix ]; options = { system.kexec-installer.name = lib.mkOption { diff --git a/nix/netboot-installer/module.nix b/nix/netboot-installer/module.nix index 590bf56..3d1038e 100644 --- a/nix/netboot-installer/module.nix +++ b/nix/netboot-installer/module.nix @@ -6,6 +6,7 @@ ../networkd.nix ../serial.nix ../restore-remote-access.nix + ../no-grub.nix ]; # We are stateless, so just default to latest. diff --git a/nix/no-grub.nix b/nix/no-grub.nix new file mode 100644 index 0000000..3a5dad2 --- /dev/null +++ b/nix/no-grub.nix @@ -0,0 +1,10 @@ +{lib, ...}:{ + # when grub ends up being bloat: kexec & netboot + nixpkgs.overlays = [ + (final: prev: { + # we don't need grub: save ~ 60MB + grub2 = prev.coreutils; + grub2_efi = prev.coreutils; + }) + ]; +} diff --git a/nix/noninteractive.nix b/nix/noninteractive.nix index 3d39182..2f7f0cb 100644 --- a/nix/noninteractive.nix +++ b/nix/noninteractive.nix @@ -21,6 +21,9 @@ # would pull in nano programs.nano.syntaxHighlight = lib.mkForce false; + programs.nano.enable = false; + + documentation.man.man-db.enable = false; # prevents nano, strace environment.defaultPackages = lib.mkForce [ From 9bd468e67641dbf0b37fe7b8ff0fb4cc636c06dc Mon Sep 17 00:00:00 2001 From: David Date: Fri, 10 May 2024 16:02:29 +0200 Subject: [PATCH 5/6] chore: iterate systemd slim --- nix/installer.nix | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/nix/installer.nix b/nix/installer.nix index 5d17c7f..1281cae 100644 --- a/nix/installer.nix +++ b/nix/installer.nix @@ -45,6 +45,13 @@ withCompression = false; withImportd = false; withPortabled = false; + withSysupdate = false; + withHomed = false; + withLocaled = false; + withPolkit = false; + # withQrencode = false; + # withVmspawn = false; + withPasswordQuality = false; }; }) ]; From ced3ac00f1266af0a2880c286996d1d746b15c7e Mon Sep 17 00:00:00 2001 From: David Date: Fri, 10 May 2024 15:27:59 +0200 Subject: [PATCH 6/6] wip: unpearl --- nix/installer.nix | 1 + nix/setup-etc.c | 298 +++++++++++++++++++++++++++++++++++++ nix/un-pearl.nix | 56 +++++++ nix/update-users-groups.c | 81 ++++++++++ nix/update-users-groups.sh | 216 +++++++++++++++++++++++++++ 5 files changed, 652 insertions(+) create mode 100644 nix/setup-etc.c create mode 100644 nix/un-pearl.nix create mode 100644 nix/update-users-groups.c create mode 100644 nix/update-users-groups.sh diff --git a/nix/installer.nix b/nix/installer.nix index 1281cae..81ae210 100644 --- a/nix/installer.nix +++ b/nix/installer.nix @@ -70,6 +70,7 @@ imports = [ ./nix-settings.nix + ./un-pearl.nix ]; # Don't add nixpkgs to the image to save space, for our intended use case we don't need it diff --git a/nix/setup-etc.c b/nix/setup-etc.c new file mode 100644 index 0000000..1c50c56 --- /dev/null +++ b/nix/setup-etc.c @@ -0,0 +1,298 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_PATH 4096 +#define STATIC_PATH "/etc/static" +#define CLEAN_FILE "/etc/.clean" +#define NIXOS_TAG "/etc/NIXOS" + +int atomicSymlink(const char *source, const char *target) { + char tmp[MAX_PATH]; + snprintf(tmp, MAX_PATH, "%s.tmp", target); + unlink(tmp); + if (symlink(source, tmp) != 0) { + return 0; + } + if (rename(tmp, target) != 0) { + unlink(tmp); + return 0; + } + return 1; +} + +int isStatic(const char *path) { + char buf[MAX_PATH]; + struct stat st; + if (lstat(path, &st) != 0) { + return 0; + } + if (S_ISLNK(st.st_mode)) { + ssize_t len = readlink(path, buf, MAX_PATH); + if (len < 0 || len >= MAX_PATH) { + return 0; + } + buf[len] = '\0'; + return strncmp(buf, STATIC_PATH "/", strlen(STATIC_PATH) + 1) == 0; + } + if (S_ISDIR(st.st_mode)) { + DIR *dir = opendir(path); + if (dir == NULL) { + return 0; + } + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + char subpath[MAX_PATH]; + snprintf(subpath, MAX_PATH, "%s/%s", path, entry->d_name); + if (!isStatic(subpath)) { + closedir(dir); + return 0; + } + } + closedir(dir); + return 1; + } + return 0; +} + +void cleanup(const char *path) { + if (strcmp(path, "/etc/nixos") == 0) { + return; + } + struct stat st; + if (lstat(path, &st) != 0) { + return; + } + if (S_ISLNK(st.st_mode)) { + char buf[MAX_PATH]; + ssize_t len = readlink(path, buf, MAX_PATH); + if (len < 0 || len >= MAX_PATH) { + return; + } + buf[len] = '\0'; + if (strncmp(buf, STATIC_PATH "/", strlen(STATIC_PATH) + 1) == 0) { + char target[MAX_PATH]; + snprintf(target, MAX_PATH, "%s/%s", STATIC_PATH, path + strlen("/etc/")); + if (lstat(target, &st) != 0 || !S_ISLNK(st.st_mode)) { + printf("removing obsolete symlink '%s'...\n", path); + unlink(path); + } + } + } +} + +void createLink(const char *path, const char *etc_path) { + char fn[MAX_PATH]; + snprintf(fn, MAX_PATH, "%s", path + strlen(etc_path) + 1); + + if (strcmp(fn, "resolv.conf") == 0 && getenv("IN_NIXOS_ENTER") != NULL) { + return; + } + + char target[MAX_PATH]; + snprintf(target, MAX_PATH, "/etc/%s", fn); + char *dir = strdup(target); + char *last_slash = strrchr(dir, '/'); + *last_slash = '\0'; + mkdir(dir, 0755); + free(dir); + + char mode_path[MAX_PATH]; + snprintf(mode_path, MAX_PATH, "%s.mode", path); + FILE *mode_file = fopen(mode_path, "r"); + if (mode_file != NULL) { + char mode[16]; + if (fgets(mode, sizeof(mode), mode_file) != NULL) { + mode[strcspn(mode, "\n")] = '\0'; + if (strcmp(mode, "direct-symlink") == 0) { + char source[MAX_PATH]; + snprintf(source, MAX_PATH, "%s/%s", STATIC_PATH, fn); + char link_target[MAX_PATH]; + ssize_t len = readlink(source, link_target, MAX_PATH); + if (len < 0 || len >= MAX_PATH) { + fprintf(stderr, "could not read symlink %s\n", source); + } else { + link_target[len] = '\0'; + if (!atomicSymlink(link_target, target)) { + fprintf(stderr, "could not create symlink %s\n", target); + } + } + } else { + char uid_path[MAX_PATH]; + snprintf(uid_path, MAX_PATH, "%s.uid", path); + FILE *uid_file = fopen(uid_path, "r"); + if (uid_file == NULL) { + fprintf(stderr, "could not open %s\n", uid_path); + fclose(mode_file); + return; + } + char uid_str[32]; + if (fgets(uid_str, sizeof(uid_str), uid_file) == NULL) { + fprintf(stderr, "could not read %s\n", uid_path); + fclose(uid_file); + fclose(mode_file); + return; + } + uid_str[strcspn(uid_str, "\n")] = '\0'; + uid_t uid = uid_str[0] == '+' ? atoi(uid_str + 1) : getpwnam(uid_str) != NULL ? getpwnam(uid_str)->pw_uid : 0; + fclose(uid_file); + + char gid_path[MAX_PATH]; + snprintf(gid_path, MAX_PATH, "%s.gid", path); + FILE *gid_file = fopen(gid_path, "r"); + if (gid_file == NULL) { + fprintf(stderr, "could not open %s\n", gid_path); + fclose(mode_file); + return; + } + char gid_str[32]; + if (fgets(gid_str, sizeof(gid_str), gid_file) == NULL) { + fprintf(stderr, "could not read %s\n", gid_path); + fclose(gid_file); + fclose(mode_file); + return; + } + gid_str[strcspn(gid_str, "\n")] = '\0'; + gid_t gid = gid_str[0] == '+' ? atoi(gid_str + 1) : getgrnam(gid_str) != NULL ? getgrnam(gid_str)->gr_gid : 0; + fclose(gid_file); + + char tmp_path[MAX_PATH]; + snprintf(tmp_path, MAX_PATH, "%s.tmp", target); + char source[MAX_PATH]; + snprintf(source, MAX_PATH, "%s/%s", STATIC_PATH, fn); + FILE *source_file = fopen(source, "rb"); + if (source_file == NULL) { + fprintf(stderr, "could not open %s\n", source); + fclose(mode_file); + return; + } + FILE *tmp_file = fopen(tmp_path, "wb"); + if (tmp_file == NULL) { + fprintf(stderr, "could not create %s\n", tmp_path); + fclose(source_file); + fclose(mode_file); + return; + } + char buf[4096]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), source_file)) > 0) { + fwrite(buf, 1, n, tmp_file); + } + fclose(source_file); + fclose(tmp_file); + chmod(tmp_path, strtol(mode, NULL, 8)); + chown(tmp_path, uid, gid); + if (rename(tmp_path, target) != 0) { + fprintf(stderr, "could not create target %s\n", target); + unlink(tmp_path); + } + } + FILE *clean_file = fopen(CLEAN_FILE, "a"); + if (clean_file != NULL) { + fprintf(clean_file, "%s\n", fn); + fclose(clean_file); + } + } + fclose(mode_file); + } else { + char source[MAX_PATH]; + snprintf(source, MAX_PATH, "%s/%s", STATIC_PATH, fn); + if (!atomicSymlink(source, target)) { + fprintf(stderr, "could not create symlink %s\n", target); + } + } +} + +int main(int argc, char *argv[]) { + if (argc != 2) { + fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + + char *etc_path = argv[1]; + char static_path[MAX_PATH]; + snprintf(static_path, MAX_PATH, "%s", STATIC_PATH); + + if (!atomicSymlink(etc_path, static_path)) { + fprintf(stderr, "Failed to create symlink %s\n", static_path); + return 1; + } + + DIR *dir = opendir("/etc"); + if (dir == NULL) { + fprintf(stderr, "could not open /etc\n"); + return 1; + } + + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + char path[MAX_PATH]; + snprintf(path, MAX_PATH, "/etc/%s", entry->d_name); + cleanup(path); + } + closedir(dir); + + char *old_copied[4096]; + int old_copied_count = 0; + FILE *clean_file = fopen(CLEAN_FILE, "r"); + if (clean_file != NULL) { + char line[MAX_PATH]; + while (fgets(line, MAX_PATH, clean_file) != NULL) { + line[strcspn(line, "\n")] = '\0'; + old_copied[old_copied_count++] = strdup(line); + } + fclose(clean_file); + } + + dir = opendir(etc_path); + if (dir == NULL) { + fprintf(stderr, "could not open %s\n", etc_path); + return 1; + } + + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + char path[MAX_PATH]; + snprintf(path, MAX_PATH, "%s/%s", etc_path, entry->d_name); + createLink(path, etc_path); + } + closedir(dir); + + clean_file = fopen(CLEAN_FILE, "w"); + if (clean_file != NULL) { + for (int i = 0; i < old_copied_count; i++) { + char path[MAX_PATH]; + snprintf(path, MAX_PATH, "/etc/%s", old_copied[i]); + struct stat st; + if (lstat(path, &st) == 0) { + fprintf(clean_file, "%s\n", old_copied[i]); + } else { + printf("removing obsolete file '%s'...\n", path); + } + free(old_copied[i]); + } + fclose(clean_file); + } + + FILE *tag_file = fopen(NIXOS_TAG, "a"); + if (tag_file != NULL) { + fclose(tag_file); + } + + return 0; +} diff --git a/nix/un-pearl.nix b/nix/un-pearl.nix new file mode 100644 index 0000000..022a72c --- /dev/null +++ b/nix/un-pearl.nix @@ -0,0 +1,56 @@ +# This module attempts to be a workaround to +# remove the dependency of pearl from the +# crictical path of using kexec images +# in combination with nixos-anywhere + +# THIS IS NOT FUNCTIONAL +# POTENTIAL SAVING: ~80MB + +# TODO: +# - It's a proof of concept to calculate the saving (with nix-tree) +# - I used some AI to get the initial transcript in bash and c +# - If you, unlike me, know enough C please consider helping to finish the rewrite + +{ config, lib, pkgs, utils, ... }: { + nixpkgs.overlays = [ + (final: prev: { + # avoid perl ~50MB - dummy + syslinux = prev.coreutils; + }) + ]; + # avoid packages that depend on perl + system.disableInstallerTools = true; + system.switch.enable = false; + # reimplement in c to avoid dependency on perl + system.build.etcActivationCommands = let + setup-etc = pkgs.writeCBin "setup-etc" (builtins.readFile ./setup-etc.c); + etc = config.system.build.etc; + in lib.mkForce + '' + # Set up the statically computed bits of /etc. + echo "setting up /etc..." + ${setup-etc}/bin/setup-etc ${etc}/etc + ''; + system.activationScripts.users.text = let + cfg = config.users; + spec = pkgs.writeText "users-groups.json" (builtins.toJSON { + inherit (cfg) mutableUsers; + users = lib.mapAttrsToList (_: u: + { inherit (u) + name uid group description home homeMode createHome isSystemUser + password hashedPasswordFile hashedPassword + autoSubUidGidRange subUidRanges subGidRanges + initialPassword initialHashedPassword expires; + shell = utils.toShellPath u.shell; + }) cfg.users; + groups = builtins.attrValues cfg.groups; + }); + # update-users-groups = pkgs.writers.writeBashBin "update-users-groups" (builtins.readFile ./update-users-groups.sh); + update-users-groups = pkgs.writeCBin "update-users-groups" (builtins.readFile ./update-users-groups.c); + in lib.mkForce '' + install -m 0700 -d /root + install -m 0755 -d /home + + ${update-users-groups}/bin/update-users-groups ${spec} + ''; +} diff --git a/nix/update-users-groups.c b/nix/update-users-groups.c new file mode 100644 index 0000000..6703ba4 --- /dev/null +++ b/nix/update-users-groups.c @@ -0,0 +1,81 @@ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Function to allocate a free UID/GID +int allocId(int *used, int *prevUsed, int min, int max, int up) { + int id = up ? min : max; + while (id >= min && id <= max) { + if (!used[id] && !prevUsed[id]) { + used[id] = 1; + return id; + } + used[id] = 1; + if (up) { + id++; + } else { + id--; + } + } + printf("Out of free UIDs or GIDs\n"); + exit(1); +} + +// Function to allocate a free GID +int allocGid(char *name) { + int prevGid = getgrnam(name)->gr_gid; + if (prevGid && !used[prevGid]) { + printf("Reviving group '%s' with GID %d\n", name, prevGid); + used[prevGid] = 1; + return prevGid; + } + return allocId(used, prevUsed, 400, 999, 0); +} + +// Function to allocate a free UID +int allocUid(char *name, int isSystemUser) { + int min = isSystemUser ? 400 : 1000; + int max = isSystemUser ? 999 : 29999; + int up = isSystemUser ? 0 : 1; + int prevUid = getpwnam(name)->pw_uid; + if (prevUid >= min && prevUid <= max && !used[prevUid]) { + printf("Reviving user '%s' with UID %d\n", name, prevUid); + used[prevUid] = 1; + return prevUid; + } + return allocId(used, prevUsed, min, max, up); +} + +int main() { + // Initialize used and prevUsed arrays + int *used = (int *)malloc(sizeof(int) * 10000); + int *prevUsed = (int *)malloc(sizeof(int) * 10000); + for (int i = 0; i < 10000; i++) { + used[i] = 0; + prevUsed[i] = 0; + } + + // Read the declared users/groups + // ... + + // Allocate UIDs/GIDs + for (int i = 0; i < numUsers; i++) { + users[i]->uid = allocUid(users[i]->name, users[i]->isSystemUser); + } + for (int i = 0; i < numGroups; i++) { + groups[i]->gid = allocGid(groups[i]->name); + } + + // Update system files + // ... + + return 0; +} diff --git a/nix/update-users-groups.sh b/nix/update-users-groups.sh new file mode 100644 index 0000000..194e585 --- /dev/null +++ b/nix/update-users-groups.sh @@ -0,0 +1,216 @@ +#!/bin/bash + +set -euo pipefail + +# Keep track of deleted uids and gids +UID_MAP_FILE="/var/lib/nixos/uid-map" +GID_MAP_FILE="/var/lib/nixos/gid-map" +UID_MAP=$(jq -r 'to_entries|map("\(.key)=\(.value)")|.[]' < "$UID_MAP_FILE") +GID_MAP=$(jq -r 'to_entries|map("\(.key)=\(.value)")|.[]' < "$GID_MAP_FILE") + +DRY_RUN="${NIXOS_ACTION:-dry-activate}" + +mkdir -p "/var/lib/nixos" + +update_file() { + local path="$1" + local contents="$2" + local perms="${3:-0644}" + if [[ "$DRY_RUN" != "dry-activate" ]]; then + echo "$contents" > "$path" + chmod "$perms" "$path" + else + echo "Would update $path with permissions $perms" + fi +} + +nscd_invalidate() { + if [[ "$DRY_RUN" != "dry-activate" ]]; then + nscd --invalidate "$1" + else + echo "Would invalidate nscd $1" + fi +} + +hash_password() { + local password="$1" + local salt=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 8) + echo "$6$${salt}\$$(echo -n "$password" | openssl passwd -1 -salt "$salt" -stdin)" +} + +allocate_gid() { + local name="$1" + local prev_gid="${GID_MAP[$name]}" + if [[ -n "$prev_gid" && -z "${GIDS_USED[$prev_gid]+x}" ]]; then + echo "Would revive group '$name' with GID $prev_gid" + GIDS_USED[$prev_gid]=1 + echo "$prev_gid" + else + for gid in {400..999}; do + if [[ -z "${GIDS_USED[$gid]+x}" && -z "${GIDS_PREV_USED[$gid]+x}" ]]; then + GIDS_USED[$gid]=1 + echo "$gid" + return + fi + done + echo "Out of free GIDs" >&2 + exit 1 + fi +} + +allocate_uid() { + local name="$1" + local is_system_user="$2" + local min max up + if [[ "$is_system_user" == "true" ]]; then + min=400 max=999 up=0 + else + min=1000 max=29999 up=1 + fi + local prev_uid="${UID_MAP[$name]}" + if [[ -n "$prev_uid" && "$prev_uid" -ge "$min" && "$prev_uid" -le "$max" && -z "${UIDS_USED[$prev_uid]+x}" ]]; then + echo "Would revive user '$name' with UID $prev_uid" + UIDS_USED[$prev_uid]=1 + echo "$prev_uid" + else + for uid in $(seq "$min" "$up" "$max"); do + if [[ -z "${UIDS_USED[$uid]+x}" && -z "${UIDS_PREV_USED[$uid]+x}" ]]; then + UIDS_USED[$uid]=1 + echo "$uid" + return + fi + done + echo "Out of free UIDs" >&2 + exit 1 + fi +} + +spec=$(jq -r 'tojson' < "$1") + +# Don't allocate UIDs/GIDs that are manually assigned +for g in $(echo "$spec" | jq -r '.groups[].gid'); do + GIDS_USED[$g]=1 +done +for u in $(echo "$spec" | jq -r '.users[].uid'); do + UIDS_USED[$u]=1 +done + +# Likewise for previously used but deleted UIDs/GIDs +for uid in ${UID_MAP[@]}; do + UIDS_PREV_USED[${uid%%=*}]=1 +done +for gid in ${GID_MAP[@]}; do + GIDS_PREV_USED[${gid%%=*}]=1 +done + +# Generate a new /etc/group containing the declared groups +declare -A groups_out +for g in $(echo "$spec" | jq -r '.groups[]'); do + name=$(echo "$g" | jq -r '.name') + gid=$(allocate_gid "$name") + members=$(echo "$g" | jq -r '.members[]' | paste -sd,) + groups_out[$name]="$name:x:$gid:$members" +done + +# Rewrite /etc/group +update_file "/etc/group" "$(printf '%s\n' "${groups_out[@]}")" +nscd_invalidate "group" + +# Generate a new /etc/passwd containing the declared users +declare -A users_out +for u in $(echo "$spec" | jq -r '.users[]'); do + name=$(echo "$u" | jq -r '.name') + uid=$(allocate_uid "$name" "$(echo "$u" | jq -r '.isSystemUser')") + gid=$(echo "$u" | jq -r '.gid') + if [[ "$gid" =~ ^[0-9]+$ ]]; then + : + elif [[ -n "${groups_out[$gid]+x}" ]]; then + gid="${groups_out[$gid]%%:*}" + else + echo "warning: user '$name' has unknown group '$gid'" >&2 + gid=65534 + fi + home=$(echo "$u" | jq -r '.home') + shell=$(echo "$u" | jq -r '.shell') + users_out[$name]="$name:x:$uid:$gid::/home/$name:/bin/bash" +done + +# Rewrite /etc/passwd +update_file "/etc/passwd" "$(printf '%s\n' "${users_out[@]}")" +nscd_invalidate "passwd" + +# Rewrite /etc/shadow to add new accounts or remove dead ones +declare -A shadow_seen +shadow_new=() +while IFS=':' read -ra fields; do + name="${fields[0]}" + password="${fields[1]}" + if [[ -n "${users_out[$name]+x}" ]]; then + if [[ "$DRY_RUN" == "dry-activate" ]]; then + password="${users_out[$name]}" + fi + shadow_new+=("$name:$password:1::::::") + shadow_seen[$name]=1 + else + shadow_new+=("$name:!:1::::::") + fi +done < /etc/shadow + +for u in "${!users_out[@]}"; do + if [[ -z "${shadow_seen[$u]+x}" ]]; then + shadow_new+=("$u:$(hash_password ""):1::::::") + fi +done + +update_file "/etc/shadow" "$(printf '%s\n' "${shadow_new[@]}")" 0640 +chown root:shadow /etc/shadow + +# Rewrite /etc/subuid & /etc/subgid to include default container mappings +sub_uid_map_file="/var/lib/nixos/auto-subuid-map" +sub_uid_map=$(jq -r 'to_entries|map("\(.key)=\(.value)")|.[]' < "$sub_uid_map_file") + +declare -A sub_uids_used sub_uids_prev_used +for uid in ${sub_uid_map[@]}; do + sub_uids_prev_used[${uid%%=*}]=1 +done + +allocate_sub_uid() { + local name="$1" + local min=100000 max=$((100000 * 100)) up=1 + local prev_id="${sub_uid_map[$name]}" + if [[ -n "$prev_id" && -z "${sub_uids_used[$prev_id]+x}" ]]; then + sub_uids_used[$prev_id]=1 + echo "$prev_id" + else + for uid in $(seq "$min" "$up" "$max"); do + if [[ -z "${sub_uids_used[$uid]+x}" && -z "${sub_uids_prev_used[$uid]+x}" ]]; then + sub_uids_used[$uid]=1 + local offset=$((uid - 100000)) + local count=$((offset * 65536)) + local subordinate=$((100000 + count)) + echo "$subordinate" + return + fi + done + echo "Out of free sub UIDs" >&2 + exit 1 + fi +} + +sub_uids=() +sub_gids=() +for u in "${!users_out[@]}"; do + for range in $(echo "${users_out[$u]}" | jq -r '.subUidRanges[]'); do + start=$(echo "$range" | jq -r '.startUid') + count=$(echo "$range" | jq -r '.count') + sub_uids+=("$u:$start:$count") + done + for range in $(echo "${users_out[$u]}" | jq -r '.subGidRanges[]'); do + start=$(echo "$range" | jq -r '.startGid') + count=$(echo "$range" | jq -r '.count') + sub_gids+=("$u:$start:$count") + done +done + +update_file "/etc/subuid" "$(printf '%s\n' "${sub_uids[@]}")" +update_file "/etc/subgid" "$(printf '%s\n' "${sub_gids[@]}")"