this repo has no description
1# NixOS module: Darling-based x86_64-darwin remote builder
2#
3# This module sets up a Darling instance as a Nix remote builder for
4# x86_64-darwin, allowing a Linux host to build Darwin packages without
5# Apple hardware. It manages:
6#
7# - SSH key generation for the Nix daemon ↔ Darling sshd connection
8# - Darling prefix initialisation (Nix install, sshd setup)
9# - A systemd service running sshd inside the Darling prefix
10# - Registration as a `nix.buildMachines` entry
11# - Optional /nix/store sharing between host and Darling prefix
12#
13# Usage (in a NixOS configuration):
14#
15# {
16# imports = [ ./path/to/darling-nix/nix/darlingBuilderModule.nix ];
17#
18# services.darling-builder = {
19# enable = true;
20# maxJobs = 4;
21# shareStore = true;
22# };
23# }
24#
25# After `nixos-rebuild switch`:
26#
27# nix build nixpkgs#hello --system x86_64-darwin
28#
29# See: plan/09-phase7-remote-builder.md
30{
31 config,
32 lib,
33 pkgs,
34 ...
35}:
36
37let
38 cfg = config.services.darling-builder;
39
40 # Helper: script that initialises the Darling prefix for builder use.
41 # Idempotent — safe to run on every service start.
42 prefixInitScript = pkgs.writeShellScript "darling-builder-init" ''
43 set -euo pipefail
44 export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.openssh ]}:$PATH"
45
46 DPREFIX="${cfg.prefixPath}"
47 export DPREFIX
48
49 echo "[darling-builder] Initialising prefix at $DPREFIX ..."
50
51 # First run creates the prefix (may take a while)
52 darling --prefix "$DPREFIX" shell true
53
54 # ── SSH setup ────────────────────────────────────────────────────
55 # Generate host keys inside the prefix if missing
56 darling --prefix "$DPREFIX" shell \
57 bash -c 'test -f /etc/ssh/ssh_host_ed25519_key || ssh-keygen -A'
58
59 # Write a minimal sshd_config
60 darling --prefix "$DPREFIX" shell bash -c 'cat > /etc/ssh/sshd_config << EOF
61Port ${toString cfg.port}
62ListenAddress 127.0.0.1
63PermitRootLogin yes
64PubkeyAuthentication yes
65AuthorizedKeysFile .ssh/authorized_keys
66PasswordAuthentication no
67ChallengeResponseAuthentication no
68UsePAM no
69UsePrivilegeSeparation no
70Subsystem sftp /usr/libexec/sftp-server
71AcceptEnv NIX_REMOTE NIX_PATH NIX_SSL_CERT_FILE
72EOF
73'
74
75 # Install the public key
76 darling --prefix "$DPREFIX" shell mkdir -p /var/root/.ssh
77 cat "${cfg.sshKeyPath}.pub" | \
78 darling --prefix "$DPREFIX" shell tee /var/root/.ssh/authorized_keys > /dev/null
79 darling --prefix "$DPREFIX" shell chmod 700 /var/root/.ssh
80 darling --prefix "$DPREFIX" shell chmod 600 /var/root/.ssh/authorized_keys
81
82 # ── Nix configuration ────────────────────────────────────────────
83 darling --prefix "$DPREFIX" shell mkdir -p /etc/nix
84
85 darling --prefix "$DPREFIX" shell bash -c 'cat > /etc/nix/nix.conf << EOF
86# Managed by NixOS darling-builder module — do not edit
87build-users-group =
88sandbox = false
89experimental-features = nix-command flakes
90substituters = https://cache.nixos.org
91trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
92EOF
93'
94
95 # ── Shared store (optional) ──────────────────────────────────────
96 ${lib.optionalString cfg.shareStore ''
97 # Darling exposes the host root at /Volumes/SystemRoot inside the
98 # prefix, so /Volumes/SystemRoot/nix is the host's /nix.
99 # We symlink /nix -> /Volumes/SystemRoot/nix to share the store
100 # and avoid expensive SSH-based copying.
101 darling --prefix "$DPREFIX" shell bash -c '
102 if [ ! -L /nix ] && [ ! -d /nix/store ]; then
103 rm -rf /nix 2>/dev/null || true
104 ln -sf /Volumes/SystemRoot/nix /nix
105 elif [ -L /nix ]; then
106 # Already a symlink — make sure it points to the right place
107 target=$(readlink /nix)
108 if [ "$target" != "/Volumes/SystemRoot/nix" ]; then
109 rm /nix
110 ln -sf /Volumes/SystemRoot/nix /nix
111 fi
112 fi
113 '
114 ''}
115
116 # ── Directory Services stubs (for multi-user Nix if needed) ──────
117 # Verify the stubs are installed (they ship with our Darling package)
118 for tool in dseditgroup sysadminctl dscl; do
119 if ! darling --prefix "$DPREFIX" shell test -x "/usr/sbin/$tool"; then
120 echo "[darling-builder] WARNING: /usr/sbin/$tool not found in prefix"
121 fi
122 done
123
124 # Verify sandbox-exec stub
125 if ! darling --prefix "$DPREFIX" shell test -x /usr/bin/sandbox-exec; then
126 echo "[darling-builder] WARNING: /usr/bin/sandbox-exec not found in prefix"
127 fi
128
129 # ── Ensure /var/empty exists (home dir for build users) ──────────
130 darling --prefix "$DPREFIX" shell mkdir -p /var/empty
131 darling --prefix "$DPREFIX" shell chmod 555 /var/empty
132
133 # ── sshd privilege-separation directory ───────────────────────────
134 darling --prefix "$DPREFIX" shell mkdir -p /var/empty/sshd
135 darling --prefix "$DPREFIX" shell chmod 755 /var/empty/sshd
136
137 echo "[darling-builder] Prefix initialisation complete."
138 '';
139
140 # Connectivity test script — useful for manual debugging
141 connectivityTestScript = pkgs.writeShellScript "darling-builder-test" ''
142 set -euo pipefail
143 export PATH="${lib.makeBinPath [ pkgs.openssh pkgs.coreutils ]}:$PATH"
144
145 echo "Testing SSH connectivity to Darling builder ..."
146 ssh -o StrictHostKeyChecking=no \
147 -o UserKnownHostsFile=/dev/null \
148 -i "${cfg.sshKeyPath}" \
149 -p ${toString cfg.port} \
150 root@127.0.0.1 \
151 echo "SSH connection OK"
152
153 echo "Testing Nix inside Darling builder ..."
154 ssh -o StrictHostKeyChecking=no \
155 -o UserKnownHostsFile=/dev/null \
156 -i "${cfg.sshKeyPath}" \
157 -p ${toString cfg.port} \
158 root@127.0.0.1 \
159 'source /Users/root/.nix-profile/etc/profile.d/nix.sh 2>/dev/null || true; nix --version'
160
161 echo "Testing nix store ping ..."
162 nix store ping --store "ssh://root@127.0.0.1?ssh-key=${cfg.sshKeyPath}" \
163 --option ssh-port ${toString cfg.port} \
164 && echo "nix store ping OK" \
165 || echo "nix store ping FAILED (this may be expected if Nix is not yet installed in the prefix)"
166
167 echo ""
168 echo "Darling builder connectivity test complete."
169 '';
170in
171{
172 options.services.darling-builder = {
173 enable = lib.mkEnableOption "Darling-based x86_64-darwin remote Nix builder";
174
175 package = lib.mkPackageOption pkgs "darling" { };
176
177 port = lib.mkOption {
178 type = lib.types.port;
179 default = 2222;
180 description = ''
181 SSH port for the sshd instance running inside the Darling prefix.
182 Uses 2222 by default to avoid conflict with the host's sshd on port 22.
183 '';
184 };
185
186 maxJobs = lib.mkOption {
187 type = lib.types.ints.positive;
188 default = 4;
189 description = ''
190 Maximum number of concurrent builds the Darling builder will run.
191 Set this based on available CPU cores; Darwin builds inside Darling
192 are slower than native, so a conservative value is recommended.
193 '';
194 };
195
196 speedFactor = lib.mkOption {
197 type = lib.types.ints.unsigned;
198 default = 1;
199 description = ''
200 Speed factor for this builder relative to others. Lower values
201 mean Nix will prefer faster (native) builders when available.
202 Use 1 for a Darling-only setup, or a low value (1-5) when real
203 macOS builders are also configured.
204 '';
205 };
206
207 shareStore = lib.mkOption {
208 type = lib.types.bool;
209 default = true;
210 description = ''
211 Whether to share the host's /nix/store with the Darling prefix
212 via a symlink through /Volumes/SystemRoot/nix. This avoids the
213 expensive step of copying store paths over SSH and makes build
214 outputs immediately available on the host.
215
216 When disabled, Nix will use SSH-based store path transfer, which
217 is slower but simpler and avoids any store database conflicts.
218 '';
219 };
220
221 sshKeyPath = lib.mkOption {
222 type = lib.types.str;
223 default = "/etc/nix/darling-builder-key";
224 description = ''
225 Path to the SSH ed25519 private key used by the host's Nix daemon
226 to connect to the sshd running inside Darling. The corresponding
227 public key (.pub) is installed into the Darling prefix's
228 authorized_keys.
229
230 The key is auto-generated on first activation if it doesn't exist.
231 '';
232 };
233
234 prefixPath = lib.mkOption {
235 type = lib.types.str;
236 default = "/var/lib/darling-builder";
237 description = ''
238 Path to the Darling prefix used by the builder. This is a
239 separate prefix from the default (~/.darling) to avoid
240 interference with interactive Darling usage.
241 '';
242 };
243
244 supportedFeatures = lib.mkOption {
245 type = lib.types.listOf lib.types.str;
246 default = [ ];
247 description = ''
248 List of supported features to advertise for this builder.
249 Derivations can require specific features (e.g. "big-parallel")
250 and will only be scheduled on builders that support them.
251 '';
252 };
253
254 mandatoryFeatures = lib.mkOption {
255 type = lib.types.listOf lib.types.str;
256 default = [ ];
257 description = ''
258 List of mandatory features for this builder. Only derivations
259 that explicitly require ALL of these features will be scheduled
260 on this builder. Usually left empty.
261 '';
262 };
263
264 installNix = lib.mkOption {
265 type = lib.types.bool;
266 default = false;
267 description = ''
268 Whether to automatically install Nix inside the Darling prefix
269 during service startup. Requires network access to download the
270 Nix installer. If false (default), you must install Nix manually
271 using scripts/install-nix-in-darling.sh before enabling the builder.
272
273 NOTE: This is a slow operation on first run (downloads ~50MB) and
274 is best done manually. Set to true only for automated/CI setups.
275 '';
276 };
277
278 nixVersion = lib.mkOption {
279 type = lib.types.str;
280 default = "2.24.12";
281 description = ''
282 Version of Nix to install inside the Darling prefix when
283 `installNix` is true. Must match a release on
284 https://releases.nixos.org/nix/.
285 '';
286 };
287 };
288
289 config = lib.mkIf cfg.enable {
290 # Ensure the base Darling program is available
291 programs.darling.enable = true;
292 programs.darling.package = cfg.package;
293
294 # ── SSH key generation ───────────────────────────────────────────
295 # Generate the ed25519 keypair on system activation if it doesn't
296 # already exist. The key is owned by root with mode 600.
297 system.activationScripts.darling-builder-keys = lib.stringAfter [ "users" ] ''
298 if [ ! -f "${cfg.sshKeyPath}" ]; then
299 echo "[darling-builder] Generating SSH keypair at ${cfg.sshKeyPath} ..."
300 mkdir -p "$(dirname "${cfg.sshKeyPath}")"
301 ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -N "" -f "${cfg.sshKeyPath}" -C "darling-builder@$(hostname)"
302 chown root:root "${cfg.sshKeyPath}" "${cfg.sshKeyPath}.pub"
303 chmod 600 "${cfg.sshKeyPath}"
304 chmod 644 "${cfg.sshKeyPath}.pub"
305 fi
306 '';
307
308 # ── Prefix directory ─────────────────────────────────────────────
309 systemd.tmpfiles.rules = [
310 "d ${cfg.prefixPath} 0755 root root -"
311 ];
312
313 # ── Darling builder service ──────────────────────────────────────
314 # This service:
315 # 1. Initialises the Darling prefix (idempotent)
316 # 2. Optionally installs Nix
317 # 3. Starts sshd inside the prefix (foreground, Type=simple)
318 #
319 # sshd runs in the foreground (-D) so systemd can manage its lifecycle.
320 systemd.services.darling-builder = {
321 description = "Darling x86_64-darwin Nix remote builder";
322 wantedBy = [ "multi-user.target" ];
323 after = [ "network.target" "nss-lookup.target" ];
324
325 path = [ cfg.package pkgs.coreutils pkgs.openssh pkgs.curl ];
326
327 environment = {
328 DPREFIX = cfg.prefixPath;
329 HOME = "/root";
330 };
331
332 serviceConfig = {
333 Type = "simple";
334
335 # Initialise the prefix before starting sshd
336 ExecStartPre = [
337 "+${prefixInitScript}"
338 ] ++ lib.optional cfg.installNix
339 "+${pkgs.writeShellScript "darling-builder-install-nix" ''
340 set -euo pipefail
341 export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.curl pkgs.gnutar pkgs.xz ]}:$PATH"
342 export DPREFIX="${cfg.prefixPath}"
343
344 # Skip if Nix is already installed
345 if darling --prefix "$DPREFIX" shell test -x /nix/var/nix/profiles/default/bin/nix 2>/dev/null; then
346 echo "[darling-builder] Nix is already installed, skipping."
347 exit 0
348 fi
349
350 # Download and install Nix
351 nix_version="${cfg.nixVersion}"
352 installer_name="nix-''${nix_version}-x86_64-darwin"
353 installer_url="https://releases.nixos.org/nix/nix-''${nix_version}/''${installer_name}.tar.xz"
354
355 tmpdir=$(mktemp -d)
356 trap "rm -rf $tmpdir" EXIT
357
358 echo "[darling-builder] Downloading Nix $nix_version ..."
359 curl -fsSL -o "$tmpdir/nix-installer.tar.xz" "$installer_url"
360
361 echo "[darling-builder] Extracting ..."
362 mkdir -p "$tmpdir/extract"
363 tar -xf "$tmpdir/nix-installer.tar.xz" -C "$tmpdir/extract"
364
365 installer_dir=$(find "$tmpdir/extract" -maxdepth 1 -type d -name 'nix-*' | head -1)
366 if [ -z "$installer_dir" ]; then
367 echo "[darling-builder] ERROR: Could not find extracted installer directory"
368 exit 1
369 fi
370
371 # Patch: force single-user / no-daemon mode
372 sed -i 's/INSTALL_MODE=daemon/INSTALL_MODE=no-daemon/g' "$installer_dir/install"
373
374 # Copy into the Darling prefix
375 prefix_tmp="$DPREFIX/private/tmp/nix-installer"
376 mkdir -p "$prefix_tmp"
377 cp -a "$installer_dir/"* "$prefix_tmp/"
378 chmod +x "$prefix_tmp/install"
379
380 echo "[darling-builder] Running Nix installer inside Darling ..."
381 darling --prefix "$DPREFIX" shell env \
382 NIX_INSTALLER_NO_MODIFY_PROFILE=0 \
383 bash /tmp/nix-installer/install --no-daemon
384
385 rm -rf "$prefix_tmp"
386 echo "[darling-builder] Nix installation complete."
387 ''}";
388
389 ExecStart = "${cfg.package}/bin/darling --prefix ${cfg.prefixPath} shell /usr/sbin/sshd -D -f /etc/ssh/sshd_config";
390
391 # Stop sshd cleanly, then shut down the Darling prefix
392 ExecStopPost = "-${cfg.package}/bin/darling --prefix ${cfg.prefixPath} shutdown";
393
394 Restart = "on-failure";
395 RestartSec = 10;
396
397 # Give the prefix init plenty of time (first run is slow)
398 TimeoutStartSec = "10min";
399 TimeoutStopSec = "30s";
400
401 # Security hardening (the service runs as root because Darling
402 # needs namespace capabilities, but we restrict what it can do)
403 NoNewPrivileges = false; # Darling needs to create namespaces
404 ProtectSystem = "full";
405 ProtectHome = "read-only";
406 ReadWritePaths = [
407 cfg.prefixPath
408 "/nix"
409 ];
410 };
411 };
412
413 # ── Register as a Nix remote builder ─────────────────────────────
414 nix.buildMachines = [
415 {
416 hostName = "127.0.0.1";
417 port = cfg.port;
418 systems = [ "x86_64-darwin" ];
419 sshUser = "root";
420 sshKey = cfg.sshKeyPath;
421 maxJobs = cfg.maxJobs;
422 speedFactor = cfg.speedFactor;
423 supportedFeatures = cfg.supportedFeatures;
424 mandatoryFeatures = cfg.mandatoryFeatures;
425 }
426 ];
427
428 nix.distributedBuilds = true;
429
430 # Trust the builder's SSH host key automatically on first connection
431 # to avoid interactive prompts when the Nix daemon connects.
432 nix.settings.trusted-users = [ "root" ];
433
434 # ── Convenience: connectivity test script ────────────────────────
435 environment.systemPackages = [
436 (pkgs.writeShellScriptBin "darling-builder-test" ''
437 exec ${connectivityTestScript} "$@"
438 '')
439 ];
440 };
441}