MiNix: initial commit

Minimal dinit + Nix Linux system with per-user overlay stores.
Renamed from DiNix, restructured flake architecture:
- Distro flake = build tooling (lib.minixSystem, homeModules, disk images)
- Machine flake = per-device config at /etc/minix/ (mutable, first-boot-only)
- User HM config = independent, scaffolded via minix-home init

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
GammaKinematics 2026-03-28 16:02:20 +07:00
commit e287082dba
29 changed files with 4048 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
result
result-*
.direnv/
Refs/
*.qcow2

66
flake.lock generated Normal file
View file

@ -0,0 +1,66 @@
{
"nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs-stable"
]
},
"locked": {
"lastModified": 1774559029,
"narHash": "sha256-deix7yg3j6AhjMPnFDCmWB3f83LsajaaULP5HH2j34k=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "a0bb0d11514f92b639514220114ac8063c72d0a3",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-25.11",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1774388614,
"narHash": "sha256-tFwzTI0DdDzovdE9+Ras6CUss0yn8P9XV4Ja6RjA+nU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1073dad219cb244572b74da2b20c7fe39cb3fa9e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1774386573,
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"home-manager": "home-manager",
"nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable"
}
}
},
"root": "root",
"version": 7
}

77
flake.nix Normal file
View file

@ -0,0 +1,77 @@
{
description = "MiNix minimal dinit + Nix Linux system";
inputs = {
nixpkgs-stable.url = "github:NixOS/nixpkgs/nixos-25.11";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager/release-25.11";
inputs.nixpkgs.follows = "nixpkgs-stable";
};
};
outputs = { self, nixpkgs-stable, nixpkgs-unstable, home-manager }:
let
system = "x86_64-linux";
# Clean pkgs without overlay — for build tools that don't go into the system
# (avoids cache misses from the systemd-stripping overlay on transitive deps)
hostPkgs = import nixpkgs-stable { system = "x86_64-linux"; };
minixSystem = import ./system/eval.nix { nixpkgs = nixpkgs-stable; };
# Evaluate the system configuration
systemConfig = minixSystem {
modules = [
./machines/qemu-vm/configuration.nix
# Install the machine flake to /etc/minix/ (mutable, first-boot-only).
# This is the flake that both root (minix-rebuild) and user (home-manager)
# evaluate on the end machine.
{ minix.machineFlake = ./machines/qemu-vm; }
];
};
pkgs = systemConfig.pkgs;
systemProfile = systemConfig.config.system.build.systemProfile;
diskImage = import ./image/disk.nix {
pkgs = hostPkgs;
inherit systemConfig systemProfile;
};
in {
# The minixSystem function — for external consumers
lib.minixSystem = minixSystem;
# Home Manager module for dinit user service integration.
# Machine flakes: modules = [ minix.homeModules.default ./home/user/home.nix ];
homeModules.default = ./system/modules/hm-dinit.nix;
packages.${system} = {
system = systemProfile;
vm = diskImage;
default = diskImage;
};
devShells.${system}.default = hostPkgs.mkShell {
packages = with hostPkgs; [
# VM
qemu_kvm
# Debugging initrd / disk images
cpio
gzip
file
binutils # readelf, objdump
strace
# General
coreutils
findutils
gnugrep
gawk
tree
jq
];
};
};
}

183
image/disk.nix Normal file
View file

@ -0,0 +1,183 @@
# Persistent disk image — boots MiNix from a qcow2 with ext4 root.
# Sandbox-safe: no loop mounts, no sudo, no /dev access.
#
# Boot flow:
# QEMU loads kernel + initrd (from NixOS module system), disk attached as virtio
# → NixOS stage-1 initrd mounts ext4 root partition (from fileSystems config)
# → switch_root to ${systemProfile}/init (the init wrapper)
# → init wrapper mounts pseudo-fs, execs dinit --system
# → dinit starts boot service, early services populate /etc etc.
#
# Build pipeline:
# closureInfo → staging dir → mke2fs -d → sfdisk GPT → dd → qemu-img qcow2
{ pkgs, systemConfig, systemProfile }:
let
inherit (pkgs) lib;
kernel = systemConfig.config.boot.kernelPackages.kernel;
kernelFile = systemConfig.config.system.boot.loader.kernelFile;
initrd = "${systemConfig.config.system.build.initialRamdisk}/initrd";
kernelParams = lib.concatStringsSep " " systemConfig.config.boot.kernelParams;
# Full Nix store closure (system profile + all transitive deps)
closureInfo = pkgs.closureInfo {
rootPaths = [ systemProfile ];
};
# The disk image derivation
diskImage = pkgs.stdenv.mkDerivation {
name = "minix-disk-image";
nativeBuildInputs = with pkgs; [
e2fsprogs
libfaketime
fakeroot
util-linux # sfdisk
qemu-utils # qemu-img
coreutils
];
buildCommand = ''
# === Stage 1: Build staging directory ===
echo "Building staging directory..."
mkdir -p staging/nix/store
# Copy full Nix store closure
echo "Copying store paths..."
xargs -I % cp -a --reflink=auto % -t staging/nix/store/ < ${closureInfo}/store-paths
chmod -R u+w staging/nix/store
# Nix store registration (loaded by stage-1 on first boot)
cp ${closureInfo}/registration staging/nix-path-registration
# System profile symlink
mkdir -p staging/nix/var/nix/profiles
ln -s ${systemProfile} staging/nix/var/nix/profiles/system
# Nix database directory structure (single-user Nix)
mkdir -p staging/nix/var/nix/db
mkdir -p staging/nix/var/nix/gcroots
mkdir -p staging/nix/var/nix/temproots
# Directory skeleton (init wrapper creates some of these too, but
# having them pre-existing avoids transient errors during early boot)
mkdir -p staging/{bin,sbin,etc,proc,sys,dev,run,tmp,home,root,var,lib}
mkdir -p staging/var/{log,empty,run,lib/dhcpcd}
chmod 1777 staging/tmp
# === Stage 2: Create ext4 filesystem image ===
# Size calculation (following nixpkgs make-ext4-fs.nix)
echo "Calculating image size..."
numInodes=$(find staging | wc -l)
numDataBlocks=$(du -s -c -B 4096 --apparent-size staging | tail -1 | awk '{ print int($1 * 1.20) }')
bytes=$((2 * 4096 * numInodes + 4096 * numDataBlocks))
echo "Base size: $((bytes / 1024 / 1024)) MiB (numInodes=$numInodes, numDataBlocks=$numDataBlocks)"
# Round up to nearest MiB
mebibyte=$((1024 * 1024))
if (( bytes % mebibyte )); then
bytes=$(( (bytes / mebibyte + 1) * mebibyte ))
fi
truncate -s $bytes rootfs.img
echo "Creating ext4 image: $((bytes / 1024 / 1024)) MiB"
faketime -f "1970-01-01 00:00:01" fakeroot mkfs.ext4 \
-L minix \
-U 44444444-4444-4444-8888-888888888888 \
-d staging \
rootfs.img
# Verify integrity
export EXT2FS_NO_MTAB_OK=yes
fsck.ext4 -n -f rootfs.img
# Shrink to actual content size, then add 2 GiB headroom for in-VM builds
resize2fs -M rootfs.img
new_size=$(dumpe2fs -h rootfs.img 2>/dev/null | awk -F: \
'/Block count/{count=$2} /Block size/{size=$2} END{print int((count*size + 2 * 2^30) / size)}')
resize2fs rootfs.img "$new_size"
echo "Final ext4 size: $(( $(stat -c %s rootfs.img) / 1024 / 1024 )) MiB"
# Free staging directory — no longer needed, ext4 image has the data
rm -rf staging
# === Stage 3: Create GPT disk image ===
rootSize=$(stat -c %s rootfs.img)
partOffsetBytes=$((1 * mebibyte)) # partition starts at 1 MiB
gptBackupBytes=$((1 * mebibyte)) # GPT backup at end
diskSize=$((partOffsetBytes + rootSize + gptBackupBytes))
# Round to MiB
if (( diskSize % mebibyte )); then
diskSize=$(( (diskSize / mebibyte + 1) * mebibyte ))
fi
echo "Creating GPT disk image: $((diskSize / 1024 / 1024)) MiB"
truncate -s "$diskSize" disk.raw
# Write GPT partition table — single Linux root partition starting at sector 2048
sfdisk disk.raw <<EOF
label: gpt
first-lba: 2048
type=linux, start=2048
EOF
# Write ext4 image into the partition slot (sector 2048 = 1 MiB offset)
dd if=rootfs.img of=disk.raw bs=1M seek=1 conv=notrunc status=none
rm rootfs.img
# === Stage 4: Convert to qcow2 ===
mkdir -p $out
qemu-img convert -f raw -O qcow2 disk.raw $out/minix.qcow2
echo "Disk image: $out/minix.qcow2"
echo "qcow2 size: $(du -sh $out/minix.qcow2 | cut -f1)"
'';
};
# QEMU runner script — creates a copy-on-write overlay for persistence
startVM = pkgs.writeShellScript "run-minix-vm" ''
if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then
echo "qemu-system-x86_64 not found, fetching via nix shell..." >&2
exec nix shell nixpkgs#qemu_kvm -c "$0" "$@"
fi
DISK="''${MINIX_DISK:-minix-disk.qcow2}"
if [ ! -f "$DISK" ]; then
echo "Creating working disk at $DISK (backed by Nix store image)..."
qemu-img create -f qcow2 -b ${diskImage}/minix.qcow2 -F qcow2 "$DISK"
echo ""
fi
echo "=== MiNix QEMU VM (persistent disk) ==="
echo "Disk: $DISK"
echo "Login as root (no password)"
echo "Press Ctrl-A X to quit QEMU"
echo "Delete $DISK to start fresh"
echo ""
exec qemu-system-x86_64 \
-name minix \
-m 4096 \
-smp 2 \
-enable-kvm \
-device virtio-rng-pci \
-drive file="$DISK",format=qcow2,if=virtio \
-kernel ${kernel}/${kernelFile} \
-initrd ${initrd} \
-nographic \
-append "${kernelParams} init=${systemProfile}/init" \
-net nic,model=virtio \
-net user,net=10.0.2.0/24,host=10.0.2.2,dns=10.0.2.3,hostfwd=tcp::2222-:22
'';
in pkgs.runCommand "minix-disk" {
preferLocalBuild = true;
meta.mainProgram = "run-minix-vm";
} ''
mkdir -p $out/bin
ln -s ${startVM} $out/bin/run-minix-vm
''

View file

@ -0,0 +1,63 @@
# System configuration for the QEMU development VM.
{ config, lib, pkgs, ... }:
{
imports = [
./hardware-configuration.nix
];
# Platform
nixpkgs.hostPlatform = "x86_64-linux";
# System
system.stateVersion = "25.11";
minix.hostname = "minix";
# Locale and timezone
time.timeZone = "UTC";
i18n.defaultLocale = "en_US.UTF-8";
# Kernel
boot.kernelPackages = pkgs.linuxPackages_latest;
boot.kernelParams = [ "quiet" "panic=-1" ];
# Users
minix.users.root.hashedPassword = "";
minix.users.user = {
uid = 1000;
gid = 100; # users
createHome = true;
hashedPassword = "";
};
minix.groups.users.members = [ "user" ];
# System packages
environment.systemPackages = with pkgs; [
nix
dhcpcd
eudev
kmod
];
# SSH — stock NixOS module, translated via systemd-compat
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "yes";
PasswordAuthentication = true;
PermitEmptyPasswords = true;
};
};
# Allow empty password login (dev VM only)
security.pam.services.sshd.allowNullPassword = true;
security.pam.services.login.allowNullPassword = true;
# Nix config — single-user mode (no daemon)
# experimental-features (nix-command, flakes, local-overlay-store) from defaults.nix
nix.settings.build-users-group = "";
# Enable turnstile root session for testing (dev VM only)
minix.turnstile.rootSession = true;
}

View file

@ -0,0 +1,21 @@
# Machine flake for the QEMU development VM.
# Installed to /etc/minix/ on the end machine (mutable, first-boot-only).
# root rebuilds with: minix-rebuild build
{
description = "MiNix QEMU VM";
inputs = {
minix.url = "git+https://git.axiomania.org/lebowski/MiNix.git";
nixpkgs-stable.follows = "minix/nixpkgs-stable";
nixpkgs-unstable.follows = "minix/nixpkgs-unstable";
};
outputs = { minix, nixpkgs-stable, ... }:
let
system = "x86_64-linux";
in {
packages.${system}.system = (minix.lib.minixSystem {
modules = [ ./configuration.nix ];
}).config.system.build.systemProfile;
};
}

View file

@ -0,0 +1,15 @@
# Hardware configuration for QEMU/KVM virtual machine.
# In a real deployment, this file would be generated by minix-generate-config.
{ config, lib, pkgs, modulesPath, ... }:
{
boot.initrd.availableKernelModules = [ "virtio_blk" "virtio_pci" "virtio_net" ];
boot.initrd.kernelModules = [ "virtio_blk" "virtio_pci" "virtio_net" ];
boot.kernelParams = [ "console=ttyS0" ];
fileSystems."/" = {
device = "/dev/vda1";
fsType = "ext4";
};
}

672
reference.md Normal file
View file

@ -0,0 +1,672 @@
# MiNix: Architecture & Reference
## Vision
A minimal, non-systemd Linux system with Nix as the operating system layer.
dinit for PID 1 and process supervision. NixOS module system for declarative
configuration. Bubblewrap for service sandboxing. No Nix daemon by default.
The key insight: you don't need NixOS to get most of its benefits — just Nix +
the NixOS module evaluator + cherry-picked modules + a translation shim for
services.
Two core principles drive the architecture:
1. **dinit over runit** — proper dependency DAG, parallel boot, native service
types that map cleanly to systemd, readiness notification, cgroup placement.
2. **Daemon-less Nix** — the system store is root-owned (single-user Nix), user
stores are per-user (daemon-less Home Manager). No nix-daemon by default.
---
## Architecture Overview
```
Layer 2: User Environment (Home Manager, per-user Nix store)
Per-user, declarative. Owns $HOME only.
Packages, dotfiles, user dinit services.
Own Nix store (~/.local/share/nix/store) — no daemon needed.
Updated: home-manager switch (live, no reboot)
Layer 1: System Profile (root-owned Nix store, single-user Nix)
/nix/var/nix/profiles/system
init — dinit init wrapper (generated)
kernel/ — vmlinuz, modules
etc/ — /etc files (built by NixOS etc.nix)
dinit.d/ — dinit service files
sw/ — system PATH (buildEnv)
firmware/ — hardware firmware
sysusers-credentials/ — hashed passwords for systemd-sysusers
Updated: minix-rebuild (root, single-user nix build) + reboot
Layer 0: Boot (NixOS initrd)
NixOS scripted initrd -> switch_root -> $profile/init -> dinit
Kernel cmdline: init=$profile/init
```
### Two-layer model
```
System profile: "What does the machine need to run?" -> kernel, /etc, services
User environment: "What does the user see and use?" -> $HOME only
```
No separate "base rootfs" layer. The init wrapper and all boot logic are
generated as Nix derivations and live inside the system profile. The system
profile is self-contained — switch profiles by pointing
`/nix/var/nix/profiles/system` at a new store path and rebooting.
### Daemon-less Nix stores
```
/nix/store (system) — owned by root, single-user Nix mode
minix-rebuild runs nix build directly as root
read-only bind mount at runtime (minix.hardenStore)
~/.local/share/nix/ (user) — owned by each user, daemon-less
Home Manager operates against this store
binary cache for substitutes (no local rebuilds needed)
user dinit services reference paths in this store
```
No nix-daemon service. No nix-daemon.socket. No nixbld build users. The daemon
is available as an opt-in for multi-user shared-store setups, not a default.
The store boundary and dinit instance boundary align: system dinit (PID 1)
launches system services from `/nix/store`; user dinit instances (`dinit --user`)
launch user services from the user's store. No cross-store references.
### dinit service supervision
```
PID 1: dinit --system
├── early-* services (scripted: pseudofs, eudev, modules, hostname, etc.)
├── system services (translated from systemd.services.*)
└── user dinit instances (spawned at login)
└── user services (from Home Manager / user store)
```
dinit provides dependency-ordered parallel startup, readiness notification,
native service types (process, bgprocess, scripted, internal, triggered),
and template services via `@` suffix with `$1` substitution.
---
## Module Evaluation
`system/eval.nix` imports the **full NixOS module-list.nix** and filters via
a blacklist, then adds MiNix modules:
```
Blacklisted patterns (hasInfix match):
"system/boot/systemd" -> systemd-compat.nix (optionsOnly)
"system/boot/stage-2.nix" -> systemd-compat.nix (optionsOnly)
"system/activation/" -> systemd-compat.nix (optionsOnly)
"tasks/network-interfaces-systemd.nix" -> networkd backend, needs systemd
"system/boot/networkd.nix" -> same
"system/boot/shutdown.nix" -> unconditionally refs systemd
"services/system/nix-daemon" -> systemd-compat.nix (optionsOnly; no daemon)
Cherry-picked back from blanket-excluded dirs:
system/activation/activation-script.nix -> activation script infrastructure
system/boot/systemd/tmpfiles.nix -> tmpfiles.d generation
```
Uses `evalModulesMinimal` (not `evalModules`) for a lighter evaluation path.
### The `optionsOnly` pattern
systemd-compat.nix uses `optionsOnly` to import stock NixOS modules keeping
only their `options` and `imports` — dropping `config` so no systemd wiring
fires. This gives us 1:1 API match with stock NixOS and zero maintenance
when upstream adds or changes options:
```nix
optionsOnly = path:
let mod = (import path) args;
in { options = mod.options or {}; imports = mod.imports or []; };
```
Applied to ~25 systemd sub-modules (journald, logind, user, coredump, oomd,
nspawn, sysupdate, sysusers, homed, userdbd, initrd, stage-2, networkd,
top-level, bootspec, activatable-system, switchable-system, nixos-init, etc.)
plus `nix-daemon.nix` (suppresses daemon service + nixbld users while keeping
`nix.enable`, `nix.package` etc. that `config/nix.nix` depends on).
---
## Boot Flow
```
NixOS initrd -> switch_root -> $PROFILE/init (minix-init)
-> mount proc, sys, dev, run (fallbacks)
-> remount / rw
-> busybox --install -s /bin
-> exec dinit --system --services-dir $PROFILE/dinit.d
```
### 3-layer boot DAG
dinit starts the `boot` service by default. The boot DAG is 3 layers deep:
```
boot (internal)
├── depends-on: system
└── waits-for: serial-getty@ttyS0, autovt@tty1 (login last)
system (internal)
├── depends-on: early
└── waits-for: sshd, dhcpcd, firewall, ... (all auto-start services)
early (internal)
└── depends-on: early-sysinit, early-hostname, early-loopback
early-sysinit (scripted)
└── depends-on: early-etc, early-eudev, early-modules
├── tmpfiles --create --remove
├── sysusers (create/merge users, groups, passwords)
├── machine-id
├── sysctl
├── fstab mount
├── seedrng (entropy credit)
├── nix store registration
├── store hardening (optional)
└── activation scripts
early-etc (scripted) — populate /etc from profile
└── depends-on: early-pseudofs
early-eudev (scripted) — udevd + coldplug + settle
└── depends-on: early-pseudofs
early-modules (scripted) — link kernel modules, load modules-load.d
└── depends-on: early-pseudofs, early-etc
early-hostname (scripted)
└── depends-on: early-etc
early-loopback (scripted) — bring up lo
└── depends-on: early-pseudofs
early-pseudofs (scripted) — mount tmp, devpts, shm, cgroup2, /run/wrappers
```
Getty templates have `dependsMs = [ "system" ]` so login prompts appear
after all service status lines.
### Session management (turnstile)
turnstile (Chimera Linux project) provides session tracking and per-user
dinit instance spawning:
```
Login flow:
user logs in via getty/sshd
-> PAM calls pam_turnstile.so (session rule)
-> turnstiled spawns dinit --user for that UID
-> XDG_RUNTIME_DIR created at /run/user/$UID
-> user dinit reads ~/.config/dinit.d/ + /etc/dinit.d/user/
-> PAM returns -> shell prompt
Logout flow:
last session for UID closes
-> turnstiled stops user dinit instance
-> XDG_RUNTIME_DIR cleaned up
(linger=maybe checks /var/lib/turnstiled/linger/<username>)
```
Boot placement: `turnstiled` is a process service with `dependsMs = [ "early" ]`,
auto-starts via system milestone, ready before gettys accept login.
Configuration:
- `/etc/turnstile/turnstiled.conf` — backend selection, rundir, linger policy
- `/etc/turnstile/backend/dinit.conf` — user dinit service directories
- `/etc/dinit.d/user/` — system-provided user services (D-Bus, PipeWire, etc.)
- `/etc/dinit.d/user/boot.d/` — system-provided user boot services
PAM integration: `pam_turnstile.so` added to login, sshd, su, sudo session
rules (order 11900). Separate `turnstiled` PAM service for backend spawning.
### /etc population (early-etc)
- Symlinks for immutable files (store paths)
- Copies for mutable files (those with `.mode` sidecar)
- Mutable files preserved across reboots (only written on first boot)
- Stale symlinks from previous profiles cleaned up
- passwd/shadow/group/gshadow NOT in /etc profile — managed by sysusers at runtime
### Shutdown
```
dinit ordered service shutdown via dependency graph
-> shutdown-hook: seedrng (save entropy seed)
-> exit 1 (tells dinit to handle unmounting)
-> dinit unmounts filesystems, remounts / ro, sync, halt/reboot
```
---
## The systemd->dinit Translator
`systemd-compat.nix` is the core translation layer. It reads
`systemd.services.*` set by stock NixOS modules and generates dinit service
files. The `optionsOnly` pattern (importing NixOS modules with only their
option declarations) remains unchanged.
### systemd -> dinit mapping
dinit's native primitives map directly to systemd.
Most translations become 1:1 config directives rather than shell script hacks:
| systemd directive | dinit equivalent |
|---|---|
| `ExecStart` | `command =` |
| `ExecStop` | `stop-command =` |
| `Type=simple` | `type = process` |
| `Type=oneshot` | `type = scripted` |
| `Type=forking` | `type = bgprocess` + `pid-file =` |
| `Type=notify` | `type = process` (readiness not translated) |
| `User`, `Group` | `run-as =` |
| `Environment` | `env-file =` (generated) |
| `EnvironmentFile` | `env-file +=` |
| `WorkingDirectory` | `working-dir =` |
| `Restart=always` | `restart = yes` |
| `Restart=on-failure` | `restart = on-failure` |
| `Restart=no` | `restart = no` |
| `After=` + `Requires=` | `depends-on:` (hard dep) |
| `After=` + `Wants=` | `waits-for:` (soft dep) |
| `After=` (ordering only) | `after:` |
| `LimitNOFILE` | `rlimit-nofile =` |
| `LimitCORE` | `rlimit-core =` |
| `LimitDATA` | `rlimit-data =` |
| `LimitAS` | `rlimit-addrspace =` |
| `AmbientCapabilities` | `capabilities =` (IAB `^CAP_*` format) |
| `NoNewPrivileges` | `options: no-new-privs` |
| `PIDFile` | `pid-file =` (for bgprocess) |
| `StateDirectory` etc. | pre-start scripted setup |
| Template services (`@`) | native `$1` substitution |
| `WantedBy=` (empty) | service not depended on by `system` (dormant) |
| Unix socket activation | `socket-listen =` + permissions/uid/gid |
Key features:
- **Dependencies are real**`depends-on`/`waits-for`/`after` give proper
startup ordering and failure propagation.
- **Service types map natively** — oneshot->scripted, forking->bgprocess, etc.
- **Restart policy is a directive** — no generated finish scripts.
- **Socket pre-opening**`socket-listen =` provides basic socket activation.
- **Capabilities** — native `capabilities =` with IAB syntax.
### Sandboxing (bubblewrap)
bwrap wraps the `command` in the dinit service file:
| systemd directive | bwrap equivalent |
|---|---|
| `ProtectSystem=strict` | `--ro-bind / /` + writable /var, /run, /tmp + declared dirs |
| `ProtectSystem=full` | `--ro-bind /usr /usr --ro-bind /boot /boot --ro-bind /etc /etc` |
| `ProtectSystem=true` | `--ro-bind /usr /usr --ro-bind /boot /boot` |
| `ProtectHome=true` | `--tmpfs /home --tmpfs /root --tmpfs /run/user` |
| `ProtectHome=read-only` | `--ro-bind /home /home --ro-bind /root /root` |
| `PrivateTmp=true` | `--tmpfs /tmp --tmpfs /var/tmp` |
| `PrivateDevices=true` | `--dev /dev` |
| `PrivateNetwork=true` | `--unshare-net` |
| `ProtectKernelTunables` | `--ro-bind /proc/sys /proc/sys` |
| `ProtectControlGroups` | `--ro-bind /sys/fs/cgroup /sys/fs/cgroup` |
| `ProtectHostname` | `--unshare-uts` |
| `NoNewPrivileges` | `--new-session` (+ native `no-new-privs` option) |
| `ReadOnlyPaths` | `--ro-bind` per path |
| `ReadWritePaths` | `--bind` per path |
Services with `AmbientCapabilities` skip bwrap and setpriv — bwrap can't
restore caps after uid change. These use dinit native `capabilities` instead.
### Timer -> snooze translation
`systemd.timers` and `startAt` are translated to per-timer dinit services
using snooze (a lightweight cron alternative):
| systemd timerConfig | snooze flag |
|---|---|
| `OnCalendar` | `-d`, `-m`, `-w`, `-H`, `-M`, `-S` (parsed from calendar spec) |
| `RandomizedDelaySec` | `-R <seconds>` |
| `Persistent=true` | `-t /var/lib/snooze/<name>.timefile` |
| `AccuracySec` | `-s <seconds>` (slack) |
| `OnBootSec` | `builtins.throw` (not supported) |
| `OnUnitActiveSec` | `builtins.throw` (not supported) |
Each timer becomes a supervised `dinit.services."timer-<name>"` (process type,
restart=yes). The triggered service has `autoStart = false` (started by snooze
via `dinitctl start`).
### What can't be translated
| Feature | Status with dinit |
|---|---|
| Socket activation (full) | dinit has `socket-listen` for Unix sockets; TCP/UDP not supported |
| `DynamicUser` | Needs ephemeral UID allocator |
| `sd_notify` protocol | dinit has `ready-notification` (pipefd/pipevar) — different protocol |
| Cgroup resource limits (`CPUQuota`, `MemoryMax`) | dinit has `run-in-cgroup`; resource limits need cgroup controller config |
| Monotonic timers (`OnBootSec`, etc.) | Hard error — calendar scheduling only |
---
## User/Group Management
`users.nix` provides `minix.users` and `minix.groups` options.
### systemd-sysusers (standalone)
Users and groups are created at boot by `systemd-sysusers` (standalone build
from upstream systemd, statically links all systemd internals, only libc is
dynamic). The sysusers config is generated at build time:
```
/etc/sysusers.d/00-minix.conf:
u root 0:0 "System administrator" /root /bin/sh
u sshd 806:803 "SSH privilege separation user" /var/empty /bin/sh
g root 0
g wheel 1
m root wheel
```
Hashed passwords are passed via `$CREDENTIALS_DIRECTORY` (a store path linked
from the system profile). On first boot, sysusers creates all entries. On
subsequent boots, existing entries are preserved (merge semantics) — password
changes via `passwd` persist across reboots.
### Dynamic UID/GID allocation
Groups and users without well-known IDs get deterministic allocation:
- Groups sorted by name, sequential GIDs from base 800
- Users sorted by name, sequential UIDs from 800 + num_groups
- Fallback chain: explicit UID/GID -> ids.nix lookup -> dynamic -> throw
### Bridge from stock NixOS API
Translates `users.users` -> `minix.users` and `users.groups` -> `minix.groups`
with resolved UIDs/GIDs, including `extraGroups` membership bridging. Stock
NixOS activation scripts (Perl-based user/group management) are force-emptied.
---
## Standalone Tools
Built from upstream sources, replacing hand-rolled implementations:
| Tool | Source | Purpose |
|---|---|---|
| `systemd-tmpfiles` | systemd v260.1 standalone build | tmpfiles.d directory/symlink creation |
| `systemd-sysusers` | systemd v260.1 standalone build | User/group creation with merge semantics |
| `seedrng` | busybox built-in | RNG seeding with BLAKE2s mixing + entropy credit |
| `virt-what` | nixpkgs (48 KiB) | Virtualization detection (CPUID-level, replaces shell script) |
| `turnstile` | chimera-linux/turnstile v0.1.11 | Session tracking + per-user dinit instance spawning |
| `hwdb.bin` | systemd v260.1 hwdb.d data + eudev udevadm | Hardware identification database (PCI/USB/Bluetooth/input) |
---
## eudev Compatibility
`eudev-compat.nix` presents eudev as `systemd.package` for stock NixOS modules
that expect systemd paths:
- Symlinks eudev binaries and lib/udev into a wrapper derivation
- Stubs: `systemd-sysctl` (exit 0), `systemd-run` (exec directly), `systemd-cat` (strip flags, exec)
- Empty stubs for systemd-specific udev rules
- `99-default.link` in `lib/systemd/network/`
- `kmod` attribute passthrough (stage-1.nix reads `udev.kmod`)
- tmpfiles.d configs from standalone systemd-tmpfiles build
- Initrd fixes: dereference symlinks, provide `systemd-udevd` name
- `hwdb.bin` compiled from systemd v260.1 hwdb.d data files using eudev's `udevadm hwdb --update`
---
## NixOS Compatibility
### What works (standard NixOS options)
These work identically to NixOS because we use the stock modules:
- `boot.kernelPackages`, `boot.kernelModules`, `boot.kernelParams`
- `boot.initrd.availableKernelModules`, `boot.initrd.kernelModules`
- `boot.blacklistedKernelModules`, `boot.extraModprobeConfig`
- `fileSystems.*`, `swapDevices`
- `hardware.firmware`, `hardware.enableRedistributableFirmware`
- `hardware.deviceTree.*`
- `hardware.cpu.amd.updateMicrocode`, `hardware.cpu.intel.updateMicrocode`
- `services.udev.extraRules`, `services.udev.packages`
- `environment.etc.*`, `environment.systemPackages`
- `nixpkgs.overlays`, `nixpkgs.config`, `nixpkgs.hostPlatform`
- `boot.kernel.sysctl.*`
- `time.timeZone`, `i18n.defaultLocale`
- `security.pki.certificateFiles`
- `security.pam.services.*` (stock NixOS PAM, with SHA-512 enforced)
- `networking.iproute2.*`
- `networking.firewall.*` (via translated iptables service)
- `system.stateVersion`
- `systemd.services.*` (auto-translated to dinit)
- `systemd.timers.*`, `startAt` (translated to snooze)
- `systemd.tmpfiles.*` (standalone tmpfiles binary)
- `systemd.targets.*` (wants/requires used for template instantiation)
A standard NixOS `hardware-configuration.nix` works as-is in MiNix.
### MiNix-specific options
| NixOS option | MiNix equivalent | Why |
|---|---|---|
| `users.users.*` | `minix.users.*` | sysusers standalone replaces Perl activation |
| `users.groups.*` | `minix.groups.*` | Same |
| `networking.hostName` | `minix.hostname` | Simpler, decoupled from networkd |
| `dinit.services.*` | (native) | Direct dinit service definitions |
| `minix.hardenStore` | (native) | Read-only bind mount for /nix/store |
| `minix.turnstile.*` | (native) | Session tracking, XDG_RUNTIME_DIR, user dinit instances |
| `minix.flakeRef` | (native) | Default flake reference for minix-rebuild build |
Note: `users.users` and `users.groups` from stock NixOS modules are bridged
automatically to `minix.users`/`minix.groups` — both APIs work.
### What doesn't work
- `networking.interfaces.*` (scripted backend) — deeply systemd-coupled
- `virtualisation.*` — systemd-coupled
- `services.*` modules using `DynamicUser` or full socket activation
- `systemd.network.*` (networkd) — blacklisted, no backend
- `powerManagement.*` — disabled by default
---
## File Structure
```
/data/Distro/
flake.nix Main flake: minixSystem, packages, devShell
reference.md This document
system/
eval.nix Module evaluator (blacklist + evalModulesMinimal)
overlay.nix nixpkgs overlay (strip systemd deps, enable dinit caps)
modules/
systemd-compat.nix optionsOnly imports + systemd->dinit translator
dinit-services.nix dinit.services -> environment.etc."dinit.d/..."
dinit-init.nix Init wrapper + 3-layer boot DAG + shutdown hook
users.nix minix.users/groups + sysusers.d generation + bridge
eudev-compat.nix eudev-as-systemd wrapper + standalone tools + initrd fixes
toplevel.nix Assembles system profile derivation
generate-config.nix minix-generate-config (virt-what + stock perl script)
base-etc.nix minix.hostname, LOCALE_ARCHIVE override
defaults.nix Feature flags, distro identity, overlay, syslogd
rebuild.nix minix-rebuild (build + switch subcommands)
turnstile.nix turnstile session tracking + PAM integration
system-path.nix Busybox-based core packages
packages/
systemd-tmpfiles-standalone.nix Standalone tmpfiles from systemd v260.1
systemd-sysusers-standalone.nix Standalone sysusers from systemd v260.1
turnstile.nix turnstile v0.1.11 (session/login tracker)
machines/
qemu-vm/
configuration.nix QEMU VM config (getty, sshd, dhcpcd, etc.)
hardware-configuration.nix Detected hardware (kernel modules, filesystems)
image/
disk.nix Persistent qcow2 disk image + QEMU runner
Refs/
nixpkgs/ Shallow clone for module inspection
dinit/ dinit source + man pages
dinit-chimera/ Chimera Linux's dinit integration
NixNG/modules/dinit/ NixNG's Nix-native dinit module
systemd/ systemd v260.1 source (for standalone builds)
seedrng/ seedrng source (reference only, busybox ships it)
turnstile/ turnstile source (session tracker, Chimera Linux)
```
---
## nixpkgs Overlay
```nix
final: prev: {
procps = prev.procps.override { withSystemd = false; };
util-linux = prev.util-linux.override { systemdSupport = false; };
openssh = prev.openssh.override { withFIDO = false; };
dinit = prev.dinit.overrideAttrs (old: {
buildInputs = (old.buildInputs or []) ++ [ prev.libcap ];
configureFlags = (old.configureFlags or []) ++ [ "--enable-capabilities" ];
});
}
```
Applied via `nixpkgs.overlays` in defaults.nix. Strips systemd linkage from
key packages. dinit built with `--enable-capabilities` for native IAB support.
Uses a separate `hostPkgs` (without overlay) for build tools to avoid cache misses.
---
## Flake Outputs
```nix
{
lib.minixSystem = minixSystem; # For external consumers
packages.x86_64-linux = {
system = systemProfile; # Just the system profile
vm = diskImage; # qcow2 disk + QEMU runner
default = diskImage;
};
devShells.x86_64-linux.default = ...; # qemu, cpio, gzip, etc.
}
```
### Build & test
```bash
nix build .#vm # Build disk image + runner
./result/bin/run-minix-vm # Boot in QEMU
ssh -p 2222 root@localhost # SSH into VM (empty password)
dinitctl list # Check services inside VM
```
---
## Key Design Rules
1. **Reuse NixOS modules wherever possible.** Use stock modules as-is via
optionsOnly or direct import. Only write from scratch where NixOS is
structurally incompatible.
2. **Translate, don't rewrite.** The systemd->dinit translator gives access
to the entire NixOS service ecosystem automatically. dinit's native
primitives make most translations 1:1 config directives.
3. **Mutable files with merge semantics.** /etc files with `.mode` sidecar
are preserved across reboots (early-etc only writes on first boot).
passwd/shadow/group/gshadow managed by sysusers with entry-level merge —
runtime changes via `passwd` persist.
4. **Sandboxing matters.** Bubblewrap provides real namespace isolation
equivalent to systemd's security directives. dinit's native capabilities
and no-new-privs complement bwrap.
5. **Keep it minimal.** dinit + busybox + eudev base. No systemd runtime.
No Nix daemon by default.
6. **Use standalone tools.** Prefer standalone builds (systemd-tmpfiles,
systemd-sysusers) and busybox builtins (seedrng) over hand-rolled code.
---
## Key Tradeoffs
### What we lose vs NixOS
- Live system switching (we require reboot for system profile changes)
- DynamicUser (needs ephemeral UID allocator)
- Full sd_listen_fds socket activation (dinit covers Unix sockets)
- networkd, resolved, journald (use scripted networking, dhcpcd, syslogd)
- Monotonic timers (OnBootSec, etc. — calendar only via snooze)
- Shared binary cache between users (each user has own store)
### What we gain
- No systemd runtime
- No Nix daemon (simpler, fewer moving parts, smaller attack surface)
- Minimal base (dinit + busybox + eudev)
- Parallel dependency-ordered boot (3-layer dinit DAG)
- Readiness notification for services
- Native capabilities, cgroup placement, rlimits in the init
- Full control over every layer
- Access to ~80-85% of NixOS service modules via the translator
- Real sandboxing via bubblewrap + dinit native security
- Standard hardware-configuration.nix compatibility
- Per-user isolation (separate stores, separate dinit instances)
- Proper user merge semantics (sysusers — password changes persist)
---
## Development Roadmap
### Done
- [x] System profile via evalModulesMinimal + full NixOS module set
- [x] systemd->dinit translator with bwrap sandboxing
- [x] Timer->snooze translation (calendar timers, RandomizedDelaySec, Persistent)
- [x] AmbientCapabilities -> dinit native IAB capabilities
- [x] Socket activation for Unix sockets (socket-listen)
- [x] PIDFile, wants translation, rlimits (nofile, core, data, addrspace)
- [x] Template services (getty@, serial-getty@, autovt@) with native dinit `$1`
- [x] User/group management with sysusers + dynamic allocation + NixOS bridge
- [x] eudev compatibility layer + standalone tools (tmpfiles, sysusers)
- [x] Persistent qcow2 disk image builder
- [x] minix-generate-config (virt-what) and minix-rebuild tools
- [x] dinit-init.nix — 3-layer boot DAG (early->system->boot) + shutdown hook
- [x] seedrng (busybox built-in) for entropy management
- [x] Serial console detection via kernel params (no udev deadlock)
- [x] Mutable /etc with first-boot-only writes + sysusers merge semantics
- [x] VM-tested: boot, reboot, SSH, password persistence, service ordering
- [x] nix-daemon suppressed (optionsOnly — no daemon service, no nixbld users)
- [x] turnstile session tracking (per-user dinit instances, XDG_RUNTIME_DIR)
- [x] PAM integration (pam_turnstile.so in login/sshd/su/sudo)
- [x] minix-rebuild enhanced (build + switch subcommands, configurable flake ref)
- [x] VM-tested: turnstiled running, user dinit instance, XDG_RUNTIME_DIR=/run/user/$UID
- [x] hwdb.bin compiled from systemd hwdb.d data (11 MB, eudev device identification working)
- [x] systemd-cat stub (strips journal flags, execs command — for X11 display manager modules)
### Next: per-user Nix stores + Home Manager
- [ ] User store: per-user Nix store paths (~/.local/share/nix/)
- [ ] Home Manager integration with per-user stores
- [ ] System-provided user services (D-Bus session bus, PipeWire via /etc/dinit.d/user/)
### Future
- [ ] Board support (Rockchip SBCs, SD card images)
- [ ] GRUB bootloader (currently extlinux only)
- [ ] ISO for generic x86 install
- [ ] Seccomp filter generation (SystemCallFilter -> BPF)
- [ ] Live system switching without reboot (stretch goal)
- [ ] Rewrite minix-generate-config in shell (drop Perl dependency)
---
## References
```
Refs/
nixpkgs/ Shallow clone of nixpkgs for module inspection
dinit/ dinit source + man pages (service file format spec)
dinit-chimera/ Chimera Linux's dinit system integration (production reference)
NixNG/ NixNG — has a Nix-native dinit module (modules/dinit/)
systemd/ systemd v260.1 source (standalone tmpfiles + sysusers builds)
seedrng/ seedrng source by Jason Donenfeld (reference; busybox ships it)
turnstile/ turnstile source (session/login tracker, Chimera Linux)
```

80
system/eval.nix Normal file
View file

@ -0,0 +1,80 @@
# system/eval.nix — full NixOS module set (blacklist-filtered) + MiNix modules.
{ nixpkgs }:
args @ {
modules,
specialArgs ? {},
...
}:
let
lib = nixpkgs.lib;
# NixOS utils — provides fsNeededForBoot, systemdUtils types, etc.
utils = import (nixpkgs + "/nixos/lib/utils.nix") {
inherit lib config pkgs;
};
allNixosModules = import (nixpkgs + "/nixos/modules/module-list.nix");
isExcluded = mod:
if !builtins.isPath mod && !builtins.isString mod then false
else let p = toString mod; in
builtins.any (pat: lib.hasInfix pat p) [
"system/boot/systemd" # → systemd-compat.nix (optionsOnly + translator)
"system/boot/stage-2.nix" # → systemd-compat.nix
"system/activation/" # → systemd-compat.nix (cherry-picks activation-script.nix)
# config/system-path.nix — whitelisted (also imported by documentation.nix)
"tasks/network-interfaces-systemd.nix" # networkd backend, needs systemd-networkd
"system/boot/networkd.nix" # same
"system/boot/shutdown.nix" # unconditionally refs systemd poweroff
"services/system/nix-daemon" # → systemd-compat.nix (optionsOnly; no daemon)
];
nixosModules = builtins.filter (m: !isExcluded m) allNixosModules;
minixModules = [
./modules/dinit-services.nix
./modules/dinit-init.nix
./modules/users.nix
./modules/system-path.nix
./modules/base-etc.nix
./modules/systemd-compat.nix
./modules/eudev-compat.nix
./modules/toplevel.nix
./modules/rebuild.nix
./modules/generate-config.nix
./modules/defaults.nix
./modules/turnstile.nix
# Cherry-picked from blanket-excluded dirs
(nixpkgs + "/nixos/modules/system/activation/activation-script.nix")
(nixpkgs + "/nixos/modules/system/boot/systemd/tmpfiles.nix")
];
evalModulesMinimal =
(import (nixpkgs + "/nixos/lib") {
inherit lib;
featureFlags.minimalModules = {};
})
.evalModules;
baseModules = minixModules ++ nixosModules;
result = evalModulesMinimal {
specialArgs = {
modulesPath = builtins.toString (nixpkgs + "/nixos/modules");
nixpkgsSrc = nixpkgs;
inherit utils;
} // specialArgs;
modules = baseModules ++ args.modules;
};
# Circular ref — resolved lazily
inherit (result) config;
pkgs = result._module.args.pkgs;
in result // {
inherit pkgs;
inherit lib;
}

108
system/lib/translator.nix Normal file
View file

@ -0,0 +1,108 @@
# Common systemd→dinit translator functions.
#
# Shared by system/modules/systemd-compat.nix (system services) and
# system/modules/hm-dinit.nix (user services via Home Manager).
#
# Pure functions operating on systemd service attrsets — no module config deps.
{ lib, pkgs }:
let
inherit (lib)
optionalString concatStringsSep concatMapStringsSep
hasPrefix removePrefix hasSuffix removeSuffix
escapeShellArg filter isList toList;
# systemd convention: ExecStart = ["" cmd] clears upstream default.
getScalar = v:
if isList v then
let nonEmpty = filter (x: x != "" && x != null) v;
in if nonEmpty == [] then builtins.head v else lib.last nonEmpty
else v;
scGet = svc: key: default:
if svc.serviceConfig ? ${key} then getScalar svc.serviceConfig.${key} else default;
scGetList = svc: key:
if svc.serviceConfig ? ${key} then toList svc.serviceConfig.${key} else [];
scHas = svc: key: svc.serviceConfig ? ${key};
scBool = svc: key:
if !(scHas svc key) then false
else let v = getScalar svc.serviceConfig.${key};
in v == true || v == "true" || v == "yes" || v == "on" || v == "1";
getDinitType = svc:
let raw = scGet svc "Type" "simple";
in if raw == "simple" || raw == "exec" || raw == "notify"
|| raw == "notify-reload" || raw == "idle"
then "process"
else if raw == "oneshot" then "scripted"
else if raw == "forking" then "bgprocess"
else "process";
getDinitRestart = svc:
let policy = scGet svc "Restart" "no";
in if policy == "always" then "yes"
else if policy == "on-failure" || policy == "on-abnormal"
|| policy == "on-abort" || policy == "on-watchdog"
then "on-failure"
else "no";
# Strip systemd exec prefix chars (-, :, +, !, !!, @) in any combination.
stripExecPrefix = cmd:
let
s = toString cmd;
peel = str: foundAt:
if str == "" then { inherit str; hasAt = foundAt; }
else let c = builtins.substring 0 1 str;
tail = builtins.substring 1 (builtins.stringLength str - 1) str;
in if c == "-" || c == ":" || c == "+" || c == "!"
then peel tail foundAt
else if c == "@" then peel tail true
else { inherit str; hasAt = foundAt; };
result = peel s false;
tokens = lib.splitString " " result.str;
binary = builtins.head tokens;
rest = builtins.tail tokens;
args = if result.hasAt && rest != [] then builtins.tail rest else rest;
in if args != [] then "${binary} ${concatStringsSep " " args}" else binary;
mkEnvFile = name: svc:
let
envVars = lib.filterAttrs (_: v: v != null) svc.environment;
lines = lib.mapAttrsToList (k: v: "${k}=${toString v}") envVars;
in if lines == [] then null
else pkgs.writeText "${name}.env" (concatStringsSep "\n" lines + "\n");
# Runtime EnvironmentFile paths — strip optional `-` prefix
getEnvFilePaths = svc:
map (f: let s = toString f; in if hasPrefix "-" s then removePrefix "-" s else s)
(scGetList svc "EnvironmentFile");
buildDinitDeps = name: svc:
let
stripSuffix = dep: if hasSuffix ".service" dep then removeSuffix ".service" dep else dep;
isRelevant = dep:
dep != name && !(hasPrefix "systemd-" dep)
&& !(hasSuffix ".target" dep) && !(hasSuffix ".socket" dep)
&& !(hasSuffix ".mount" dep) && !(hasSuffix ".slice" dep)
&& !(hasSuffix ".path" dep);
afterSet = filter isRelevant (lib.unique (map stripSuffix svc.after));
requireSet = filter isRelevant (lib.unique (map stripSuffix svc.requires));
wantsSet = filter isRelevant (lib.unique (map stripSuffix svc.wants));
hardDeps = lib.intersectLists afterSet requireSet;
# wants + after = waits-for (start dep, wait, don't fail if dep fails)
softDeps = lib.intersectLists afterSet wantsSet;
in {
dependsOn = lib.unique (hardDeps ++ lib.subtractLists afterSet requireSet);
waitsFor = softDeps;
after = lib.unique (lib.subtractLists (requireSet ++ wantsSet) afterSet);
};
in {
inherit getScalar scGet scGetList scHas scBool;
inherit getDinitType getDinitRestart;
inherit stripExecPrefix mkEnvFile getEnvFilePaths buildDinitDeps;
}

View file

@ -0,0 +1,31 @@
# Base /etc files unique to MiNix.
# Stock shells-environment.nix and system-environment.nix handle /etc/profile,
# /etc/shells, environment.profiles, and environment.sessionVariables.
{ config, pkgs, lib, ... }:
let
inherit (lib) mkOption types;
in {
options.minix.hostname = mkOption {
type = types.str;
default = "minix";
description = "System hostname.";
};
config = {
networking.hostName = config.minix.hostname;
environment.profiles = lib.mkBefore [
"/nix/var/nix/profiles/system/sw"
];
# nsswitch.conf is handled by stock nsswitch.nix via system.nssDatabases.
# Its defaults (passwd: files, group: files, hosts: files dns) are correct
# for MiNix, and using stock keeps the API extensible for modules like
# mDNS, LDAP, etc.
# Override NixOS default (/run/current-system/sw/...) to our profile path
environment.sessionVariables.LOCALE_ARCHIVE = lib.mkForce
"/nix/var/nix/profiles/system/sw/lib/locale/locale-archive";
};
}

View file

@ -0,0 +1,50 @@
# MiNix default configuration — feature flags, distro identity, overlay.
{ lib, pkgs, ... }:
{
nixpkgs.overlays = [ (import ../overlay.nix) ];
boot.kexec.enable = false;
powerManagement.enable = false;
networking.resolvconf.enable = lib.mkDefault true;
documentation.nixos.enable = false;
system.nixos.distroId = lib.mkDefault "minix";
system.nixos.distroName = lib.mkDefault "MiNix";
system.nixos.vendorId = lib.mkDefault "minix";
system.nixos.vendorName = lib.mkDefault "MiNix";
# Busybox login doesn't support yescrypt ($y$) — use SHA-512 ($6$).
# login.defs covers shadow-utils commands that read it directly;
# PAM override covers passwd/chpasswd which go through pam_unix.
security.loginDefs.settings.ENCRYPT_METHOD = "SHA512";
security.pam.services.passwd.rules.password.unix.settings = lib.mkForce {
nullok = true;
sha512 = true;
};
security.pam.services.chpasswd.rules.password.unix.settings = lib.mkForce {
nullok = true;
sha512 = true;
};
# Enable local-overlay-store for per-user Nix stores (Phase 3 L2).
# Machine config adds nix-command + flakes; we add local-overlay-store here
# as a distro default so all MiNix systems support overlay stores.
nix.settings.experimental-features = lib.mkDefault [ "nix-command" "flakes" "local-overlay-store" ];
# vconsole-setup is handled by systemd-vconsole-setup.service which
# we don't have. The reload service calls systemctl — disable it.
systemd.services.reload-systemd-vconsole-setup.enable = lib.mkDefault false;
# Base system logger (busybox syslogd)
dinit.services.syslogd = {
type = "process";
command = "${lib.getBin pkgs.busybox}/bin/syslogd -n";
dependsMs = [ "early" ];
};
# Getty services need to respawn after logout. Stock systemd unit files
# inherit Restart=always from upstream presets that we don't have.
systemd.services."serial-getty@".serviceConfig.Restart = lib.mkDefault "always";
systemd.services."autovt@".serviceConfig.Restart = lib.mkDefault "always";
systemd.services."container-getty@".serviceConfig.Restart = lib.mkDefault "always";
systemd.services."getty@".serviceConfig.Restart = lib.mkDefault "always";
}

View file

@ -0,0 +1,421 @@
# dinit-init.nix — generates init wrapper, early boot services, and boot service.
#
# Replaces runit-init.nix. The init wrapper (system.build.bootStage2) does minimal
# pseudo-fs setup and execs dinit as PID 1. Early boot tasks that were previously a
# monolithic stage-1 script are now a DAG of dinit services that run in parallel
# where possible.
#
# Service DAG (top-down):
# boot (dinit default) → system (all services) → early (pseudo-fs, etc, eudev…)
# boot waits-for gettys → gettys depend-ms system (login prompt appears last)
{ config, pkgs, lib, ... }:
let
inherit (lib) mkOption mkDefault mkForce mkIf types
optionalString concatStringsSep filterAttrs;
busybox = pkgs.busybox;
eudev = pkgs.eudev;
dinit = pkgs.dinit;
kmod = pkgs.kmod;
profilePath = "/nix/var/nix/profiles/system";
# Collect all services that should auto-start via the system milestone.
# Excludes internal milestones and early-* (pulled in via depends-on chain).
allAutoStart = let
svcs = filterAttrs (name: svc:
svc.enable && svc.autoStart && name != "boot" && name != "system"
) config.dinit.services;
in builtins.attrNames svcs;
# Serial console gettys from kernel console= params (e.g. console=ttyS0 → serial-getty@ttyS0)
serialConsoles = lib.concatMap (p:
let m = builtins.match "console=(tty[A-Za-z]+[0-9]+)(,.*)?$" p;
in if m != null then [ "serial-getty@${builtins.elemAt m 0}" ] else []
) config.boot.kernelParams;
# --- Init wrapper ---
# Minimal PID 1 entry point: mount essential pseudo-fs, install busybox,
# then exec dinit with --services-dir pointing to the system profile.
initWrapper = pkgs.writeScript "minix-init" ''
#!${busybox}/bin/sh
export PATH=${busybox}/bin
mkdir -p /bin /sbin /run /tmp
${busybox}/bin/busybox --install -s /bin
# Ensure essential pseudo-fs (initrd usually handles this; these are fallbacks)
mountpoint -q /proc || mount -t proc proc /proc -o nosuid,noexec,nodev
mountpoint -q /sys || mount -t sysfs sys /sys -o nosuid,noexec,nodev
mountpoint -q /dev || mount -t devtmpfs dev /dev -o mode=0755,nosuid
mountpoint -q /run || mount -t tmpfs run /run -o mode=0755,nosuid,nodev
# Remount root rw (initrd may leave it ro)
mount -o remount,rw / 2>/dev/null || true
PROFILE="$(readlink -f ${profilePath})"
exec ${dinit}/bin/dinit --system --services-dir "$PROFILE/dinit.d"
'';
# --- Early boot service scripts ---
earlyPseudofsScript = pkgs.writeScript "early-pseudofs" ''
#!${busybox}/bin/sh
# Mount remaining pseudo-filesystems and create standard directories.
# proc/sys/dev/run are already mounted by the init wrapper.
mountpoint -q /tmp || mount -t tmpfs tmp /tmp -o mode=1777,nosuid,nodev
mkdir -p /dev/pts /dev/shm
mountpoint -q /dev/pts || mount -t devpts devpts /dev/pts -o mode=0620,gid=5,ptmxmode=0666,nosuid,noexec
mountpoint -q /dev/shm || mount -t tmpfs shm /dev/shm -o mode=1777,nosuid,nodev
# /run/wrappers — separate tmpfs WITHOUT nosuid so SUID wrappers work
mkdir -p /run/wrappers
mountpoint -q /run/wrappers || mount -t tmpfs tmpfs /run/wrappers -o mode=0755,nodev
# cgroup2 unified
if [ -d /sys/fs/cgroup ] && ! mountpoint -q /sys/fs/cgroup; then
mount -t cgroup2 cgroup2 /sys/fs/cgroup -o nsdelegate 2>/dev/null || true
fi
# /dev symlinks
ln -sfn /proc/self/fd /dev/fd
ln -sf /proc/self/fd/0 /dev/stdin
ln -sf /proc/self/fd/1 /dev/stdout
ln -sf /proc/self/fd/2 /dev/stderr
# Standard directories
mkdir -p /run/lock /run/user /var/log /var/empty /var/tmp /var/lib/dhcpcd
[ -L /var/run ] || { rm -rf /var/run; ln -s /run /var/run; }
ln -sf /proc/mounts /etc/mtab
'';
earlyEudevScript = pkgs.writeScript "early-eudev" ''
#!${busybox}/bin/sh
# Start eudev daemon, trigger coldplug, and settle.
# Set firmware path before coldplug
if [ -e /sys/module/firmware_class/parameters/path ]; then
echo -n "${profilePath}/firmware" > /sys/module/firmware_class/parameters/path
fi
${eudev}/bin/udevd --daemon
${eudev}/bin/udevadm trigger --action=add --type=subsystems
${eudev}/bin/udevadm trigger --action=add --type=devices
${eudev}/bin/udevadm settle
'';
earlyEtcScript = pkgs.writeScript "early-etc" ''
#!${busybox}/bin/sh
# Populate /etc from the system profile.
# etc.nix marks mutable files with .mode sidecar files.
# mode=symlink (default) -> symlink to store (immutable)
# mode=0644 etc -> copy (writable at runtime)
PROFILE="$(readlink -f ${profilePath})"
if [ ! -e "$PROFILE" ]; then
echo "ERROR: System profile not found at ${profilePath}" >&2
exit 1
fi
# Clean stale symlinks from a previous system profile
if [ -d /etc ]; then
find /etc -type l 2>/dev/null | while read -r link; do
target="$(readlink "$link" 2>/dev/null)" || continue
case "$target" in
/nix/store/*) rm -f "$link" ;;
esac
done
find /etc -mindepth 1 -type d -empty -delete 2>/dev/null || true
fi
if [ -d "$PROFILE/etc" ]; then
cd "$PROFILE/etc"
: > /etc/.clean.new
find . -type f -o -type l | while read -r f; do
f="''${f#./}"
case "$f" in
*.mode|*.uid|*.gid) continue ;;
esac
target="/etc/$f"
target_dir="$(dirname "$target")"
mkdir -p "$target_dir"
if [ -f "$PROFILE/etc/''${f}.mode" ]; then
mode="$(cat "$PROFILE/etc/''${f}.mode")"
if [ "$mode" = "direct-symlink" ]; then
cp -a "$PROFILE/etc/$f" "$target"
elif [ -e "$target" ]; then
# Mutable file already exists — preserve runtime changes
true
else
cp -L "$PROFILE/etc/$f" "$target.tmp"
chmod "$mode" "$target.tmp"
_uid="0"; _gid="0"
[ -f "$PROFILE/etc/''${f}.uid" ] && _uid="$(cat "$PROFILE/etc/''${f}.uid")"
[ -f "$PROFILE/etc/''${f}.gid" ] && _gid="$(cat "$PROFILE/etc/''${f}.gid")"
_uid="''${_uid#+}"; _gid="''${_gid#+}"
if [ "$_uid" != "0" ] || [ "$_gid" != "0" ]; then
chown "$_uid:$_gid" "$target.tmp"
fi
mv -f "$target.tmp" "$target"
fi
echo "$f" >> /etc/.clean.new
elif [ -L "$PROFILE/etc/$f" ]; then
cp -a "$PROFILE/etc/$f" "$target"
else
ln -sf "$PROFILE/etc/$f" "$target"
fi
done
cd /
# Remove mutable files from previous profile that no longer exist
if [ -f /etc/.clean ]; then
while read -r _old; do
[ -z "$_old" ] && continue
if ! grep -qxF "$_old" /etc/.clean.new 2>/dev/null; then
rm -f "/etc/$_old"
fi
done < /etc/.clean
fi
mv -f /etc/.clean.new /etc/.clean
fi
'';
earlyModulesScript = pkgs.writeScript "early-modules" ''
#!${busybox}/bin/sh
# Link kernel modules and load modules from /etc/modules-load.d/.
PROFILE="$(readlink -f ${profilePath})"
if [ -e /proc/modules ] && [ -d "$PROFILE/kernel/modules/lib/modules" ]; then
rm -rf /lib/modules
ln -sfn "$PROFILE/kernel/modules/lib/modules" /lib/modules
fi
for _f in /etc/modules-load.d/*.conf; do
[ -f "$_f" ] || continue
while read -r _mod; do
case "$_mod" in "#"*|"") continue ;; esac
${kmod}/bin/modprobe "$_mod" 2>/dev/null || true
done < "$_f"
done
'';
earlySysinitScript = pkgs.writeScript "early-sysinit" ''
#!${busybox}/bin/sh
# System initialization: tmpfiles, sysusers, machine-id, sysctl, fstab,
# random seed, nix path registration, store hardening, activation scripts.
PROFILE="$(readlink -f ${profilePath})"
# tmpfiles — create directories, symlinks, etc.
if [ -x "$PROFILE/sw/bin/systemd-tmpfiles" ]; then
"$PROFILE/sw/bin/systemd-tmpfiles" --create --remove --exclude-prefix=/dev 2>/dev/null || true
fi
# sysusers — create/merge system users and groups
if [ -x "$PROFILE/sw/bin/systemd-sysusers" ]; then
CREDENTIALS_DIRECTORY="$PROFILE/sysusers-credentials" \
"$PROFILE/sw/bin/systemd-sysusers" 2>/dev/null || true
fi
# Generate machine-id if missing
if [ ! -f /etc/machine-id ]; then
if [ -r /proc/sys/kernel/random/uuid ]; then
tr -d '-' < /proc/sys/kernel/random/uuid > /etc/machine-id
fi
fi
# Apply sysctl settings
for _f in /etc/sysctl.d/*.conf /etc/sysctl.conf; do
[ -f "$_f" ] && sysctl -p "$_f" 2>/dev/null || true
done
# Mount additional filesystems from /etc/fstab
if [ -f /etc/fstab ]; then
mount -a -t nosysfs,noproc,nodevtmpfs,notmpfs -O no_netdev 2>/dev/null || true
swapon -a 2>/dev/null || true
fi
# Seed the RNG (load saved seed, credit entropy, save new seed)
seedrng 2>/dev/null || true
# Register Nix store paths (first boot on persistent disk)
if [ -f /nix-path-registration ] && [ ! -f /nix/var/nix/db/db.sqlite ]; then
if [ -x "$PROFILE/sw/bin/nix-store" ]; then
HOME=/root "$PROFILE/sw/bin/nix-store" --load-db < /nix-path-registration
rm -f /nix-path-registration
fi
fi
${optionalString config.minix.hardenStore ''
# Harden /nix/store — read-only bind mount
mount --bind /nix/store /nix/store
mount -o remount,ro,nosuid,nodev,bind /nix/store
''}
# Run activation scripts
if [ -x "$PROFILE/activate" ]; then
"$PROFILE/activate" "$PROFILE" || echo "WARNING: Activation script failed (non-fatal)" >&2
fi
# Record boot configuration
ln -sfn "$PROFILE" /run/booted-system
'';
earlyHostnameScript = pkgs.writeScript "early-hostname" ''
#!${busybox}/bin/sh
if [ -f /etc/hostname ]; then
HOSTNAME="$(cat /etc/hostname)"
if [ -n "$HOSTNAME" ]; then
printf "%s" "$HOSTNAME" > /proc/sys/kernel/hostname
fi
fi
'';
earlyLoopbackScript = pkgs.writeScript "early-loopback" ''
#!${busybox}/bin/sh
${busybox}/bin/ip link set up dev lo 2>/dev/null || \
${busybox}/bin/ifconfig lo 127.0.0.1 up 2>/dev/null || true
'';
in {
options.minix.hardenStore = mkOption {
type = types.bool;
default = false;
description = "Bind-mount /nix/store read-only (nosuid,nodev). Breaks single-user nix builds.";
};
config = {
# Init wrapper — toplevel.nix copies this as $out/init
system.build.bootStage2 = initWrapper;
# dinit in system path (provides dinitctl, shutdown, halt, poweroff, reboot)
environment.systemPackages = [ dinit ];
# Force-empty activation scripts that conflict with early boot services:
# - etc: uses Perl (setup-etc.pl) — we populate /etc in early-etc
# - specialfs: mounts pseudo-fs — we do this in init wrapper + early-pseudofs
# - binsh: creates /bin/sh → bashInteractive — we want busybox
system.activationScripts.etc = mkForce "";
system.activationScripts.specialfs = mkForce "";
system.activationScripts.binsh = mkForce "";
# --- Early boot services ---
dinit.services.early-pseudofs = {
type = "scripted";
command = "${earlyPseudofsScript}";
restart = "no";
autoStart = false;
};
dinit.services.early-eudev = {
type = "scripted";
command = "${earlyEudevScript}";
stopCommand = "${eudev}/bin/udevadm control --exit";
restart = "no";
dependsOn = [ "early-pseudofs" ];
autoStart = false;
};
dinit.services.early-etc = {
type = "scripted";
command = "${earlyEtcScript}";
restart = "no";
dependsOn = [ "early-pseudofs" ];
autoStart = false;
};
dinit.services.early-modules = {
type = "scripted";
command = "${earlyModulesScript}";
restart = "no";
dependsOn = [ "early-pseudofs" "early-etc" ];
autoStart = false;
};
dinit.services.early-sysinit = {
type = "scripted";
command = "${earlySysinitScript}";
restart = "no";
dependsOn = [ "early-etc" "early-eudev" "early-modules" ];
autoStart = false;
};
dinit.services.early-hostname = {
type = "scripted";
command = "${earlyHostnameScript}";
restart = "no";
dependsOn = [ "early-etc" ];
autoStart = false;
};
dinit.services.early-loopback = {
type = "scripted";
command = "${earlyLoopbackScript}";
restart = "no";
dependsOn = [ "early-pseudofs" ];
autoStart = false;
};
# Early milestone — all early boot tasks complete
dinit.services.early = {
type = "internal";
restart = "no";
dependsOn = [ "early-sysinit" "early-hostname" "early-loopback" ];
autoStart = false;
};
# System milestone — all non-login services started.
dinit.services.system = {
type = "internal";
restart = "no";
dependsOn = [ "early" ];
waitsFor = mkDefault allAutoStart;
autoStart = false;
};
# Boot service — top-level entry point (dinit starts "boot" by default).
# depends-on system (all services ready), then waits-for login gettys.
dinit.services.boot = {
type = "internal";
restart = "no";
dependsOn = [ "system" ];
waitsFor = mkDefault serialConsoles;
autoStart = false;
};
# Getty templates wait for system (not just early) so login prompts
# appear after all service status lines.
dinit.services."serial-getty@".dependsMs = mkForce [ "system" ];
dinit.services."autovt@".dependsMs = mkForce [ "system" ];
dinit.services."getty@".dependsMs = mkForce [ "system" ];
dinit.services."container-getty@".dependsMs = mkForce [ "system" ];
# Shutdown hook — save random seed before filesystem unmount.
# dinit's shutdown utility calls this after stopping services.
# Exit non-zero tells dinit to handle unmounting itself.
environment.etc."dinit/shutdown-hook" = {
mode = "0755";
text = ''
#!/bin/sh
seedrng 2>/dev/null || true
exit 1
'';
};
# Udev rules for hotplug serial devices.
# Built-in ttyS* consoles are handled by serialConsoles (kernel console= param).
# USB/ACM serial devices get a getty on hotplug — backgrounded to avoid
# blocking udevadm settle during early-eudev.
services.udev.extraRules = ''
SUBSYSTEM=="tty", KERNEL=="ttyUSB*", TAG+="minix", RUN+="/bin/sh -c '${dinit}/bin/dinitctl start serial-getty@%k &'"
SUBSYSTEM=="tty", KERNEL=="ttyACM*", TAG+="minix", RUN+="/bin/sh -c '${dinit}/bin/dinitctl start serial-getty@%k &'"
'';
};
}

View file

@ -0,0 +1,314 @@
# Declarative dinit service definitions.
# Generates /etc/dinit.d/<name> service files via environment.etc.
# Template services (names ending with @) use dinit's native $1 substitution.
#
# This is the user-facing API for native dinit services. The systemd->dinit
# translator in systemd-compat.nix also writes to dinit.services.
{ config, pkgs, lib, ... }:
let
inherit (lib) mkOption mkEnableOption mkIf mkMerge mkDefault
mapAttrs mapAttrs' filterAttrs types
optionalString concatStringsSep concatMapStringsSep
escapeShellArg;
serviceOpts = types.submodule ({ name, config, ... }: {
options = {
enable = mkEnableOption "this service" // { default = true; };
type = mkOption {
type = types.enum [ "process" "bgprocess" "scripted" "internal" "triggered" ];
default = "process";
description = "dinit service type.";
};
command = mkOption {
type = types.nullOr types.str;
default = null;
description = "Command to start the service. Not needed for internal/triggered types.";
};
stopCommand = mkOption {
type = types.nullOr types.str;
default = null;
description = "Command to stop the service (optional, default is SIGTERM).";
};
startScript = mkOption {
type = types.nullOr types.lines;
default = null;
description = ''
If set, a wrapper script is generated and used as the command.
Use this when pre-start setup (mkdir, keygen, etc.) is needed
before exec'ing the actual service.
'';
};
stopScript = mkOption {
type = types.nullOr types.lines;
default = null;
description = "If set, a wrapper script is generated and used as the stop-command.";
};
runAs = mkOption {
type = types.nullOr types.str;
default = null;
description = "User (or user:group) to run the service as.";
};
workingDir = mkOption {
type = types.nullOr types.str;
default = null;
description = "Working directory for the service.";
};
envFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Path to a build-time environment file (KEY=VALUE format).";
};
extraEnvFiles = mkOption {
type = types.listOf types.str;
default = [];
description = "Additional runtime environment files (loaded by dinit, KEY=VALUE format).";
};
dependsOn = mkOption {
type = types.listOf types.str;
default = [];
description = "Hard dependencies service stops if any dependency stops.";
};
dependsMs = mkOption {
type = types.listOf types.str;
default = [];
description = "Milestone dependencies must start first, but can stop later.";
};
waitsFor = mkOption {
type = types.listOf types.str;
default = [];
description = "Soft dependencies wait for start/fail, but don't fail if dep fails.";
};
after = mkOption {
type = types.listOf types.str;
default = [];
description = "Ordering only if named service is starting, wait for it first.";
};
before = mkOption {
type = types.listOf types.str;
default = [];
description = "Reverse ordering named service waits for this one.";
};
restart = mkOption {
type = types.enum [ "yes" "on-failure" "no" ];
default = "yes";
description = "Restart policy.";
};
logfile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Log file path for service output.";
};
rlimits = mkOption {
type = types.attrsOf types.str;
default = {};
description = ''
Resource limits. Keys are dinit rlimit names (nofile, core, data, addrspace).
Values are "soft:hard" or just "limit".
'';
example = { nofile = "1024:4096"; core = "0"; addrspace = "1073741824"; };
};
capabilities = mkOption {
type = types.nullOr types.str;
default = null;
description = "Linux capabilities IAB string.";
};
socketListen = mkOption {
type = types.nullOr types.str;
default = null;
description = "Unix socket path for socket activation (dinit socket-listen).";
};
socketPermissions = mkOption {
type = types.nullOr types.str;
default = null;
description = "Octal permissions for the socket (default: 0666).";
};
socketUid = mkOption {
type = types.nullOr types.str;
default = null;
description = "Socket owner (username or UID).";
};
socketGid = mkOption {
type = types.nullOr types.str;
default = null;
description = "Socket group (groupname or GID).";
};
options = mkOption {
type = types.listOf types.str;
default = [];
description = "dinit options (runs-on-console, no-new-privs, starts-rwfs, etc.).";
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = "Raw lines appended to the dinit service file.";
};
pidFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "PID file path (for bgprocess type services).";
};
readyNotification = mkOption {
type = types.nullOr types.str;
default = null;
description = "Readiness notification mechanism (e.g. pipefd:3, pipevar:NOTIFY_FD).";
};
termSignal = mkOption {
type = types.nullOr types.str;
default = null;
description = "Signal to send when stopping (default: TERM).";
};
smoothRecovery = mkOption {
type = types.bool;
default = false;
description = "Restart without stopping dependents on unexpected exit.";
};
autoStart = mkOption {
type = types.bool;
default = builtins.elem config.type [ "process" "bgprocess" "scripted" ];
description = "Whether the boot service should automatically start this service.";
};
};
});
allServices = filterAttrs (_: svc: svc.enable) config.dinit.services;
# Generate the dinit service file content from a service attrset
mkServiceFile = name: svc:
let
# Resolve command: startScript generates a wrapper, otherwise use command directly
resolvedCommand =
if svc.startScript != null then
"${pkgs.writeScript "${name}-start" svc.startScript}"
else
svc.command;
resolvedStopCommand =
if svc.stopScript != null then
"${pkgs.writeScript "${name}-stop" svc.stopScript}"
else
svc.stopCommand;
lines = lib.filter (s: s != "") [
"type = ${svc.type}"
(optionalString (resolvedCommand != null)
"command = ${resolvedCommand}")
(optionalString (resolvedStopCommand != null)
"stop-command = ${resolvedStopCommand}")
(optionalString (svc.runAs != null)
"run-as = ${svc.runAs}")
(optionalString (svc.workingDir != null)
"working-dir = ${svc.workingDir}")
(optionalString (svc.pidFile != null)
"pid-file = ${svc.pidFile}")
(optionalString (svc.readyNotification != null)
"ready-notification = ${svc.readyNotification}")
"restart = ${svc.restart}"
(optionalString (svc.termSignal != null)
"term-signal = ${svc.termSignal}")
(optionalString svc.smoothRecovery
"smooth-recovery = true")
(optionalString (svc.logfile != null)
"logfile = ${svc.logfile}")
# Environment files
(optionalString (svc.envFile != null)
"env-file = ${svc.envFile}")
(concatMapStringsSep "\n" (f: "env-file += ${f}") svc.extraEnvFiles)
# Dependencies
(concatMapStringsSep "\n" (d: "depends-on: ${d}") svc.dependsOn)
(concatMapStringsSep "\n" (d: "depends-ms: ${d}") svc.dependsMs)
(concatMapStringsSep "\n" (d: "waits-for: ${d}") svc.waitsFor)
(concatMapStringsSep "\n" (d: "after: ${d}") svc.after)
(concatMapStringsSep "\n" (d: "before: ${d}") svc.before)
# Resource limits
(concatStringsSep "\n" (lib.mapAttrsToList
(k: v: "rlimit-${k} = ${v}") svc.rlimits))
# Capabilities
(optionalString (svc.capabilities != null)
"capabilities = ${svc.capabilities}")
# Socket activation
(optionalString (svc.socketListen != null)
"socket-listen = ${svc.socketListen}")
(optionalString (svc.socketPermissions != null)
"socket-permissions = ${svc.socketPermissions}")
(optionalString (svc.socketUid != null)
"socket-uid = ${svc.socketUid}")
(optionalString (svc.socketGid != null)
"socket-gid = ${svc.socketGid}")
# Options
(optionalString (svc.options != [])
"options: ${concatStringsSep " " svc.options}")
# Extra raw config
svc.extraConfig
];
in
concatStringsSep "\n" lines + "\n";
in {
options = {
dinit.services = mkOption {
type = types.attrsOf serviceOpts;
default = {};
description = "Attribute set of dinit service definitions.";
};
};
config = {
# dinit template files are named without the trailing '@' —
# e.g. service "getty@" is stored as dinit.d/getty, not dinit.d/getty@.
# The '@' is only used when referencing instances (dinitctl start getty@tty1).
environment.etc = mapAttrs' (name: svc: {
name = "dinit.d/${lib.removeSuffix "@" name}";
value = {
text = mkServiceFile name svc;
};
}) allServices;
};
}

View file

@ -0,0 +1,170 @@
# eudev-compat.nix — presents eudev as systemd.package for stock NixOS modules.
#
# NixOS's stage-1.nix does `udev = config.systemd.package` and expects paths like
# ${udev}/lib/systemd/systemd-sysctl and ${udev}/lib/systemd/network/*.link that
# eudev doesn't provide. This module builds a thin wrapper that fills those gaps,
# then sets systemd.package to point at it.
{ config, pkgs, lib, ... }:
let
inherit (lib) concatStringsSep;
tmpfilesStandalone = pkgs.callPackage ../packages/systemd-tmpfiles-standalone.nix {};
sysusersStandalone = pkgs.callPackage ../packages/systemd-sysusers-standalone.nix {};
# systemd source — hwdb.d data files (PCI/USB/Bluetooth IDs, input devices, sensors, etc.)
systemdSrc = pkgs.fetchFromGitHub {
owner = "systemd";
repo = "systemd";
rev = "v260.1";
hash = "sha256-FUKj3lvjz8TIsyu8NyJYtiNele+1BhdJPdw7r7nW6as=";
};
# Compile hwdb.bin from systemd hwdb.d data + udev package hwdb files.
# Uses eudev's udevadm (same binary format as systemd-hwdb).
udevPackages = config.services.udev.packages or [];
hwdbBin = pkgs.runCommand "hwdb.bin" {
preferLocalBuild = true;
allowSubstitutes = false;
nativeBuildInputs = [ pkgs.eudev ];
} ''
mkdir -p etc/udev/hwdb.d
# systemd hwdb.d data files (35 files: PCI, USB, Bluetooth, ACPI, OUI, etc.)
for f in ${systemdSrc}/hwdb.d/*.hwdb; do
cp "$f" etc/udev/hwdb.d/
done
# hwdb files from udev packages (eudev itself + extras)
for i in ${pkgs.eudev} ${concatStringsSep " " (map toString udevPackages)}; do
for j in $i/{etc,lib}/udev/hwdb.d/*; do
[ -f "$j" ] && cp "$j" etc/udev/hwdb.d/ || true
done
done
udevadm hwdb --update --root=$(pwd)
mv etc/udev/hwdb.bin $out
'';
# Wrapper that makes eudev look like systemd from the paths stage-1.nix expects
eudevCompat = (pkgs.runCommand "eudev-as-systemd" {} ''
mkdir -p $out/bin $out/lib/udev $out/lib/systemd/network
# Symlink eudev binaries
for f in ${pkgs.eudev}/bin/*; do
ln -s "$f" $out/bin/
done
# Symlink eudev lib/udev (helpers, rules, etc.)
# Handle directories specially — create real dirs with individual symlinks
# so we can add stub files (e.g. rules.d needs extra systemd-specific rules)
for f in ${pkgs.eudev}/lib/udev/*; do
if [ -d "$f" ]; then
mkdir -p "$out/lib/udev/$(basename "$f")"
for g in "$f"/*; do
[ -e "$g" ] && ln -s "$g" "$out/lib/udev/$(basename "$f")/"
done
else
ln -s "$f" $out/lib/udev/
fi
done
# systemd-sysctl stub — sysctl is applied by early-sysinit after /etc population
cat > $out/lib/systemd/systemd-sysctl << 'STUB'
#!/bin/sh
exit 0
STUB
chmod +x $out/lib/systemd/systemd-sysctl
# systemd-run stub — udev rules reference it by absolute path
cat > $out/bin/systemd-run << 'STUB'
#!/bin/sh
# Stub: run the command directly instead of in a transient service
while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do shift; done
[ "$1" = "--" ] && shift
exec "$@"
STUB
chmod +x $out/bin/systemd-run
# systemd-cat stub — strips journal-specific flags, execs the command
cat > $out/bin/systemd-cat << 'STUB'
#!/bin/sh
while [ "$#" -gt 0 ]; do
case "$1" in
-t|--identifier|-p|--priority) shift; shift ;;
--level-prefix*) shift ;;
--) shift; break ;;
-*) shift ;;
*) break ;;
esac
done
exec "$@"
STUB
chmod +x $out/bin/systemd-cat
# stage-1.nix expects these specific rules files — create empty stubs for any
# that eudev doesn't ship (systemd-specific names)
mkdir -p $out/lib/udev/rules.d
for rule in 60-cdrom_id.rules 60-persistent-storage.rules 75-net-description.rules \
80-drivers.rules 80-net-setup-link.rules; do
[ -e $out/lib/udev/rules.d/$rule ] || touch $out/lib/udev/rules.d/$rule
done
# stage-1.nix copies *.link from here — provide a default so the glob doesn't fail
cat > $out/lib/systemd/network/99-default.link << 'LINK'
[Match]
OriginalName=*
[Link]
NamePolicy=keep kernel database onboard slot path
MACAddressPolicy=persistent
LINK
# tmpfiles.d configs — stock tmpfiles.nix symlinks to
# ''${systemd.package}/example/tmpfiles.d/*.conf.
# Provide them from our standalone systemd-tmpfiles build.
mkdir -p $out/example
ln -s ${tmpfilesStandalone}/example/tmpfiles.d $out/example/tmpfiles.d
'') // {
# stage-1.nix accesses lib.getLib udev.kmod for libkmod
kmod = pkgs.kmod;
};
in {
config = {
# Point NixOS modules at our eudev wrapper
systemd.package = lib.mkDefault eudevCompat;
# Standalone systemd tools built from upstream source
environment.systemPackages = [ tmpfilesStandalone sysusersStandalone ];
# Stock modprobe.nix points at ${systemd.package}/lib/modprobe.d/systemd.conf
# which eudev doesn't ship — override with empty file to prevent dangling symlink.
environment.etc."modprobe.d/systemd.conf".text = lib.mkForce "";
# stage-1.nix copies binaries from eudev-as-systemd, but our wrapper uses
# symlinks into ${pkgs.eudev}. cp -pdv preserves those symlinks, and nuke-refs
# only processes regular files — so the eudev store path leaks into the initrd.
# Fix: dereference all absolute symlinks, then replace udevd.
boot.initrd.extraUtilsCommands = ''
for f in $out/bin/* $out/lib/udev/*; do
if [ -L "$f" ]; then
target=$(readlink "$f")
case "$target" in
/nix/store/*) real=$(readlink -f "$f"); rm "$f"; cp "$real" "$f" ;;
esac
fi
done
rm -f $out/bin/systemd-udevd
cp ${pkgs.eudev}/bin/udevd $out/bin/systemd-udevd
'';
# Test that eudev's daemon works in the patched initrd
boot.initrd.extraUtilsCommandsTest = ''
$out/bin/systemd-udevd --version
'';
# hwdb.bin — compiled from systemd hwdb.d data files using eudev's udevadm
environment.etc."udev/hwdb.bin".source = lib.mkForce hwdbBin;
};
}

View file

@ -0,0 +1,58 @@
# generate-config.nix — packages minix-generate-config from stock nixos-generate-config.pl
# with MiNix-specific substitutions. Adds to system packages.
{ config, pkgs, lib, nixpkgsSrc, ... }:
let
detectVirt = pkgs.writeShellScript "detect-virt" ''
# Virtualization detection — wraps virt-what, translates to systemd-detect-virt names.
out=$(${pkgs.virt-what}/bin/virt-what 2>/dev/null | head -1)
case "$out" in
virtualbox) echo "oracle" ;;
hyperv) echo "microsoft" ;;
"") echo "none" ;;
*) echo "$out" ;;
esac
'';
minixGenerateConfig = pkgs.replaceVarsWith {
name = "minix-generate-config";
src = "${nixpkgsSrc}/nixos/modules/installer/tools/nixos-generate-config.pl";
dir = "bin";
isExecutable = true;
replacements = {
perl = "${pkgs.perl.withPackages (p: [
p.FileSlurp
p.ConfigIniFiles
])}/bin/perl";
hostPlatformSystem = pkgs.stdenv.hostPlatform.system;
detectvirt = "${detectVirt}";
btrfs = "${pkgs.btrfs-progs}/bin/btrfs";
configuration = ''
{ config, lib, pkgs, ... }:
{
imports =
[ ./hardware-configuration.nix
];
$bootLoaderConfig
system.stateVersion = "25.11";
}'';
desktopConfiguration = "";
flake = ''
{
description = "MiNix system configuration";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
outputs = inputs@{ self, nixpkgs, ... }: {
# Configure with minixSystem
};
}'';
xserverEnabled = "0";
};
};
in {
config.environment.systemPackages = [ minixGenerateConfig ];
}

150
system/modules/hm-dinit.nix Normal file
View file

@ -0,0 +1,150 @@
# hm-dinit.nix — Home Manager module for dinit user service integration.
#
# Consumed via flake output: minix.homeModules.default
# User flakes import it as: modules = [ minix.homeModules.default ./home.nix ];
#
# Two responsibilities:
# A. Translate systemd.user.services → dinit user service files (~/.config/dinit.d/)
# B. Replace HM's reloadSystemd activation with dinitctl --user
{ config, lib, pkgs, ... }:
let
inherit (lib)
mkDefault mkForce mkMerge
mapAttrs mapAttrs' filterAttrs attrValues
optionalString concatStringsSep concatMapStringsSep
hasPrefix removePrefix hasSuffix removeSuffix
escapeShellArg filter isList toList;
# Common translator functions (shared with systemd-compat.nix)
tr = import ../lib/translator.nix { inherit lib pkgs; };
inherit (tr) getScalar scGet scGetList getDinitType getDinitRestart
stripExecPrefix mkEnvFile getEnvFilePaths buildDinitDeps;
# --- Translate user services ---
userServices = filterAttrs (name: svc:
svc.enable
&& (svc.serviceConfig ? ExecStart)
&& !(hasPrefix "systemd-" name)
) (config.systemd.user.services or {});
translateService = name: svc:
let
deps = buildDinitDeps name svc;
envFile = mkEnvFile name svc;
envFiles = getEnvFilePaths svc;
execStart = stripExecPrefix (getScalar svc.serviceConfig.ExecStart);
rawWorkDir = scGet svc "WorkingDirectory" "";
workDir = let s = if hasPrefix "-" rawWorkDir then removePrefix "-" rawWorkDir else rawWorkDir;
in if s == "" || s == "~" then null else s;
pidFile = scGet svc "PIDFile" "";
execStartPre = scGetList svc "ExecStartPre";
execStartPost = scGetList svc "ExecStartPost";
execStartList = map (cmd: stripExecPrefix (toString cmd))
(filter (x: x != "" && x != null) (toList svc.serviceConfig.ExecStart));
needsScript = execStartPre != [] || execStartPost != []
|| builtins.length execStartList > 1;
fmtCmd = cmd:
let c = toString cmd; opt = hasPrefix "-" c;
in "${stripExecPrefix c}${optionalString opt " || true"}";
wrapperScript = "#!/bin/sh\nset -e\n"
+ optionalString (execStartPre != []) (concatMapStringsSep "\n" fmtCmd execStartPre + "\n")
+ (if getDinitType svc == "scripted" then
concatMapStringsSep "\n" (cmd: cmd) execStartList
+ optionalString (execStartPost != []) ("\n" + concatMapStringsSep "\n" fmtCmd execStartPost)
else
optionalString (execStartPost != [])
"( set +e; sleep 1; ${concatMapStringsSep "; " fmtCmd execStartPost} ) &\n"
+ "exec ${execStart}")
+ "\n";
in {
type = mkDefault (getDinitType svc);
restart = getDinitRestart svc;
dependsOn = deps.dependsOn;
waitsFor = deps.waitsFor;
after = deps.after;
}
// lib.optionalAttrs (envFile != null) { envFile = mkDefault envFile; }
// lib.optionalAttrs (envFiles != []) { extraEnvFiles = mkDefault envFiles; }
// lib.optionalAttrs (workDir != null) { workingDir = mkDefault workDir; }
// lib.optionalAttrs (pidFile != "") { pidFile = mkDefault pidFile; }
// (if needsScript then { startScript = mkDefault wrapperScript; }
else { command = mkDefault execStart; });
dinitUserServices = mapAttrs translateService userServices;
# --- Generate dinit service file content ---
mkServiceFile = name: svc:
let
resolvedCommand =
if svc ? startScript && svc.startScript != null then
"${pkgs.writeScript "${name}-start" svc.startScript}"
else svc.command or null;
resolvedStopCommand =
if svc ? stopScript && svc.stopScript != null then
"${pkgs.writeScript "${name}-stop" svc.stopScript}"
else svc.stopCommand or null;
lines = filter (s: s != "") [
"type = ${svc.type}"
(optionalString (resolvedCommand != null) "command = ${resolvedCommand}")
(optionalString (resolvedStopCommand != null) "stop-command = ${resolvedStopCommand}")
(optionalString (svc.workingDir or null != null) "working-dir = ${svc.workingDir}")
(optionalString (svc.pidFile or null != null) "pid-file = ${svc.pidFile}")
"restart = ${svc.restart}"
(optionalString (svc.envFile or null != null) "env-file = ${svc.envFile}")
(concatMapStringsSep "\n" (f: "env-file += ${f}") (svc.extraEnvFiles or []))
(concatMapStringsSep "\n" (d: "depends-on: ${d}") (svc.dependsOn or []))
(concatMapStringsSep "\n" (d: "waits-for: ${d}") (svc.waitsFor or []))
(concatMapStringsSep "\n" (d: "after: ${d}") (svc.after or []))
(svc.extraConfig or "")
];
in concatStringsSep "\n" lines + "\n";
serviceFiles = mapAttrs' (name: svc: {
name = "dinit.d/${lib.removeSuffix "@" name}";
value = { text = mkServiceFile name svc; };
}) dinitUserServices;
in {
config = mkMerge [
# Install translated user service files into ~/.config/dinit.d/
(lib.mkIf (dinitUserServices != {}) {
xdg.configFile = serviceFiles;
})
# Replace HM's systemd reload activation with dinit reload
{
home.activation.reloadSystemd = mkForce "";
home.activation.reloadDinit = lib.hm.dag.entryAfter [ "linkGeneration" ] ''
if command -v dinitctl >/dev/null 2>&1 && dinitctl --user list >/dev/null 2>&1; then
_dinit_new="$newGenPath/home-files/.config/dinit.d"
_dinit_old="''${oldGenPath:+$oldGenPath/home-files/.config/dinit.d}"
if [ -d "$_dinit_new" ]; then
for _svc_file in "$_dinit_new"/*; do
[ -f "$_svc_file" ] || continue
_svc_name="$(basename "$_svc_file")"
_changed=0
if [ -z "$_dinit_old" ] || [ ! -f "$_dinit_old/$_svc_name" ]; then
_changed=1
elif ! diff -q "$_svc_file" "$_dinit_old/$_svc_name" >/dev/null 2>&1; then
_changed=1
fi
if [ "$_changed" = "1" ]; then
dinitctl --user stop "$_svc_name" 2>/dev/null || true
dinitctl --user start "$_svc_name" 2>/dev/null || true
fi
done
fi
fi
'';
}
];
}

151
system/modules/rebuild.nix Normal file
View file

@ -0,0 +1,151 @@
# minix-rebuild — build and/or switch the system profile.
# minix-home — scaffold per-user Home Manager config.
# Installs the machine flake to /etc/minix/ as mutable files (first-boot-only).
{ config, pkgs, lib, ... }:
let
cfg = config.minix;
in {
options.minix.flakeRef = lib.mkOption {
type = lib.types.str;
default = "/etc/minix";
description = "Default flake reference for minix-rebuild build.";
};
options.minix.machineFlake = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to the machine flake directory (e.g. ./machines/qemu-vm).
Its contents are installed to /etc/minix/ as mutable files
written on first boot, preserved across reboots so user edits persist.
'';
};
config = lib.mkMerge [
{
environment.systemPackages = [
# minix-rebuild — system profile builder (root)
(pkgs.writeShellScriptBin "minix-rebuild" ''
set -e
usage() {
echo "Usage: minix-rebuild <command> [args]"
echo ""
echo "Commands:"
echo " build [FLAKE_REF] Build system profile and switch to it"
echo " switch PATH Switch to a pre-built system profile"
echo ""
echo "If FLAKE_REF is omitted, uses ${cfg.flakeRef}"
echo "After switching, reboot to activate the new profile."
exit 1
}
do_switch() {
RESULT="$(readlink -f "$1")"
if [ ! -e "$RESULT" ]; then
echo "Error: $1 does not exist"
exit 1
fi
echo "Setting system profile to: $RESULT"
nix-env --profile /nix/var/nix/profiles/system --set "$RESULT"
echo "Done. Reboot to activate the new system profile."
}
[ $# -lt 1 ] && usage
case "$1" in
build)
FLAKE="''${2:-${cfg.flakeRef}}"
OUTLINK="/tmp/minix-rebuild-$$"
echo "Building system profile from: $FLAKE#packages.${pkgs.stdenv.hostPlatform.system}.system"
nix build "''${FLAKE}#packages.${pkgs.stdenv.hostPlatform.system}.system" -o "$OUTLINK"
do_switch "$OUTLINK"
rm -f "$OUTLINK"
;;
switch)
[ -z "$2" ] && usage
do_switch "$2"
;;
*)
usage
;;
esac
'')
# minix-home — scaffold user Home Manager config
(pkgs.writeShellScriptBin "minix-home" ''
set -e
HM_DIR="''${HOME}/.config/home-manager"
case "''${1:-}" in
init)
if [ -f "$HM_DIR/flake.nix" ]; then
echo "''${HM_DIR}/flake.nix already exists. Remove it first to re-init."
exit 1
fi
USERNAME="$(id -un)"
HOMEDIR="$HOME"
ARCH="${pkgs.stdenv.hostPlatform.system}"
mkdir -p "$HM_DIR"
cat > "$HM_DIR/flake.nix" << 'FLAKE'
{
inputs = {
minix.url = "git+https://git.axiomania.org/lebowski/MiNix.git";
nixpkgs.follows = "minix/nixpkgs-stable";
home-manager.follows = "minix/home-manager";
};
outputs = { minix, nixpkgs, home-manager, ... }: {
homeConfigurations."MINIX_USERNAME" = home-manager.lib.homeManagerConfiguration {
pkgs = nixpkgs.legacyPackages.MINIX_ARCH;
modules = [ minix.homeModules.default ./home.nix ];
};
};
}
FLAKE
${pkgs.gnused}/bin/sed -i "s|MINIX_USERNAME|$USERNAME|;s|MINIX_ARCH|$ARCH|" "$HM_DIR/flake.nix"
cat > "$HM_DIR/home.nix" << HOMENIX
{ pkgs, ... }: {
home.username = "$USERNAME";
home.homeDirectory = "$HOMEDIR";
home.stateVersion = "25.11";
programs.home-manager.enable = true;
}
HOMENIX
echo "Created $HM_DIR/flake.nix and $HM_DIR/home.nix"
echo "Edit home.nix, then run: home-manager switch --flake $HM_DIR"
;;
*)
echo "Usage: minix-home init"
echo ""
echo "Scaffolds ~/.config/home-manager/ with a starter flake + home.nix."
exit 1
;;
esac
'')
];
}
# Install machine flake to /etc/minix/ as mutable files.
# early-etc copies files with .mode sidecar on first boot only —
# subsequent boots preserve user edits (configuration.nix, home.nix, etc.).
(lib.mkIf (cfg.machineFlake != null) {
environment.etc = let
flakeDir = cfg.machineFlake;
# Install a file from the machine flake dir as a mutable /etc/minix/ entry.
mkMutable = etcPath: srcPath: {
"minix/${etcPath}" = {
source = srcPath;
mode = "0644";
};
};
in lib.mkMerge [
(mkMutable "flake.nix" (flakeDir + "/flake.nix"))
(lib.mkIf (builtins.pathExists (flakeDir + "/flake.lock"))
(mkMutable "flake.lock" (flakeDir + "/flake.lock")))
(mkMutable "configuration.nix" (flakeDir + "/configuration.nix"))
(lib.mkIf (builtins.pathExists (flakeDir + "/hardware-configuration.nix"))
(mkMutable "hardware-configuration.nix" (flakeDir + "/hardware-configuration.nix")))
];
})
];
}

View file

@ -0,0 +1,21 @@
# Override stock config/system-path.nix defaults with busybox-based minimal set.
# Stock module declares all options; we just override corePackages and defaultPackages.
{ pkgs, lib, ... }:
{
# Replace NixOS corePackages (coreutils, bash, curl, etc.) with busybox + essentials.
environment.corePackages = lib.mkForce (with pkgs; [
busybox
util-linux
iproute2
procps
kmod
getent
]);
# Drop stock defaultPackages (perl, rsync, strace).
environment.defaultPackages = lib.mkForce [];
# Disable documentation to avoid pulling in heavy deps.
documentation.enable = lib.mkDefault false;
}

View file

@ -0,0 +1,446 @@
# systemd-compat.nix — Stock systemd option imports + systemd→dinit translator.
#
# optionsOnly imports stock NixOS module options without config sections.
# Translates systemd.services → dinit.services, systemd.timers → snooze,
# systemd.sockets → dinit socket-listen (Unix sockets only).
{ config, lib, pkgs, utils, modulesPath, ... } @ args:
let
optionsOnly = path:
let mod = (import path) args;
in { options = mod.options or {}; imports = mod.imports or []; };
inherit (lib)
mkIf mkMerge mkDefault
mapAttrs filterAttrs attrValues
optionalString concatStringsSep concatMapStringsSep
toList hasPrefix removePrefix hasSuffix removeSuffix
escapeShellArg filter isList;
# Common translator functions (shared with hm-dinit.nix)
tr = import ../lib/translator.nix { inherit lib pkgs; };
inherit (tr) getScalar scGet scGetList scHas scBool
getDinitType getDinitRestart stripExecPrefix mkEnvFile getEnvFilePaths buildDinitDeps;
cfg = config.systemd.services;
isTemplate = name: hasSuffix "@" name;
# All translatable services: enabled, has ExecStart, not systemd-internal
translatedServices = filterAttrs (name: svc:
svc.enable
&& (svc.serviceConfig ? ExecStart)
&& !(hasPrefix "systemd-" name)
) cfg;
# Split for boot waitsFor (templates are started via target wants, not boot)
regularServices = filterAttrs (n: _: !(isTemplate n)) translatedServices;
# Template instance names from target wants/requires (e.g. "getty@tty1")
templateInstanceNames = let
refs = lib.concatLists (lib.mapAttrsToList (_: t:
map (removeSuffix ".service") ((t.wants or []) ++ (t.requires or []))
) config.systemd.targets);
in filter (n: builtins.match "(.+)@(.+)" n != null) refs;
# Directory provisioning (StateDirectory, RuntimeDirectory, etc.)
buildDirProvisioning = svc:
let
user = scGet svc "User" "";
group = scGet svc "Group" "";
own = if user != "" then (if group != "" then "${user}:${group}" else user) else "";
mkLines = { prefix, key, modeKey }:
let
raw = if scHas svc key then svc.serviceConfig.${key} else null;
dirs = if raw == null then [] else if isList raw then map toString raw else [ (toString raw) ];
mode = scGet svc modeKey "0755";
in concatMapStringsSep "\n" (d:
let path = escapeShellArg "${prefix}/${d}"; in
"mkdir -p ${path}"
+ optionalString (own != "") " && chown ${own} ${path}"
+ " && chmod ${mode} ${path}"
) dirs;
in concatStringsSep "\n" (filter (s: s != "") (map mkLines [
{ prefix = "/var/lib"; key = "StateDirectory"; modeKey = "StateDirectoryMode"; }
{ prefix = "/run"; key = "RuntimeDirectory"; modeKey = "RuntimeDirectoryMode"; }
{ prefix = "/var/cache"; key = "CacheDirectory"; modeKey = "CacheDirectoryMode"; }
{ prefix = "/var/log"; key = "LogsDirectory"; modeKey = "LogsDirectoryMode"; }
{ prefix = "/etc"; key = "ConfigurationDirectory"; modeKey = "ConfigurationDirectoryMode"; }
]));
getDinitRlimits = svc:
let
add = sdKey: dinitKey:
let v = scGet svc sdKey "";
in if v != "" && v != "infinity" then { ${dinitKey} = toString v; } else {};
in (add "LimitNOFILE" "nofile") // (add "LimitCORE" "core")
// (add "LimitDATA" "data") // (add "LimitAS" "addrspace");
templateSubst = s: builtins.replaceStrings ["%I" "%i"] ["$1" "$1"] s;
# bwrap sandboxing is incompatible with AmbientCapabilities — bwrap's
# --cap-drop/--cap-add can't restore caps already lost via setpriv user switch.
# Services with ambient caps use dinit's native capabilities + run-as instead.
hasSandboxing = svc:
let sc = svc.serviceConfig; in
!(sc ? AmbientCapabilities) &&
((sc ? ProtectSystem) || (sc ? ProtectHome) || (sc ? PrivateTmp)
|| (sc ? PrivateDevices) || (sc ? ProtectKernelTunables)
|| (sc ? ProtectControlGroups) || (sc ? ProtectHostname)
|| (sc ? NoNewPrivileges) || (sc ? PrivateNetwork)
|| (sc ? ReadOnlyPaths) || (sc ? ReadWritePaths)
|| (sc ? CapabilityBoundingSet));
collectWritableDirs = svc:
let
getDirs = key: prefix:
let raw = if scHas svc key then svc.serviceConfig.${key} else null;
in if raw == null then []
else if isList raw then map (d: "${prefix}/${toString d}") raw
else [ "${prefix}/${toString raw}" ];
in (getDirs "StateDirectory" "/var/lib") ++ (getDirs "RuntimeDirectory" "/run")
++ (getDirs "CacheDirectory" "/var/cache") ++ (getDirs "LogsDirectory" "/var/log")
++ (getDirs "ConfigurationDirectory" "/etc");
buildBwrapFlags = svc:
let
protectSystem = scGet svc "ProtectSystem" "";
protectHome = scGet svc "ProtectHome" "";
flags = filter (s: s != "") [
(if protectSystem == "strict" then "--ro-bind / / --proc /proc --dev-bind /dev /dev --bind /run /run --bind /var /var --bind /tmp /tmp"
else if protectSystem == "full" then "--ro-bind /usr /usr --ro-bind /boot /boot --ro-bind /etc /etc"
else if protectSystem == true || protectSystem == "true"
then "--ro-bind /usr /usr --ro-bind /boot /boot"
else "")
(if protectSystem == "strict" then
concatStringsSep " " (map (p: "--bind ${p} ${p}") (collectWritableDirs svc))
else "")
(concatStringsSep " " (map (p: "--bind ${toString p} ${toString p}") (scGetList svc "ReadWritePaths")))
(concatStringsSep " " (map (p: "--ro-bind ${toString p} ${toString p}") (scGetList svc "ReadOnlyPaths")))
(if protectHome == true || protectHome == "true" || protectHome == "yes"
then "--tmpfs /home --tmpfs /root --tmpfs /run/user"
else if protectHome == "read-only" then "--ro-bind /home /home --ro-bind /root /root"
else if protectHome == "tmpfs" then "--tmpfs /home --tmpfs /root --tmpfs /run/user"
else "")
(if scBool svc "PrivateTmp" then "--tmpfs /tmp --tmpfs /var/tmp" else "")
(if scBool svc "PrivateDevices" then "--dev /dev" else "")
(if scBool svc "PrivateNetwork" then "--unshare-net" else "")
(if scBool svc "ProtectKernelTunables" then "--ro-bind /proc/sys /proc/sys" else "")
(if scBool svc "ProtectControlGroups" then "--ro-bind /sys/fs/cgroup /sys/fs/cgroup" else "")
(if scBool svc "ProtectHostname" then "--unshare-uts" else "")
(if scBool svc "NoNewPrivileges" then "--new-session" else "")
(let caps = scGetList svc "CapabilityBoundingSet";
in if caps != [] then
"--cap-drop ALL " + concatStringsSep " " (map (c: "--cap-add ${toString c}") caps)
else "")
];
in concatStringsSep " \\\n " flags;
# Wrapper script — needed for ExecStartPre, dir provisioning, UMask, bwrap, multi-ExecStart, ExecStartPost
needsWrapper = svc:
let execStartList = filter (x: x != "" && x != null) (toList svc.serviceConfig.ExecStart);
in scGetList svc "ExecStartPre" != [] || scGetList svc "ExecStartPost" != []
|| buildDirProvisioning svc != "" || scGet svc "UMask" "" != ""
|| hasSandboxing svc || builtins.length execStartList > 1;
mkSetpriv = svc:
let user = scGet svc "User" ""; group = scGet svc "Group" "";
hasCaps = scGetList svc "AmbientCapabilities" != [];
# Skip setpriv for services with AmbientCapabilities — setpriv drops
# ambient caps on uid change. Let these run as root with dinit caps.
in if user == "" || hasCaps then ""
else "${pkgs.util-linux}/bin/setpriv --reuid=${user}${optionalString (group != "") " --regid=${group}"} --init-groups --";
mkWrapperScript = name: svc:
let
dinitType = getDinitType svc;
dirProv = buildDirProvisioning svc;
umask = scGet svc "UMask" "";
useBwrap = hasSandboxing svc;
bwrapFlags = if useBwrap then buildBwrapFlags svc else "";
setpriv = mkSetpriv svc;
execStart = stripExecPrefix (getScalar svc.serviceConfig.ExecStart);
execStartPre = scGetList svc "ExecStartPre";
execStartPost = scGetList svc "ExecStartPost";
execStartList = map (cmd: stripExecPrefix (toString cmd))
(filter (x: x != "" && x != null) (toList svc.serviceConfig.ExecStart));
wrapCmd = cmd:
"${optionalString (setpriv != "") "${setpriv} "}${optionalString useBwrap
"${pkgs.bubblewrap}/bin/bwrap \\\n ${bwrapFlags} \\\n -- "}${cmd}";
fmtCmd = cmd:
let c = toString cmd; opt = hasPrefix "-" c;
in "${stripExecPrefix c}${optionalString opt " || true"}";
body = concatStringsSep "\n" (filter (s: s != "") [
dirProv
(optionalString (umask != "") "umask ${umask}")
(optionalString (execStartPre != []) (concatMapStringsSep "\n" fmtCmd execStartPre))
]);
in "#!/bin/sh\nset -e\n"
+ optionalString (body != "") (body + "\n")
+ (if dinitType == "scripted" then
concatMapStringsSep "\n" (cmd: wrapCmd cmd) execStartList
+ optionalString (execStartPost != []) ("\n" + concatMapStringsSep "\n" fmtCmd execStartPost)
else
optionalString (execStartPost != [])
"( set +e; sleep 1; ${concatMapStringsSep "; " fmtCmd execStartPost} ) &\n"
+ "exec ${wrapCmd execStart}")
+ "\n";
getStopAttrs = name: svc:
let
stops = scGetList svc "ExecStop";
posts = scGetList svc "ExecStopPost";
all = stops ++ posts;
isTempl = isTemplate name;
t = s: if isTempl then templateSubst s else s;
in if all == [] then {}
else if builtins.length all == 1 && posts == [] then
{ stopCommand = mkDefault (t (stripExecPrefix (toString (builtins.head all)))); }
else { stopScript = mkDefault (t ("#!/bin/sh\n"
+ concatMapStringsSep "\n" (cmd: stripExecPrefix (toString cmd)) all + "\n")); };
mkDinitService = name: svc:
let
deps = buildDinitDeps name svc;
envFile = mkEnvFile name svc;
envFiles = getEnvFilePaths svc;
wrapper = needsWrapper svc;
execStart = stripExecPrefix (getScalar svc.serviceConfig.ExecStart);
user = scGet svc "User" "";
rawWorkDir = scGet svc "WorkingDirectory" "";
workDir = let s = if hasPrefix "-" rawWorkDir then removePrefix "-" rawWorkDir else rawWorkDir;
in if s == "" || s == "~" then null else s;
isTempl = isTemplate name;
t = s: if isTempl then templateSubst s else s;
ambientCaps = scGetList svc "AmbientCapabilities";
dinitCaps = if ambientCaps == [] then null
else concatStringsSep "," (map (c: "^${c}") ambientCaps);
pidFile = scGet svc "PIDFile" "";
in {
type = mkDefault (getDinitType svc);
restart = getDinitRestart svc;
dependsMs = [ "early" ];
dependsOn = deps.dependsOn;
waitsFor = deps.waitsFor;
after = deps.after;
rlimits = mkDefault (getDinitRlimits svc);
options = mkDefault (lib.optional (scBool svc "NoNewPrivileges") "no-new-privs");
}
// lib.optionalAttrs (envFile != null) { envFile = mkDefault envFile; }
// lib.optionalAttrs (envFiles != []) { extraEnvFiles = mkDefault envFiles; }
// lib.optionalAttrs (workDir != null) { workingDir = mkDefault (t workDir); }
// lib.optionalAttrs (dinitCaps != null) { capabilities = mkDefault dinitCaps; }
// lib.optionalAttrs (pidFile != "") { pidFile = mkDefault pidFile; }
// getStopAttrs name svc
// (if wrapper then { startScript = mkDefault (t (mkWrapperScript name svc)); }
else { command = mkDefault (t execStart); runAs = mkDefault (if user != "" then user else null); })
// lib.optionalAttrs (builtins.elem name dormantServiceNames || isTemplate name) { autoStart = mkDefault false; };
anyUseBwrap = lib.any (svc: hasSandboxing svc) (attrValues regularServices);
# Dormant: no wantedBy + timer-triggered → no auto-start
dormantServiceNames = lib.unique (
builtins.attrNames (filterAttrs (_: svc: svc.wantedBy == []) regularServices)
++ timerTriggeredServices
);
# Getty template instances → boot.waitsFor (login services start after system milestone).
# Regular services are covered by dinit-init.nix's allAutoStart via autoStart flag.
bootWaitsFor = templateInstanceNames;
# --- Timer → snooze translation ---
# Each timer becomes a dinit service: snooze sleeps, execs, dinit restarts.
parseTimespanSec = span:
let
m = builtins.match "([0-9]+)(s|sec|m|min|h|hr|d|day)?" (lib.toLower (lib.trim (toString span)));
n = if m != null then lib.toInt (builtins.elemAt m 0) else 0;
unit = if m != null then builtins.elemAt m 1 else null;
in if unit == "m" || unit == "min" then n * 60
else if unit == "h" || unit == "hr" then n * 3600
else if unit == "d" || unit == "day" then n * 86400
else n;
dowMap = { mon = "1"; tue = "2"; wed = "3"; thu = "4"; fri = "5"; sat = "6"; sun = "0"; };
translateDow = d:
let dl = lib.toLower d;
parts = builtins.match "([a-z]+)\\.\\.([a-z]+)" dl;
in if dowMap ? ${dl} then dowMap.${dl}
else if parts != null then "${dowMap.${builtins.elemAt parts 0}}-${dowMap.${builtins.elemAt parts 1}}"
else d;
# OnCalendar spec → snooze flags. Snooze defaults: -d* -m* -w* -H0 -M0 -S0.
calendarToSnoozeFlags = spec:
let
s = lib.toLower (lib.trim spec);
shorthands = {
minutely = "-H'*' -M'*'"; hourly = "-H'*'"; daily = "";
weekly = "-w0"; monthly = "-d1"; yearly = "-m1 -d1"; annually = "-m1 -d1";
quarterly = "-m1,4,7,10 -d1"; semiannually = "-m1,7 -d1";
};
parseTime = t:
let p = lib.splitString ":" t;
in { hour = builtins.elemAt p 0;
minute = if builtins.length p >= 2 then builtins.elemAt p 1 else "0";
second = if builtins.length p >= 3 then builtins.elemAt p 2 else null; };
parseDate = d:
let p = lib.splitString "-" d;
in if builtins.length p == 3 then { month = builtins.elemAt p 1; dom = builtins.elemAt p 2; }
else if builtins.length p == 2 then { month = builtins.elemAt p 0; dom = builtins.elemAt p 1; }
else { month = null; dom = null; };
f = flag: v: if v == null || v == "*" then "" else "${flag}${v}";
fs = flag: v: if v == null then "" else if v == "*" then "${flag}'*'" else "${flag}${v}";
join = args: concatStringsSep " " (filter (x: x != "") args);
tokens = lib.splitString " " (lib.trim spec);
nTokens = builtins.length tokens;
timeFlags = t: [ (fs "-H" t.hour) (fs "-M" t.minute) (fs "-S" t.second) ];
in
if shorthands ? ${s} then shorthands.${s}
else if nTokens == 1 then join (timeFlags (parseTime (builtins.elemAt tokens 0)))
else if nTokens == 2 then
let first = builtins.elemAt tokens 0; second = builtins.elemAt tokens 1;
in if builtins.match ".*-.*" first != null then
let d = parseDate first; t = parseTime second;
in join ([ (f "-m" d.month) (f "-d" d.dom) ] ++ timeFlags t)
else join ([ "-w${translateDow first}" ] ++ timeFlags (parseTime second))
else if nTokens == 3 then
let d = parseDate (builtins.elemAt tokens 1); t = parseTime (builtins.elemAt tokens 2);
in join ([ "-w${translateDow (builtins.elemAt tokens 0)}" (f "-m" d.month) (f "-d" d.dom) ] ++ timeFlags t)
else builtins.throw "systemd-compat: unsupported calendar spec '${spec}'";
timerServicesFromStartAt = filterAttrs (_: svc: svc.enable && svc.startAt != []) cfg;
autoTimers = mapAttrs (_: svc: { wantedBy = [ "timers.target" ]; timerConfig.OnCalendar = svc.startAt; }) timerServicesFromStartAt;
allTimers = filterAttrs (_: t: t.enable or true) (autoTimers // config.systemd.timers);
timerServiceName = name: timer:
let u = timer.timerConfig.Unit or null;
in if u != null then removeSuffix ".service" (toString u) else name;
mkTimerService = name: timer:
let
tc = timer.timerConfig;
chk = key: if (tc.${key} or null) != null
then builtins.throw "systemd-compat: ${key} not supported (timer '${name}'). Use OnCalendar."
else null;
_m = builtins.seq (chk "OnBootSec") (builtins.seq (chk "OnStartupSec")
(builtins.seq (chk "OnUnitActiveSec") (chk "OnUnitInactiveSec")));
svcName = timerServiceName name timer;
onCal = tc.OnCalendar or null;
specs = if onCal == null then []
else filter (s: s != "") (if isList onCal then map toString onCal else [ (toString onCal) ]);
persistent = tc.Persistent or false;
flags = concatStringsSep " " (filter (x: x != "") [
(if specs != [] then calendarToSnoozeFlags (builtins.head specs) else "")
(let v = tc.RandomizedDelaySec or null; in if v != null then "-R${toString (parseTimespanSec v)}" else "")
(let v = tc.AccuracySec or null; in if v != null then "-s${toString (parseTimespanSec v)}" else "")
(if persistent then "-t /var/lib/snooze/${name}.timefile" else "")
]);
dinitctl = "${pkgs.dinit}/bin/dinitctl";
cmd = if persistent then
"${pkgs.snooze}/bin/snooze ${flags} sh -c '${dinitctl} start ${svcName} || true; touch /var/lib/snooze/${name}.timefile'"
else "${pkgs.snooze}/bin/snooze ${flags} ${dinitctl} start ${svcName}";
in builtins.seq _m { type = "process"; command = mkDefault cmd; restart = "yes"; autoStart = true; dependsMs = [ "early" ]; };
timerTriggeredServices = lib.unique (lib.mapAttrsToList (name: timer: timerServiceName name timer) allTimers);
hasTimers = allTimers != {};
timerDinitServices = mapAttrs mkTimerService allTimers;
# --- Socket activation (Unix domain sockets only) ---
eligibleSockets = filterAttrs (_: sock:
sock.enable && (sock.socketConfig ? ListenStream || sock.listenStreams != [])
) config.systemd.sockets;
mkSocketAttrs = name: sock:
let
sc = sock.socketConfig;
streams = sock.listenStreams ++ (if sc ? ListenStream then toList sc.ListenStream else []);
unixStreams = filter (s: hasPrefix "/" (toString s)) streams;
tcpStreams = filter (s: !(hasPrefix "/" (toString s))) streams;
_warn = if tcpStreams != [] then
builtins.trace "systemd-compat: TCP/UDP socket '${name}' skipped (dinit only supports Unix sockets)" null
else null;
socketPath = if unixStreams != [] then toString (builtins.head unixStreams) else null;
svcName = if sc ? Service then removeSuffix ".service" (toString sc.Service) else name;
in builtins.seq _warn (if socketPath == null then {} else {
${svcName} = { socketListen = mkDefault socketPath; }
// lib.optionalAttrs (sc ? SocketMode) { socketPermissions = mkDefault (toString sc.SocketMode); }
// lib.optionalAttrs (sc ? SocketUser) { socketUid = mkDefault (toString sc.SocketUser); }
// lib.optionalAttrs (sc ? SocketGroup) { socketGid = mkDefault (toString sc.SocketGroup); };
});
socketDinitAttrs = lib.foldl' (acc: attrs: acc // attrs) {}
(lib.mapAttrsToList mkSocketAttrs eligibleSockets);
in {
imports =
map (p: optionsOnly (modulesPath + p)) [
"/system/boot/systemd.nix"
"/system/boot/systemd/user.nix"
"/system/boot/systemd/sysusers.nix"
"/system/boot/systemd/coredump.nix"
"/system/boot/systemd/oomd.nix"
"/system/boot/systemd/nspawn.nix"
"/system/boot/systemd/sysupdate.nix"
"/system/boot/systemd/tpm2.nix"
"/system/boot/systemd/repart.nix"
"/system/boot/systemd/shutdown.nix"
"/system/boot/systemd/journald.nix"
"/system/boot/systemd/journald-gateway.nix"
"/system/boot/systemd/journald-remote.nix"
"/system/boot/systemd/journald-upload.nix"
"/system/boot/systemd/logind.nix"
"/system/boot/systemd/homed.nix"
"/system/boot/systemd/userdbd.nix"
"/system/boot/systemd/dm-verity.nix"
"/system/boot/systemd/fido2.nix"
"/system/boot/networkd.nix"
"/system/boot/systemd/initrd.nix"
"/system/boot/stage-2.nix"
"/system/activation/top-level.nix"
"/system/activation/bootspec.nix"
"/system/activation/activatable-system.nix"
"/system/activation/switchable-system.nix"
"/system/activation/nixos-init.nix"
"/services/system/nix-daemon.nix"
]
++ [
# specialisation.nix needs extendModules which evalModulesMinimal doesn't provide
({ lib, ... }: {
options.isSpecialisation = lib.mkOption { type = lib.types.bool; internal = true; default = false; };
options.specialisation = lib.mkOption {
type = lib.types.attrsOf lib.types.anything; default = {};
description = "Additional configurations to build (no-op in MiNix).";
};
})
];
config = mkMerge [
{ system.build.toplevel = mkDefault config.system.build.systemProfile; }
{ system.activationScripts.postBootCommands =
mkIf (config.boot.postBootCommands != "") config.boot.postBootCommands; }
# systemd.services → dinit.services (regular + templates)
{ dinit.services = mapAttrs mkDinitService translatedServices; }
# Getty template instances → boot.waitsFor (gettys start after system milestone)
{ dinit.services.boot.waitsFor = mkDefault bootWaitsFor; }
# Timers → snooze dinit services
(mkIf hasTimers {
dinit.services = lib.mapAttrs' (name: svc: {
name = "timer-${name}";
value = svc;
}) timerDinitServices;
})
# Socket activation → dinit socket-listen
(mkIf (socketDinitAttrs != {}) { dinit.services = socketDinitAttrs; })
(mkIf anyUseBwrap { environment.systemPackages = [ pkgs.bubblewrap ]; })
{ security.pam.services.login.updateWtmp = lib.mkForce false; }
];
}

View file

@ -0,0 +1,67 @@
# Assembles the system profile derivation.
# Follows the stock NixOS top-level.nix pattern: $out/init with @systemConfig@ substitution.
{ config, pkgs, lib, ... }:
let
inherit (lib) mkOption types optionalString;
etcDir = config.system.build.etc;
kernel = config.boot.kernelPackages.kernel;
kernelFile = config.system.boot.loader.kernelFile;
activateScript = pkgs.writeShellScript "activate"
config.system.activationScripts.script;
in {
options.system.build.systemProfile = mkOption {
internal = true;
description = "The assembled system profile derivation.";
};
config.system.build.systemProfile = pkgs.runCommand "minix-system-profile" {} ''
mkdir -p $out
# init wrapper — copy as executable
cp ${config.system.build.bootStage2} $out/init
chmod +x $out/init
# activate — substitute @out@ with final profile path
cp ${activateScript} $out/activate
substituteInPlace $out/activate --subst-var-by out $out
chmod +x $out/activate
ln -s ${etcDir}/etc $out/etc
if [ -d ${etcDir}/etc/dinit.d ]; then
ln -s ${etcDir}/etc/dinit.d $out/dinit.d
else
mkdir -p $out/dinit.d
fi
ln -s ${config.system.path} $out/sw
mkdir -p $out/kernel
ln -s ${kernel}/${kernelFile} $out/kernel/vmlinuz
ln -s ${config.system.modulesTree} $out/kernel/modules
# Stock NixOS layout — packages reference /run/booted-system/kernel-modules
ln -s ${config.system.modulesTree} $out/kernel-modules
${optionalString config.boot.initrd.enable ''
ln -s ${config.system.build.initialRamdisk}/initrd $out/initrd
''}
echo -n "${lib.concatStringsSep " " config.boot.kernelParams}" > $out/kernel-params
${optionalString (config.hardware.deviceTree.package != null) ''
ln -s ${config.hardware.deviceTree.package} $out/dtbs
''}
ln -s ${config.hardware.firmware}/lib/firmware $out/firmware
# sysusers credentials (hashed passwords for systemd-sysusers)
ln -s ${config.system.build.sysusersCredentials} $out/sysusers-credentials
echo -n "${config.system.nixos.version}" > $out/nixos-version
echo -n "${pkgs.stdenv.hostPlatform.system}" > $out/system
'';
}

View file

@ -0,0 +1,158 @@
# turnstile.nix — session/login tracker + per-user dinit instance spawning.
#
# Integrates turnstile (Chimera Linux project) into MiNix:
# - turnstiled system service (dinit process type, starts before gettys)
# - pam_turnstile.so in PAM session rules for interactive services
# - XDG_RUNTIME_DIR management (/run/user/$UID)
# - dinit backend config adapted for MiNix paths
# - /etc/dinit.d/user/ for system-provided user services
{ config, lib, pkgs, ... }:
let
inherit (lib) mkDefault mkForce mkIf mkOption types concatStringsSep;
turnstile = pkgs.callPackage ../packages/turnstile.nix {
dinit = pkgs.dinit;
};
pamMinixNs = pkgs.callPackage ../packages/pam-minix-ns.nix {};
cfg = config.minix.turnstile;
# PAM services that should get pam_turnstile.so session rule
interactiveServices = [ "login" "sshd" "su" "sudo" ];
turnstileConf = pkgs.writeText "turnstiled.conf" (lib.concatStringsSep "\n" [
"backend = dinit"
"manage_rundir = ${if cfg.manageRundir then "yes" else "no"}"
"linger = ${cfg.linger}"
"rundir_path = /run/user/%u"
"export_dbus_address = yes"
"root_session = ${if cfg.rootSession then "yes" else "no"}"
"login_timeout = ${toString cfg.loginTimeout}"
""
]);
# dinit backend config — sourced by the backend shell script.
# Uses ''$ to escape Nix interpolation (these are shell variables).
dinitBackendConf = pkgs.writeText "dinit.conf" (lib.concatStringsSep "\n" [
''boot_dir="''${HOME}/.config/dinit.d/boot.d"''
''system_boot_dir="/etc/dinit.d/user/boot.d"''
''services_dir1="''${HOME}/.config/dinit.d"''
''services_dir2="/etc/dinit.d/user"''
""
]);
in {
options.minix.turnstile = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable turnstile session tracking and per-user dinit instances.";
};
manageRundir = mkOption {
type = types.bool;
default = true;
description = "Whether turnstile manages XDG_RUNTIME_DIR (/run/user/$UID).";
};
rootSession = mkOption {
type = types.bool;
default = false;
description = "Whether to spawn a user dinit instance for root.";
};
linger = mkOption {
type = types.enum [ "yes" "no" "maybe" ];
default = "maybe";
description = ''
Whether to keep user services running after last logout.
"maybe" checks /var/lib/turnstiled/linger/<username>.
'';
};
loginTimeout = mkOption {
type = types.int;
default = 60;
description = "Timeout (seconds) for user service manager startup during login.";
};
};
config = mkIf cfg.enable {
# --- turnstiled system service ---
dinit.services.turnstiled = {
type = "process";
command = "${turnstile}/bin/turnstiled";
logfile = "/var/log/turnstiled.log";
dependsMs = [ "early" ];
};
# --- Configuration files ---
environment.etc."turnstile/turnstiled.conf".source = turnstileConf;
environment.etc."turnstile/backend/dinit.conf".source = dinitBackendConf;
# --- System-provided user service directories (empty for now) ---
# Future: D-Bus session bus, PipeWire, etc. go here
environment.etc."dinit.d/user/.keep".text = "";
environment.etc."dinit.d/user/boot.d/.keep".text = "";
# --- PAM integration ---
# pam_turnstile.so — session tracking for interactive services.
# pam_minix_ns.so — enters per-user Nix overlay store namespace (setns).
# Order: pam_unix (10200) → pam_turnstile (11900) → pam_minix_ns (11950).
security.pam.services = lib.listToAttrs (map (name: {
inherit name;
value.rules.session = {
turnstile = {
enable = true;
control = "optional";
modulePath = "${turnstile}/lib/security/pam_turnstile.so";
order = 11900;
};
minix-ns = {
enable = true;
control = "optional";
modulePath = "${pamMinixNs}/lib/security/pam_minix_ns.so";
order = 11950;
};
};
}) interactiveServices)
// {
# The daemon itself uses PAM service "turnstiled" when spawning backend processes.
# pam_minix_ns runs BEFORE pam_turnstile here (order 11800) so the forked
# backend process inherits the mount namespace.
turnstiled = {
rootOK = true;
rules.session = {
minix-ns = {
enable = true;
control = "optional";
modulePath = "${pamMinixNs}/lib/security/pam_minix_ns.so";
order = 11800;
};
turnstile = {
enable = true;
control = "required";
modulePath = "${turnstile}/lib/security/pam_turnstile.so";
args = [ "turnstiled" ];
order = 11900;
};
};
};
};
# --- State directories ---
# turnstiled needs /var/lib/turnstiled for session state and linger files
systemd.tmpfiles.rules = [
"d /var/lib/turnstiled 0755 root root - -"
"d /var/lib/turnstiled/linger 0755 root root - -"
];
# turnstile package on system PATH (for dinitctl --user, loginctl-like queries)
environment.systemPackages = [ turnstile ];
};
}

373
system/modules/users.nix Normal file
View file

@ -0,0 +1,373 @@
# Minimal user/group management.
# Generates /etc/sysusers.d/00-minix.conf from declarative config.
# At boot, systemd-sysusers (standalone) creates passwd/shadow/group/gshadow
# with merge semantics — new entries added without touching existing ones.
#
# Phase 3 L2: also creates per-user Nix overlay store directories, generates
# user nix.conf (local-overlay-store), and per-user mount namespace with
# overlay store. Users run: home-manager switch --flake /etc/minix#<username>
{ config, pkgs, lib, ... }:
let
inherit (lib) mkOption mkDefault mkForce mkMerge types concatLines mapAttrsToList
mapAttrs filterAttrs filter optionalString escapeShellArg;
userOpts = { name, config, ... }: {
options = {
name = mkOption {
type = types.str;
default = "";
description = "Username.";
};
uid = mkOption {
type = types.int;
description = "User ID.";
};
gid = mkOption {
type = types.int;
default = config.uid;
description = "Primary group ID.";
};
home = mkOption {
type = types.path;
default = if name == "root" then "/root" else "/home/${name}";
description = "Home directory.";
};
shell = mkOption {
type = types.str;
default = "/bin/sh";
description = "Login shell.";
};
password = mkOption {
type = types.str;
default = "x";
description = "Password field for /etc/passwd. Use 'x' to defer to /etc/shadow.";
};
hashedPassword = mkOption {
type = types.str;
default = "";
description = "Hashed password for /etc/shadow. Empty string = no password.";
};
description = mkOption {
type = types.str;
default = "";
description = "GECOS field.";
};
createHome = mkOption {
type = types.bool;
default = false;
description = "Whether to create and chown the home directory at boot.";
};
};
config = {
name = mkDefault name;
};
};
groupOpts = { name, config, ... }: {
options = {
name = mkOption {
type = types.str;
default = "";
description = "Group name.";
};
gid = mkOption {
type = types.int;
description = "Group ID.";
};
members = mkOption {
type = types.listOf types.str;
default = [];
description = "Group members.";
};
};
config = {
name = mkDefault name;
};
};
cfg = config.minix.users;
grp = config.minix.groups;
# Generate /etc/sysusers.d/00-minix.conf — systemd-sysusers config format.
# u <name> <uid>:<gid> "<gecos>" <home> <shell>
# g <name> <gid>
# m <user> <group>
sysusersConfig = pkgs.writeTextDir "00-minix.conf" ''
${concatLines (mapAttrsToList (_: u:
''u ${u.name} ${toString u.uid}:${toString u.gid} "${u.description}" ${u.home} ${u.shell}''
) cfg)}
${concatLines (mapAttrsToList (_: g:
"g ${g.name} ${toString g.gid}"
) grp)}
${concatLines (lib.flatten (mapAttrsToList (_: g:
map (member: "m ${member} ${g.name}") g.members
) grp))}
'';
# Credentials directory for initial passwords.
# systemd-sysusers reads passwd.hashed-password.<user> files from $CREDENTIALS_DIRECTORY.
# On first boot: sets hashed passwords. On subsequent boots: existing entries preserved.
sysusersCredentials = pkgs.runCommand "sysusers-credentials" {} ''
mkdir -p $out
${concatLines (mapAttrsToList (_: u:
"printf '%s' ${escapeShellArg u.hashedPassword} > $out/passwd.hashed-password.${u.name}"
) cfg)}
'';
# Dynamic UID/GID allocation for users/groups without well-known IDs.
# Bridge from users.users/users.groups (stock NixOS API) → minix.users/minix.groups.
dynamicBase = 800;
dynamicGroupNames = builtins.sort builtins.lessThan (
filter (name:
let ng = config.users.groups.${name};
in !(ng.gid != null) && !(config.ids.gids ? ${name})
) (builtins.attrNames config.users.groups)
);
dynamicGidMap = lib.listToAttrs (lib.imap0 (i: name: {
inherit name;
value = dynamicBase + i;
}) dynamicGroupNames);
dynamicUserNames = builtins.sort builtins.lessThan (
filter (name:
let nu = config.users.users.${name};
in !(nu.uid != null) && !(config.ids.uids ? ${name})
) (builtins.attrNames config.users.users)
);
dynamicUidMap = lib.listToAttrs (lib.imap0 (i: name: {
inherit name;
value = dynamicBase + builtins.length dynamicGroupNames + i;
}) dynamicUserNames);
# --- Per-user Nix overlay store support ---
# Users eligible for per-user stores: non-system interactive users
perUserStoreUsers = filterAttrs (_: u: u.createHome && u.uid >= 1000) cfg;
# Per-user Nix config template (local-overlay-store)
mkUserNixConf = u: pkgs.writeText "nix-conf-${u.name}" ''
store = local-overlay://?lower-store=local&upper-layer=${u.home}/.local/share/nix/upper&state=${u.home}/.local/share/nix/var/nix&check-mount=true
experimental-features = nix-command flakes local-overlay-store
'';
in {
options = {
minix.users = mkOption {
type = types.attrsOf (types.submodule userOpts);
default = {};
description = "System user definitions.";
};
minix.groups = mkOption {
type = types.attrsOf (types.submodule groupOpts);
default = {};
description = "System group definitions.";
};
};
config = mkMerge [
# Default system users — UIDs from NixOS ids.nix
{
minix.users = {
root = {
uid = config.ids.uids.root;
gid = config.ids.gids.root;
home = "/root";
shell = mkForce "/bin/sh";
createHome = true;
hashedPassword = mkDefault "";
};
nobody = {
uid = config.ids.uids.nobody;
gid = config.ids.gids.nogroup;
home = "/var/empty";
shell = "/bin/sh";
};
};
# Default system groups — GIDs from NixOS ids.nix
minix.groups = {
root = { gid = config.ids.gids.root; };
nogroup = { gid = config.ids.gids.nogroup; };
tty = { gid = config.ids.gids.tty; };
wheel = { gid = config.ids.gids.wheel; };
audio = { gid = config.ids.gids.audio; };
video = { gid = config.ids.gids.video; };
disk = { gid = config.ids.gids.disk; };
input = { gid = config.ids.gids.input; };
kvm = { gid = config.ids.gids.kvm; };
shadow = { gid = config.ids.gids.shadow; };
users = { gid = config.ids.gids.users; };
dialout = { gid = config.ids.gids.dialout; };
utmp = { gid = config.ids.gids.utmp; };
adm = { gid = config.ids.gids.adm; };
};
}
# Override stock Perl-based activation scripts — sysusers handles users/groups at boot.
{
system.activationScripts.users = mkForce "";
system.activationScripts.groups = mkForce "";
system.activationScripts.hashes = mkForce "";
users.manageLingering = mkForce false;
}
# Bridge: users.users → minix.users (stock NixOS API → our format)
{
minix.users = mapAttrs (name: nu: let
resolveUid =
if nu.uid != null then nu.uid
else if config.ids.uids ? ${name} then config.ids.uids.${name}
else if dynamicUidMap ? ${name} then dynamicUidMap.${name}
else builtins.throw
"users.users.${name}: could not allocate UID";
groupName = if nu.group != "" then nu.group else name;
resolveGid =
if config.ids.gids ? ${groupName} then config.ids.gids.${groupName}
else if dynamicGidMap ? ${groupName} then dynamicGidMap.${groupName}
else resolveUid;
in {
uid = mkDefault resolveUid;
gid = mkDefault resolveGid;
home = mkDefault (toString nu.home);
shell = mkDefault (
let s = if nu.shell != null then toString nu.shell else "/bin/sh";
in if lib.hasPrefix "/nix/store/" s then "/bin/sh" else s);
description = mkDefault (
if nu.description != null then nu.description
else "");
hashedPassword = mkDefault (
if nu ? hashedPassword && nu.hashedPassword != null then nu.hashedPassword
else if nu ? initialHashedPassword && nu.initialHashedPassword != null then nu.initialHashedPassword
else "");
}) config.users.users;
minix.groups = mapAttrs (name: ng: let
resolveGid =
if ng.gid != null then ng.gid
else if config.ids.gids ? ${name} then config.ids.gids.${name}
else if dynamicGidMap ? ${name} then dynamicGidMap.${name}
else builtins.throw
"users.groups.${name}: could not allocate GID";
in {
gid = mkDefault resolveGid;
members = mkDefault ng.members;
}) config.users.groups;
}
# Bridge: users.users.*.extraGroups → minix.groups.*.members
{
minix.groups = let
allMemberships = lib.foldlAttrs (acc: userName: nu:
builtins.foldl' (a: grpName:
a // { ${grpName} = (a.${grpName} or []) ++ [userName]; }
) acc (nu.extraGroups or [])
) {} config.users.users;
in mapAttrs (_: members: {
members = mkDefault members;
}) allMemberships;
}
# Wire into environment.etc — sysusers.d config (sysusers creates passwd/shadow/group/gshadow at boot)
{
environment.etc."sysusers.d".source = sysusersConfig;
# Expose credentials dir so toplevel.nix can link it into the profile
system.build.sysusersCredentials = sysusersCredentials;
# Create home directories + per-user store setup via a one-shot dinit service.
dinit.services.user-init = let
homeUsers = lib.filterAttrs (_: u: u.createHome) cfg;
in {
type = "scripted";
dependsMs = [ "early" ];
command = toString (pkgs.writeScript "user-init" ''
#!/bin/sh
${concatLines (mapAttrsToList (name: u: ''
mkdir -p ${escapeShellArg u.home}
chown ${toString u.uid}:${toString u.gid} ${escapeShellArg u.home}
'') homeUsers)}
# Make system store db accessible to overlay store users.
# Directory: nix checks writability. big-lock: opened O_RDWR for flock().
# sqlite-shm/wal: WAL mode readers need write access.
# Runs every boot to self-heal after rebuilds.
chgrp users /nix/var/nix/db /nix/var/nix/db/big-lock /nix/var/nix/db/db.sqlite-shm /nix/var/nix/db/db.sqlite-wal
chmod 0775 /nix/var/nix/db
chmod 0660 /nix/var/nix/db/big-lock
chmod 0664 /nix/var/nix/db/db.sqlite-shm /nix/var/nix/db/db.sqlite-wal
${concatLines (mapAttrsToList (name: u: let
nixDir = "${u.home}/.local/share/nix";
configDir = "${u.home}/.config";
unshare = "${pkgs.util-linux}/bin/unshare";
in ''
# --- Per-user Nix overlay store: ${u.name} ---
mkdir -p "${nixDir}/upper" "${nixDir}/work" "${nixDir}/merged/nix/store"
mkdir -p "${nixDir}/var/nix/db" "${nixDir}/var/nix/profiles" "${nixDir}/var/nix/gcroots"
chown -R ${toString u.uid}:${toString u.gid} "${u.home}/.local"
# Per-user nix.conf (mutable — only create if missing)
mkdir -p "${configDir}/nix"
if [ ! -f "${configDir}/nix/nix.conf" ]; then
cp ${mkUserNixConf u} "${configDir}/nix/nix.conf"
chmod 644 "${configDir}/nix/nix.conf"
fi
chown -R ${toString u.uid}:${toString u.gid} "${configDir}/nix"
# --- Mount namespace with overlay store ---
# Strategy: start a helper in a new mount ns, bind-mount the ns
# handle from the parent (no propagation needed), set up overlay
# via nsenter, then kill the helper (ns persists via bind-mount).
# Non-fatal: login continues without overlay if this fails.
mkdir -p "/run/user/${toString u.uid}/ns"
chmod 0700 "/run/user/${toString u.uid}"
chown ${toString u.uid}:${toString u.gid} "/run/user/${toString u.uid}"
touch "/run/user/${toString u.uid}/ns/mnt"
${unshare} --mount sleep infinity &
_ns_pid=$!
# Wait for child to call unshare(2) — ns inode changes
_parent_ns=$(readlink /proc/self/ns/mnt)
while [ "$(readlink /proc/$_ns_pid/ns/mnt 2>/dev/null)" = "$_parent_ns" ]; do
sleep 0.01
done
if mount --bind "/proc/$_ns_pid/ns/mnt" "/run/user/${toString u.uid}/ns/mnt" 2>/dev/null; then
nsenter --mount="/run/user/${toString u.uid}/ns/mnt" sh -c '
mount --make-rprivate /
mount -t overlay overlay \
-o "lowerdir=/nix/store,upperdir=${nixDir}/upper,workdir=${nixDir}/work" \
"${nixDir}/merged/nix/store"
mount --bind "${nixDir}/merged/nix/store" /nix/store
'
else
echo "warning: namespace bind-mount failed for ${u.name}"
fi
kill $_ns_pid 2>/dev/null; wait $_ns_pid 2>/dev/null; true
'') perUserStoreUsers)}
'');
};
}
# home-manager CLI on system PATH
{
environment.systemPackages = [ pkgs.home-manager ];
}
];
}

20
system/overlay.nix Normal file
View file

@ -0,0 +1,20 @@
final: prev: {
procps = prev.procps.override {
withSystemd = false;
};
util-linux = prev.util-linux.override {
systemdSupport = false;
};
openssh = prev.openssh.override {
withFIDO = false;
};
dinit = prev.dinit.overrideAttrs (old: {
buildInputs = (old.buildInputs or []) ++ [ prev.libcap ];
configureFlags = (old.configureFlags or []) ++ [
"--enable-capabilities"
];
});
}

View file

@ -0,0 +1,21 @@
# pam_minix_ns.so — PAM session module that enters per-user Nix overlay
# store mount namespaces. Created at boot by user-init, persisted at
# /run/user/$UID/ns/mnt. See pam_minix_ns.c for details.
{ stdenv, pam }:
stdenv.mkDerivation {
pname = "pam-minix-ns";
version = "0.1.0";
dontUnpack = true;
buildInputs = [ pam ];
buildPhase = ''
$CC -shared -fPIC -o pam_minix_ns.so ${./pam_minix_ns.c} -lpam
'';
installPhase = ''
install -Dm755 pam_minix_ns.so $out/lib/security/pam_minix_ns.so
'';
}

View file

@ -0,0 +1,47 @@
/*
* pam_minix_ns enter per-user Nix overlay store mount namespace.
*
* At boot, user-init creates a mount namespace per user with overlayfs
* merging user packages onto /nix/store, persisted at /run/user/$UID/ns/mnt.
*
* This PAM session module calls setns() to move the login process (and
* turnstile backend) into that namespace. Runs as root in the PAM stack,
* so CAP_SYS_ADMIN is available. Always returns PAM_SUCCESS if the
* namespace doesn't exist or setns fails, the session continues without
* the overlay (degraded but functional).
*/
#define _GNU_SOURCE
#include <security/pam_modules.h>
#include <sched.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <pwd.h>
PAM_EXTERN int pam_sm_open_session(pam_handle_t *pamh, int flags,
int argc, const char **argv) {
const char *user;
if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS)
return PAM_SUCCESS;
struct passwd *pw = getpwnam(user);
if (!pw || pw->pw_uid < 1000)
return PAM_SUCCESS;
char ns_path[256];
snprintf(ns_path, sizeof(ns_path), "/run/user/%d/ns/mnt", pw->pw_uid);
int fd = open(ns_path, O_RDONLY);
if (fd < 0)
return PAM_SUCCESS;
setns(fd, CLONE_NEWNS);
close(fd);
return PAM_SUCCESS;
}
PAM_EXTERN int pam_sm_close_session(pam_handle_t *pamh, int flags,
int argc, const char **argv) {
return PAM_SUCCESS;
}

View file

@ -0,0 +1,86 @@
# Standalone systemd-sysusers binary, built from upstream systemd source.
# Uses -Dstandalone-binaries=true to produce a self-contained binary that
# statically links all systemd internals (only libc is dynamic).
#
# Handles user/group creation with merge semantics: creates missing entries
# without touching existing ones. Reads hashed passwords from
# $CREDENTIALS_DIRECTORY/passwd.hashed-password.<user>.
{ lib
, stdenv
, fetchFromGitHub
, meson
, ninja
, pkg-config
, gperf
, python3
, libcap
, libxcrypt
, util-linux
, linuxHeaders ? stdenv.cc.libc.linuxHeaders
}:
stdenv.mkDerivation {
pname = "systemd-sysusers-standalone";
version = "260.1";
src = fetchFromGitHub {
owner = "systemd";
repo = "systemd";
rev = "v260.1";
hash = "sha256-FUKj3lvjz8TIsyu8NyJYtiNele+1BhdJPdw7r7nW6as=";
};
nativeBuildInputs = [
meson
ninja
pkg-config
gperf
(python3.withPackages (ps: [ ps.jinja2 ]))
];
buildInputs = [
libcap
libxcrypt
util-linux
linuxHeaders
];
postPatch = ''
patchShebangs tools/ src/
# xml_helper.py needs lxml which we don't have — skip the check
substituteInPlace man/meson.build \
--replace-fail \
"have_lxml = run_command(xml_helper_py, check: false).returncode() == 0" \
"have_lxml = false"
'';
# Disable all auto-features, then enable only what we need.
mesonAutoFeatures = "disabled";
mesonFlags = [
"-Dstandalone-binaries=true"
"-Dsysusers=true"
"-Dmode=release"
"-Dstatic-libsystemd=pic"
"-Dtests=false"
"-Dinstall-tests=false"
];
ninjaFlags = [
"systemd-sysusers.standalone"
];
installPhase = ''
mkdir -p $out/bin
cp systemd-sysusers.standalone $out/bin/systemd-sysusers
'';
meta = with lib; {
description = "Standalone systemd-sysusers binary (statically links systemd internals)";
homepage = "https://github.com/systemd/systemd";
license = licenses.lgpl21Plus;
platforms = platforms.linux;
};
}

View file

@ -0,0 +1,97 @@
# Standalone systemd-tmpfiles binary, built from upstream systemd source.
# Uses -Dstandalone-binaries=true to produce a self-contained binary that
# statically links all systemd internals (only libc is dynamic).
{ lib
, stdenv
, fetchFromGitHub
, meson
, ninja
, pkg-config
, gperf
, python3
, libcap
, libxcrypt
, util-linux
, linuxHeaders ? stdenv.cc.libc.linuxHeaders
}:
stdenv.mkDerivation {
pname = "systemd-tmpfiles-standalone";
version = "260.1";
src = fetchFromGitHub {
owner = "systemd";
repo = "systemd";
rev = "v260.1";
hash = "sha256-FUKj3lvjz8TIsyu8NyJYtiNele+1BhdJPdw7r7nW6as=";
};
nativeBuildInputs = [
meson
ninja
pkg-config
gperf
(python3.withPackages (ps: [ ps.jinja2 ]))
];
buildInputs = [
libcap
libxcrypt
util-linux
linuxHeaders
];
postPatch = ''
patchShebangs tools/ src/
# xml_helper.py needs lxml which we don't have — skip the check
substituteInPlace man/meson.build \
--replace-fail \
"have_lxml = run_command(xml_helper_py, check: false).returncode() == 0" \
"have_lxml = false"
'';
# Disable all auto-features, then enable only what we need.
mesonAutoFeatures = "disabled";
mesonFlags = [
"-Dstandalone-binaries=true"
"-Dtmpfiles=true"
"-Dmode=release"
"-Dstatic-libsystemd=pic"
"-Dtests=false"
"-Dinstall-tests=false"
];
# Build the standalone binary + jinja2-generated tmpfiles.d configs.
# The .conf.in files use custom_target() so they need explicit ninja targets.
ninjaFlags = [
"systemd-tmpfiles.standalone"
"tmpfiles.d/var.conf"
"tmpfiles.d/systemd.conf"
"tmpfiles.d/static-nodes-permissions.conf"
];
installPhase = ''
mkdir -p $out/bin $out/example/tmpfiles.d
cp systemd-tmpfiles.standalone $out/bin/systemd-tmpfiles
# Install tmpfiles.d configs (matches stock nixpkgs path convention).
# Plain .conf from the source tree:
for f in $src/tmpfiles.d/*.conf; do
[ -f "$f" ] && install -m644 "$f" $out/example/tmpfiles.d/
done
# Jinja2-generated .conf from the build dir:
for f in tmpfiles.d/*.conf; do
[ -f "$f" ] && install -m644 "$f" $out/example/tmpfiles.d/
done
'';
meta = with lib; {
description = "Standalone systemd-tmpfiles binary (statically links systemd internals)";
homepage = "https://github.com/systemd/systemd";
license = licenses.lgpl21Plus;
platforms = platforms.linux;
};
}

View file

@ -0,0 +1,77 @@
# turnstile — session/login tracker with dinit user instance spawning.
# Chimera Linux project (BSD-2-Clause). Provides:
# - turnstiled daemon (tracks sessions, spawns per-user dinit)
# - pam_turnstile.so (PAM session module)
# - dinit backend script (shell script that launches dinit --user)
#
# Creates XDG_RUNTIME_DIR, manages user dinit lifecycle, exports
# DBUS_SESSION_BUS_ADDRESS.
{ lib
, stdenv
, fetchFromGitHub
, meson
, ninja
, pkg-config
, scdoc
, pam
, dinit
}:
stdenv.mkDerivation {
pname = "turnstile";
version = "0.1.11";
src = fetchFromGitHub {
owner = "chimera-linux";
repo = "turnstile";
rev = "v0.1.11";
hash = "sha256-94J+w0RHxzw7wS70LcpEzMvgevAqAwl0EtiANUmdRYU=";
};
nativeBuildInputs = [
meson
ninja
pkg-config
scdoc
];
buildInputs = [
pam
];
postPatch = ''
# Patch dinit backend script to use Nix store paths
substituteInPlace backend/dinit \
--replace-fail 'exec dinitctl ' 'exec ${dinit}/bin/dinitctl ' \
--replace-fail 'exec dinit ' 'exec ${dinit}/bin/dinit ' \
--replace-fail '/usr/bin/dinit-monitor' '${dinit}/bin/dinit-monitor'
# CONF_PATH must resolve to /etc/turnstile at runtime — that's where
# our NixOS module generates the configs via environment.etc.
# Default meson computes $out/etc/turnstile (Nix store), unreachable at runtime.
substituteInPlace meson.build \
--replace-fail \
"get_option('prefix'), get_option('sysconfdir'), 'turnstile'" \
"'/', 'etc', 'turnstile'"
'';
mesonFlags = [
"-Ddinit=enabled"
"-Drunit=disabled"
"-Dmanage_rundir=true"
"-Dlibrary=disabled"
"-Ddefault_backend=dinit"
"-Drundir=/run"
"-Dpam_moddir=${placeholder "out"}/lib/security"
"-Dpamdir=${placeholder "out"}/etc/pam.d"
];
meta = with lib; {
description = "Session/login tracker with per-user service manager spawning";
homepage = "https://github.com/chimera-linux/turnstile";
license = licenses.bsd2;
platforms = platforms.linux;
};
}