feat(nix): add arch parity proof-of-concept modules

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-07 22:06:12 +02:00
parent cbf587c832
commit 0fa473ef0c
10 changed files with 653 additions and 0 deletions

View File

@ -0,0 +1,28 @@
{ pkgs, ... }:
{
imports = [
./modules/arch-parity-poc.nix
];
linuxConfigPoc = {
enable = true;
mainUser = "kuhy";
# Turn this on when you intentionally want to run the original imperative
# shell installers during activation (hosts install + guard setup, etc.).
# Keep it off by default for safe evaluation and incremental migration.
enableImperativeBootstrap = false;
};
# Minimal baseline the host config still needs while this remains a POC.
networking.hostName = "arch-parity-poc";
time.timeZone = "Europe/Warsaw";
# Convenience tools for verification/debugging during migration.
environment.systemPackages = with pkgs; [
git
jq
];
system.stateVersion = "25.05";
}

View File

@ -0,0 +1,24 @@
{
description = "NixOS proof-of-concept for linux_configuration parity";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, home-manager, ... }:
let
system = "x86_64-linux";
in {
nixosConfigurations.arch-parity-poc = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
home-manager.nixosModules.home-manager
./configuration.nix
];
};
};
}

View File

@ -0,0 +1,12 @@
{ ... }:
{
imports = [
./arch-parity/options.nix
./arch-parity/packages.nix
./arch-parity/report.nix
./arch-parity/desktop.nix
./arch-parity/system-maintenance.nix
./arch-parity/hosts-guards.nix
./arch-parity/bootstrap.nix
];
}

View File

@ -0,0 +1,17 @@
{ config, lib, ... }:
let
cfg = config.linuxConfigPoc;
repo = toString cfg.repoPath;
in {
config = lib.mkIf (cfg.enable && cfg.enableImperativeBootstrap) {
system.activationScripts.linuxConfigImperativeBootstrap = {
text = ''
echo "[linuxConfigPoc] Running imperative bootstrap scripts"
bash ${repo}/hosts/install.sh || true
bash ${repo}/hosts/guard/setup_hosts_guard.sh || true
bash ${repo}/scripts/setup_periodic_system.sh || true
'';
deps = [ "users" "groups" ];
};
};
}

View File

@ -0,0 +1,20 @@
{ config, lib, ... }:
let
cfg = config.linuxConfigPoc;
in {
config = lib.mkIf cfg.enable {
services.xserver.enable = true;
services.xserver.windowManager.i3.enable = true;
services.xserver.displayManager.startx.enable = true;
# Mirror i3 + i3blocks config deployment using Home Manager.
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.users.${cfg.mainUser} = {
xdg.configFile."i3/config".source = "${cfg.repoPath}/i3-configuration/i3/config";
xdg.configFile."i3blocks/config".source = "${cfg.repoPath}/i3-configuration/i3blocks/config";
xdg.configFile."i3blocks".recursive = true;
xdg.configFile."i3blocks".source = "${cfg.repoPath}/i3-configuration/i3blocks";
};
};
}

View File

@ -0,0 +1,92 @@
{ config, lib, pkgs, ... }:
let
cfg = config.linuxConfigPoc;
repo = toString cfg.repoPath;
commonScriptPath = with pkgs; [
bash
coreutils
findutils
gawk
gnugrep
gnused
util-linux
];
customHostsEntries = pkgs.runCommand "linux-config-custom-hosts" { } ''
sed -n '/^# Custom blocking entries$/,/^EOF$/p' ${cfg.repoPath}/hosts/install.sh \
| sed '$d' > "$out"
'';
in {
config = lib.mkIf cfg.enable {
networking.extraHosts = builtins.readFile customHostsEntries;
# Protect hosts lookup ordering against bypasses.
system.nssDatabases.hosts = [ "files" "myhostname" "dns" ];
systemd.services.hosts-guard = {
description = "Enforce canonical /etc/hosts contents";
after = [ "local-fs.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${repo}/hosts/guard/enforce-hosts.sh";
Nice = "10";
IOSchedulingClass = "idle";
};
path = commonScriptPath;
};
systemd.paths.hosts-guard = {
description = "Watch /etc/hosts and trigger enforcement";
wantedBy = [ "multi-user.target" ];
pathConfig = {
PathChanged = [ "/etc/hosts" ];
Unit = "hosts-guard.service";
};
};
systemd.services.nsswitch-guard = {
description = "Enforce canonical /etc/nsswitch.conf (prevents hosts bypass)";
after = [ "local-fs.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${repo}/hosts/guard/enforce-nsswitch.sh";
Nice = "10";
IOSchedulingClass = "idle";
};
path = commonScriptPath;
};
systemd.paths.nsswitch-guard = {
description = "Watch /etc/nsswitch.conf for tampering";
wantedBy = [ "multi-user.target" ];
pathConfig = {
PathChanged = [ "/etc/nsswitch.conf" ];
Unit = "nsswitch-guard.service";
};
};
systemd.services.resolved-guard = {
description = "Enforce canonical /etc/systemd/resolved.conf (prevents hosts bypass)";
after = [ "local-fs.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${repo}/hosts/guard/enforce-resolved.sh";
Nice = "10";
IOSchedulingClass = "idle";
};
path = commonScriptPath;
};
systemd.paths.resolved-guard = {
description = "Watch /etc/systemd/resolved.conf for tampering";
wantedBy = [ "multi-user.target" ];
pathConfig = {
PathChanged = [ "/etc/systemd/resolved.conf" "/etc/systemd/resolved.conf.d" ];
Unit = "resolved-guard.service";
};
};
};
}

View File

@ -0,0 +1,117 @@
{ lib, ... }:
{
options.linuxConfigPoc = {
enable = lib.mkEnableOption "Arch linux_configuration parity proof-of-concept";
repoPath = lib.mkOption {
type = lib.types.path;
default = ../../..;
description = "Path to the linux_configuration repository root.";
};
mainUser = lib.mkOption {
type = lib.types.str;
default = "kuhy";
description = "Primary desktop username for user-scoped config (i3/i3blocks).";
};
enableImperativeBootstrap = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Run original shell installers at activation (hosts + guard + periodic setup).";
};
enableResolutionReport = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Generate a package resolution report grouped by migration category.";
};
resolutionReportEtcPath = lib.mkOption {
type = lib.types.str;
default = "linux-config-poc/package-resolution-report.txt";
description = "Path under /etc where the package resolution report is materialized.";
};
enableResolutionReportJson = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Generate a machine-readable JSON package resolution report.";
};
resolutionReportJsonEtcPath = lib.mkOption {
type = lib.types.str;
default = "linux-config-poc/package-resolution-report.json";
description = "Path under /etc where the JSON package resolution report is materialized.";
};
packageMap = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {
# Fonts / desktop
"ttf-dejavu" = "dejavu_fonts";
"noto-fonts" = "noto-fonts";
"ttf-font-awesome" = "font-awesome";
"adobe-source-sans-pro-fonts" = "source-sans";
"ttf-liberation" = "liberation_ttf";
# Toolchain / language ecosystems
"go-tools" = "gotools";
"cargo" = "cargo";
"rust" = "rustc";
"nodejs" = "nodejs";
"npm" = "nodejs";
"yarn" = "yarn";
"node-gyp" = "nodePackages.node-gyp";
"lua52" = "lua5_2";
# Python package translations
"python-nose" = "python3Packages.nose";
"python-pyproject-metadata" = "python3Packages.pyproject-metadata";
"meson-python" = "python3Packages.meson-python";
"python-numpy" = "python3Packages.numpy";
"python-markdown" = "python3Packages.markdown";
"python-pyparsing" = "python3Packages.pyparsing";
"python-pyqt5" = "python3Packages.pyqt5";
"python-pefile" = "python3Packages.pefile";
"python-booleanoperations" = "python3Packages.booleanoperations";
"python-brotli" = "python3Packages.brotli";
"python-defcon" = "python3Packages.defcon";
"python-fontmath" = "python3Packages.fontmath";
"python-fontpens" = "python3Packages.fontpens";
"python-fonttools" = "python3Packages.fonttools";
"python-fs" = "python3Packages.fs";
"python-tqdm" = "python3Packages.tqdm";
"python-unicodedata2" = "python3Packages.unicodedata2";
"python-zopfli" = "python3Packages.zopfli";
"python-pyaml" = "python3Packages.pyyaml";
# Java / Perl
"java-hamcrest" = "hamcrest";
"perl-font-ttf" = "perlPackages.FontTTF";
"perl-sort-versions" = "perlPackages.SortVersions";
# Multimedia / desktop mismatches
"pavucontrol-qt" = "pavucontrol";
"qt5-wayland" = "qt5.qtwayland";
"qt6-tools" = "qt6.full";
"qt6-shadertools" = "qt6.qtshadertools";
"ffmpeg" = "ffmpeg-full";
"pyside6" = "python3Packages.pyside6";
# TeX stack consolidation
"texlive-plaingeneric" = "texliveFull";
"texlive-latexextra" = "texliveFull";
"texlive-bibtexextra" = "texliveFull";
"texlive-pictures" = "texliveFull";
"texlive-fontsextra" = "texliveFull";
"texlive-formatsextra" = "texliveFull";
"texlive-pstricks" = "texliveFull";
"texlive-games" = "texliveFull";
"texlive-humanities" = "texliveFull";
"texlive-science" = "texliveFull";
};
description = "Mapping from Arch package names to nixpkgs attributes.";
};
};
}

View File

@ -0,0 +1,55 @@
{ config, lib, pkgs, ... }:
let
cfg = config.linuxConfigPoc;
readList = file:
let
lines = lib.splitString "\n" (builtins.readFile file);
trimmed = map lib.strings.trim lines;
in
builtins.filter (line: line != "" && !(lib.hasPrefix "#" line)) trimmed;
pacmanPackageFile = "${toString cfg.repoPath}/fresh-install/pacman_packages.txt";
aurPackageFile = "${toString cfg.repoPath}/fresh-install/aur_packages.txt";
pacmanNamesRaw = readList pacmanPackageFile;
aurLinesRaw = readList aurPackageFile;
aurNames = map (line: builtins.head (lib.splitString " " line)) aurLinesRaw;
mappedPacmanNames = map
(name: if builtins.hasAttr name cfg.packageMap then builtins.getAttr name cfg.packageMap else name)
pacmanNamesRaw;
resolvePackage = name: lib.attrByPath (lib.splitString "." name) null pkgs;
resolvedPacmanPkgs = builtins.filter (pkg: pkg != null) (map resolvePackage mappedPacmanNames);
missingPacmanPkgs = builtins.filter (name: (resolvePackage name) == null) mappedPacmanNames;
in {
config = lib.mkIf cfg.enable {
warnings = [
"linuxConfigPoc: ${toString (builtins.length resolvedPacmanPkgs)} pacman packages resolved to nixpkgs attrs."
"linuxConfigPoc: ${toString (builtins.length missingPacmanPkgs)} pacman packages still need mapping/overlays."
"linuxConfigPoc: ${toString (builtins.length aurNames)} AUR packages detected and not yet represented as Nix derivations."
"linuxConfigPoc: pacman-wrapper policy/challenges remain imperative scripts; full Nix-native equivalent requires dedicated policy module."
];
environment.systemPackages =
lib.unique (
(with pkgs; [
acpi
bc
dex
i3blocks
i3lock
i3status
iw
jq
networkmanagerapplet
pavucontrol
terminator
xss-lock
])
++ resolvedPacmanPkgs
);
};
}

View File

@ -0,0 +1,169 @@
{ config, lib, pkgs, ... }:
let
cfg = config.linuxConfigPoc;
readList = file:
let
lines = lib.splitString "\n" (builtins.readFile file);
trimmed = map lib.strings.trim lines;
in
builtins.filter (line: line != "" && !(lib.hasPrefix "#" line)) trimmed;
pacmanPackageFile = "${toString cfg.repoPath}/fresh-install/pacman_packages.txt";
aurPackageFile = "${toString cfg.repoPath}/fresh-install/aur_packages.txt";
pacmanNamesRaw = readList pacmanPackageFile;
aurLinesRaw = readList aurPackageFile;
aurNames = map (line: builtins.head (lib.splitString " " line)) aurLinesRaw;
resolvePackage = path: lib.attrByPath (lib.splitString "." path) null pkgs;
existsPath = path: (resolvePackage path) != null;
mappedRecords = map
(original:
let
mapped = if builtins.hasAttr original cfg.packageMap then builtins.getAttr original cfg.packageMap else original;
in {
inherit original mapped;
resolved = (resolvePackage mapped) != null;
})
pacmanNamesRaw;
unresolvedRecords = builtins.filter (record: !record.resolved) mappedRecords;
sortStrings = builtins.sort (a: b: a < b);
sortRecordsByOriginal = builtins.sort (
a: b:
if a.original == b.original
then a.mapped < b.mapped
else a.original < b.original
);
candidatePaths = record:
let
pythonCandidate =
if lib.hasPrefix "python-" record.original
then "python3Packages.${lib.removePrefix "python-" record.original}"
else "";
underscoreFromOriginal = lib.replaceStrings [ "-" ] [ "_" ] record.original;
dashFromOriginal = lib.replaceStrings [ "_" ] [ "-" ] record.original;
underscoreFromMapped = lib.replaceStrings [ "-" ] [ "_" ] record.mapped;
dashFromMapped = lib.replaceStrings [ "_" ] [ "-" ] record.mapped;
candidates = [
record.original
record.mapped
underscoreFromOriginal
dashFromOriginal
underscoreFromMapped
dashFromMapped
pythonCandidate
];
in
lib.unique (builtins.filter (candidate: candidate != "") candidates);
easySuggestions = record:
builtins.filter
(candidate: existsPath candidate)
(candidatePaths record);
easySuggestionsSorted = record: sortStrings (easySuggestions record);
isAurOrOverlayLikely = record:
(builtins.elem record.original aurNames)
|| (lib.hasSuffix "-git" record.original)
|| (lib.hasPrefix "lib32-" record.original)
|| (lib.hasSuffix "-bin" record.original);
easyMapRecords = builtins.filter (record: (builtins.length (easySuggestions record)) > 0) unresolvedRecords;
needsOverlayRecords = builtins.filter
(record: ((builtins.length (easySuggestions record)) == 0) && (isAurOrOverlayLikely record))
unresolvedRecords;
likelyUnavailableRecords = builtins.filter
(record: ((builtins.length (easySuggestions record)) == 0) && !(isAurOrOverlayLikely record))
unresolvedRecords;
easyMapRecordsSorted = sortRecordsByOriginal easyMapRecords;
needsOverlayRecordsSorted = sortRecordsByOriginal needsOverlayRecords;
likelyUnavailableRecordsSorted = sortRecordsByOriginal likelyUnavailableRecords;
aurNamesSorted = sortStrings aurNames;
formatSimpleRecord = record: "- ${record.original} -> ${record.mapped}";
formatEasyRecord = record:
"- ${record.original} -> ${record.mapped} | suggestions: ${lib.concatStringsSep ", " (easySuggestionsSorted record)}";
renderLines = lines:
if (builtins.length lines) == 0
then "- none"
else lib.concatStringsSep "\n" lines;
toEasyJsonRecord = record: {
original = record.original;
mapped = record.mapped;
suggestions = easySuggestionsSorted record;
};
toSimpleJsonRecord = record: {
original = record.original;
mapped = record.mapped;
};
reportText = ''
linux_configuration nix-poc package resolution report
================================================
Source files:
- ${pacmanPackageFile}
- ${aurPackageFile}
Summary:
- Total pacman package entries: ${toString (builtins.length pacmanNamesRaw)}
- Unresolved pacman entries after current mapping: ${toString (builtins.length unresolvedRecords)}
- AUR package entries: ${toString (builtins.length aurNames)}
Category: easy-map (likely solvable by small mapping/path fixes)
---------------------------------------------------------------
${renderLines (map formatEasyRecord easyMapRecordsSorted)}
Category: needs-overlay (AUR/multilib/git/bin packages)
-------------------------------------------------------
${renderLines (map formatSimpleRecord needsOverlayRecordsSorted)}
Category: likely-unavailable (needs manual investigation)
---------------------------------------------------------
${renderLines (map formatSimpleRecord likelyUnavailableRecordsSorted)}
AUR inventory
-------------
${renderLines (map (name: "- ${name}") aurNamesSorted)}
'';
reportJson = {
schemaVersion = "1.0";
sourceFiles = {
pacman = pacmanPackageFile;
aur = aurPackageFile;
};
summary = {
pacmanEntries = builtins.length pacmanNamesRaw;
unresolvedPacmanEntries = builtins.length unresolvedRecords;
aurEntries = builtins.length aurNames;
};
categories = {
easyMap = map toEasyJsonRecord easyMapRecordsSorted;
needsOverlay = map toSimpleJsonRecord needsOverlayRecordsSorted;
likelyUnavailable = map toSimpleJsonRecord likelyUnavailableRecordsSorted;
};
aurInventory = aurNamesSorted;
};
in {
config = lib.mkIf (cfg.enable && cfg.enableResolutionReport) {
warnings = [
"linuxConfigPoc: package resolution report generated at /etc/${cfg.resolutionReportEtcPath}${lib.optionalString cfg.enableResolutionReportJson " and /etc/${cfg.resolutionReportJsonEtcPath}"}"
];
environment.etc = {
"${cfg.resolutionReportEtcPath}".text = reportText;
} // lib.optionalAttrs cfg.enableResolutionReportJson {
"${cfg.resolutionReportJsonEtcPath}".text = builtins.toJSON reportJson;
};
};
}

View File

@ -0,0 +1,119 @@
{ config, lib, pkgs, ... }:
let
cfg = config.linuxConfigPoc;
repo = toString cfg.repoPath;
commonScriptPath = with pkgs; [
bash
coreutils
curl
findutils
gawk
gnugrep
gnused
procps
util-linux
];
in {
config = lib.mkIf cfg.enable {
systemd.services.periodic-system-maintenance = {
description = "Periodic System Maintenance (Pacman Wrapper & Hosts File)";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
User = "root";
ExecStart = "${repo}/scripts/system-maintenance/bin/periodic-system-maintenance.sh";
StandardOutput = "journal";
StandardError = "journal";
TimeoutStartSec = "300";
TimeoutStopSec = "30";
Restart = "no";
};
path = commonScriptPath;
};
systemd.timers.periodic-system-maintenance = {
description = "Run Periodic System Maintenance every hour";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "hourly";
OnBootSec = "5min";
RandomizedDelaySec = "300";
Persistent = true;
};
unitConfig = {
Requires = "periodic-system-maintenance.service";
};
};
systemd.services.periodic-system-startup = {
description = "System Maintenance on Startup (Pacman Wrapper & Hosts File)";
after = [ "network-online.target" "systemd-resolved.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
User = "root";
ExecStart = "${repo}/scripts/system-maintenance/bin/periodic-system-maintenance.sh";
StandardOutput = "journal";
StandardError = "journal";
RemainAfterExit = true;
TimeoutStartSec = "300";
TimeoutStopSec = "30";
};
path = commonScriptPath;
};
systemd.services.hosts-file-monitor = {
description = "Hosts File Monitor and Auto-Restore Service";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "root";
ExecStart = "${repo}/scripts/system-maintenance/bin/hosts-file-monitor.sh";
Restart = "always";
RestartSec = "10";
StandardOutput = "journal";
StandardError = "journal";
NoNewPrivileges = false;
PrivateTmp = true;
MemoryMax = "50M";
CPUQuota = "10%";
};
path = commonScriptPath;
};
systemd.services.auto-system-update = {
description = "Automatic System Update (pacman + yay AUR)";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = "root";
ExecStart = "${repo}/scripts/system-maintenance/bin/auto-system-update.sh";
StandardOutput = "journal";
StandardError = "journal";
TimeoutStartSec = "1800";
TimeoutStopSec = "30";
Restart = "no";
};
path = commonScriptPath;
};
systemd.timers.auto-system-update = {
description = "Run Automatic System Update daily";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* 04:00:00";
RandomizedDelaySec = "1800";
Persistent = true;
};
unitConfig = {
Requires = "auto-system-update.service";
};
};
};
}