1{ system ? builtins.currentSystem,
2 config ? {},
3 pkgs ? import ../.. { inherit system config; },
4 debug ? false,
5 enableUnfree ? false,
6 use64bitGuest ? true
7}:
8
9with import ../lib/testing-python.nix { inherit system pkgs; };
10with pkgs.lib;
11
12let
13 testVMConfig = vmName: attrs: { config, pkgs, lib, ... }: let
14 guestAdditions = pkgs.linuxPackages.virtualboxGuestAdditions;
15
16 miniInit = ''
17 #!${pkgs.runtimeShell} -xe
18 export PATH="${lib.makeBinPath [ pkgs.coreutils pkgs.util-linux ]}"
19
20 mkdir -p /run/dbus /var
21 ln -s /run /var
22 cat > /etc/passwd <<EOF
23 root:x:0:0::/root:/bin/false
24 messagebus:x:1:1::/run/dbus:/bin/false
25 EOF
26 cat > /etc/group <<EOF
27 root:x:0:
28 messagebus:x:1:
29 EOF
30
31 "${pkgs.dbus}/bin/dbus-daemon" --fork \
32 --config-file="${pkgs.dbus}/share/dbus-1/system.conf"
33
34 ${guestAdditions}/bin/VBoxService
35 ${(attrs.vmScript or (const "")) pkgs}
36
37 i=0
38 while [ ! -e /mnt-root/shutdown ]; do
39 sleep 10
40 i=$(($i + 10))
41 [ $i -le 120 ] || fail
42 done
43
44 rm -f /mnt-root/boot-done /mnt-root/shutdown
45 '';
46 in {
47 boot.kernelParams = [
48 "console=tty0" "console=ttyS0" "ignore_loglevel"
49 "boot.trace" "panic=1" "boot.panic_on_fail"
50 "init=${pkgs.writeScript "mini-init.sh" miniInit}"
51 ];
52
53 fileSystems."/" = {
54 device = "vboxshare";
55 fsType = "vboxsf";
56 };
57
58 virtualisation.virtualbox.guest.enable = true;
59
60 boot.initrd.kernelModules = [
61 "af_packet" "vboxsf"
62 "virtio" "virtio_pci" "virtio_ring" "virtio_net" "vboxguest"
63 ];
64
65 boot.initrd.extraUtilsCommands = ''
66 copy_bin_and_libs "${guestAdditions}/bin/mount.vboxsf"
67 copy_bin_and_libs "${pkgs.util-linux}/bin/unshare"
68 ${(attrs.extraUtilsCommands or (const "")) pkgs}
69 '';
70
71 boot.initrd.postMountCommands = ''
72 touch /mnt-root/boot-done
73 hostname "${vmName}"
74 mkdir -p /nix/store
75 unshare -m ${escapeShellArg pkgs.runtimeShell} -c '
76 mount -t vboxsf nixstore /nix/store
77 exec "$stage2Init"
78 '
79 poweroff -f
80 '';
81
82 system.requiredKernelConfig = with config.lib.kernelConfig; [
83 (isYes "SERIAL_8250_CONSOLE")
84 (isYes "SERIAL_8250")
85 ];
86
87 networking.usePredictableInterfaceNames = false;
88 };
89
90 mkLog = logfile: tag: let
91 rotated = map (i: "${logfile}.${toString i}") (range 1 9);
92 all = concatMapStringsSep " " (f: "\"${f}\"") ([logfile] ++ rotated);
93 logcmd = "tail -F ${all} 2> /dev/null | logger -t \"${tag}\"";
94 in if debug then "machine.execute(ru('${logcmd} & disown'))" else "pass";
95
96 testVM = vmName: vmScript: let
97 cfg = (import ../lib/eval-config.nix {
98 system = if use64bitGuest then "x86_64-linux" else "i686-linux";
99 modules = [
100 ../modules/profiles/minimal.nix
101 (testVMConfig vmName vmScript)
102 ];
103 }).config;
104 in pkgs.vmTools.runInLinuxVM (pkgs.runCommand "virtualbox-image" {
105 preVM = ''
106 mkdir -p "$out"
107 diskImage="$(pwd)/qimage"
108 ${pkgs.vmTools.qemu}/bin/qemu-img create -f raw "$diskImage" 100M
109 '';
110
111 postVM = ''
112 echo "creating VirtualBox disk image..."
113 ${pkgs.vmTools.qemu}/bin/qemu-img convert -f raw -O vdi \
114 "$diskImage" "$out/disk.vdi"
115 '';
116
117 buildInputs = [ pkgs.util-linux pkgs.perl ];
118 } ''
119 ${pkgs.parted}/sbin/parted --script /dev/vda mklabel msdos
120 ${pkgs.parted}/sbin/parted --script /dev/vda -- mkpart primary ext2 1M -1s
121 ${pkgs.e2fsprogs}/sbin/mkfs.ext4 /dev/vda1
122 ${pkgs.e2fsprogs}/sbin/tune2fs -c 0 -i 0 /dev/vda1
123 mkdir /mnt
124 mount /dev/vda1 /mnt
125 cp "${cfg.system.build.kernel}/bzImage" /mnt/linux
126 cp "${cfg.system.build.initialRamdisk}/initrd" /mnt/initrd
127
128 ${pkgs.grub2}/bin/grub-install --boot-directory=/mnt /dev/vda
129
130 cat > /mnt/grub/grub.cfg <<GRUB
131 set root=hd0,1
132 linux /linux ${concatStringsSep " " cfg.boot.kernelParams}
133 initrd /initrd
134 boot
135 GRUB
136 umount /mnt
137 '');
138
139 createVM = name: attrs: let
140 mkFlags = concatStringsSep " ";
141
142 sharePath = "/home/alice/vboxshare-${name}";
143
144 createFlags = mkFlags [
145 "--ostype ${if use64bitGuest then "Linux26_64" else "Linux26"}"
146 "--register"
147 ];
148
149 vmFlags = mkFlags ([
150 "--uart1 0x3F8 4"
151 "--uartmode1 client /run/virtualbox-log-${name}.sock"
152 "--memory 768"
153 "--audio none"
154 ] ++ (attrs.vmFlags or []));
155
156 controllerFlags = mkFlags [
157 "--name SATA"
158 "--add sata"
159 "--bootable on"
160 "--hostiocache on"
161 ];
162
163 diskFlags = mkFlags [
164 "--storagectl SATA"
165 "--port 0"
166 "--device 0"
167 "--type hdd"
168 "--mtype immutable"
169 "--medium ${testVM name attrs}/disk.vdi"
170 ];
171
172 sharedFlags = mkFlags [
173 "--name vboxshare"
174 "--hostpath ${sharePath}"
175 ];
176
177 nixstoreFlags = mkFlags [
178 "--name nixstore"
179 "--hostpath /nix/store"
180 "--readonly"
181 ];
182 in {
183 machine = {
184 systemd.sockets."vboxtestlog-${name}" = {
185 description = "VirtualBox Test Machine Log Socket For ${name}";
186 wantedBy = [ "sockets.target" ];
187 before = [ "multi-user.target" ];
188 socketConfig.ListenStream = "/run/virtualbox-log-${name}.sock";
189 socketConfig.Accept = true;
190 };
191
192 systemd.services."vboxtestlog-${name}@" = {
193 description = "VirtualBox Test Machine Log For ${name}";
194 serviceConfig.StandardInput = "socket";
195 serviceConfig.StandardOutput = "journal";
196 serviceConfig.SyslogIdentifier = "GUEST-${name}";
197 serviceConfig.ExecStart = "${pkgs.coreutils}/bin/cat";
198 };
199 };
200
201 testSubs = ''
202
203
204 ${name}_sharepath = "${sharePath}"
205
206
207 def check_running_${name}():
208 cmd = "VBoxManage list runningvms | grep -q '^\"${name}\"'"
209 (status, _) = machine.execute(ru(cmd))
210 return status == 0
211
212
213 def cleanup_${name}():
214 if check_running_${name}():
215 machine.execute(ru("VBoxManage controlvm ${name} poweroff"))
216 machine.succeed("rm -rf ${sharePath}")
217 machine.succeed("mkdir -p ${sharePath}")
218 machine.succeed("chown alice:users ${sharePath}")
219
220
221 def create_vm_${name}():
222 cleanup_${name}()
223 vbm("createvm --name ${name} ${createFlags}")
224 vbm("modifyvm ${name} ${vmFlags}")
225 vbm("setextradata ${name} VBoxInternal/PDM/HaltOnReset 1")
226 vbm("storagectl ${name} ${controllerFlags}")
227 vbm("storageattach ${name} ${diskFlags}")
228 vbm("sharedfolder add ${name} ${sharedFlags}")
229 vbm("sharedfolder add ${name} ${nixstoreFlags}")
230
231 ${mkLog "$HOME/VirtualBox VMs/${name}/Logs/VBox.log" "HOST-${name}"}
232
233
234 def destroy_vm_${name}():
235 cleanup_${name}()
236 vbm("unregistervm ${name} --delete")
237
238
239 def wait_for_vm_boot_${name}():
240 machine.execute(
241 ru(
242 "set -e; i=0; "
243 "while ! test -e ${sharePath}/boot-done; do "
244 "sleep 10; i=$(($i + 10)); [ $i -le 3600 ]; "
245 "VBoxManage list runningvms | grep -q '^\"${name}\"'; "
246 "done"
247 )
248 )
249
250
251 def wait_for_ip_${name}(interface):
252 property = f"/VirtualBox/GuestInfo/Net/{interface}/V4/IP"
253 getip = f"VBoxManage guestproperty get ${name} {property} | sed -n -e 's/^Value: //p'"
254
255 ip = machine.succeed(
256 ru(
257 "for i in $(seq 1000); do "
258 f'if ipaddr="$({getip})" && [ -n "$ipaddr" ]; then '
259 'echo "$ipaddr"; exit 0; '
260 "fi; "
261 "sleep 1; "
262 "done; "
263 "echo 'Could not get IPv4 address for ${name}!' >&2; "
264 "exit 1"
265 )
266 ).strip()
267 return ip
268
269
270 def wait_for_startup_${name}(nudge=lambda: None):
271 for _ in range(0, 130, 10):
272 machine.sleep(10)
273 if check_running_${name}():
274 return
275 nudge()
276 raise Exception("VirtualBox VM didn't start up within 2 minutes")
277
278
279 def wait_for_shutdown_${name}():
280 for _ in range(0, 130, 10):
281 machine.sleep(10)
282 if not check_running_${name}():
283 return
284 raise Exception("VirtualBox VM didn't shut down within 2 minutes")
285
286
287 def shutdown_vm_${name}():
288 machine.succeed(ru("touch ${sharePath}/shutdown"))
289 machine.execute(
290 "set -e; i=0; "
291 "while test -e ${sharePath}/shutdown "
292 " -o -e ${sharePath}/boot-done; do "
293 "sleep 1; i=$(($i + 1)); [ $i -le 3600 ]; "
294 "done"
295 )
296 wait_for_shutdown_${name}()
297 '';
298 };
299
300 hostonlyVMFlags = [
301 "--nictype1 virtio"
302 "--nictype2 virtio"
303 "--nic2 hostonly"
304 "--hostonlyadapter2 vboxnet0"
305 ];
306
307 # The VirtualBox Oracle Extension Pack lets you use USB 3.0 (xHCI).
308 enableExtensionPackVMFlags = [
309 "--usbxhci on"
310 ];
311
312 dhcpScript = pkgs: ''
313 ${pkgs.dhcpcd}/bin/dhcpcd eth0 eth1
314
315 otherIP="$(${pkgs.netcat}/bin/nc -l 1234 || :)"
316 ${pkgs.iputils}/bin/ping -I eth1 -c1 "$otherIP"
317 echo "$otherIP reachable" | ${pkgs.netcat}/bin/nc -l 5678 || :
318 '';
319
320 sysdDetectVirt = pkgs: ''
321 ${pkgs.systemd}/bin/systemd-detect-virt > /mnt-root/result
322 '';
323
324 vboxVMs = mapAttrs createVM {
325 simple = {};
326
327 detectvirt.vmScript = sysdDetectVirt;
328
329 test1.vmFlags = hostonlyVMFlags;
330 test1.vmScript = dhcpScript;
331
332 test2.vmFlags = hostonlyVMFlags;
333 test2.vmScript = dhcpScript;
334
335 headless.virtualisation.virtualbox.headless = true;
336 headless.services.xserver.enable = false;
337 };
338
339 vboxVMsWithExtpack = mapAttrs createVM {
340 testExtensionPack.vmFlags = enableExtensionPackVMFlags;
341 };
342
343 mkVBoxTest = useExtensionPack: vms: name: testScript: makeTest {
344 name = "virtualbox-${name}";
345
346 nodes.machine = { lib, config, ... }: {
347 imports = let
348 mkVMConf = name: val: val.machine // { key = "${name}-config"; };
349 vmConfigs = mapAttrsToList mkVMConf vms;
350 in [ ./common/user-account.nix ./common/x11.nix ] ++ vmConfigs;
351 virtualisation.memorySize = 2048;
352 virtualisation.qemu.options = ["-cpu" "kvm64,svm=on,vmx=on"];
353 virtualisation.virtualbox.host.enable = true;
354 test-support.displayManager.auto.user = "alice";
355 users.users.alice.extraGroups = let
356 inherit (config.virtualisation.virtualbox.host) enableHardening;
357 in lib.mkIf enableHardening (lib.singleton "vboxusers");
358 virtualisation.virtualbox.host.enableExtensionPack = useExtensionPack;
359 nixpkgs.config.allowUnfree = useExtensionPack;
360 };
361
362 testScript = ''
363 from shlex import quote
364 ${concatStrings (mapAttrsToList (_: getAttr "testSubs") vms)}
365
366 def ru(cmd: str) -> str:
367 return f"su - alice -c {quote(cmd)}"
368
369
370 def vbm(cmd: str) -> str:
371 return machine.succeed(ru(f"VBoxManage {cmd}"))
372
373
374 def remove_uuids(output: str) -> str:
375 return "\n".join(
376 [line for line in (output or "").splitlines() if not line.startswith("UUID:")]
377 )
378
379
380 machine.wait_for_x()
381
382 ${mkLog "$HOME/.config/VirtualBox/VBoxSVC.log" "HOST-SVC"}
383
384 ${testScript}
385 # (keep black happy)
386 '';
387
388 meta = with pkgs.lib.maintainers; {
389 maintainers = [ aszlig cdepillabout ];
390 };
391 };
392
393 unfreeTests = mapAttrs (mkVBoxTest true vboxVMsWithExtpack) {
394 enable-extension-pack = ''
395 create_vm_testExtensionPack()
396 vbm("startvm testExtensionPack")
397 wait_for_startup_testExtensionPack()
398 machine.screenshot("cli_started")
399 wait_for_vm_boot_testExtensionPack()
400 machine.screenshot("cli_booted")
401
402 with machine.nested("Checking for privilege escalation"):
403 machine.fail("test -e '/root/VirtualBox VMs'")
404 machine.fail("test -e '/root/.config/VirtualBox'")
405 machine.succeed("test -e '/home/alice/VirtualBox VMs'")
406
407 shutdown_vm_testExtensionPack()
408 destroy_vm_testExtensionPack()
409 '';
410 };
411
412in mapAttrs (mkVBoxTest false vboxVMs) {
413 simple-gui = ''
414 # Home to select Tools, down to move to the VM, enter to start it.
415 def send_vm_startup():
416 machine.send_key("home")
417 machine.send_key("down")
418 machine.send_key("ret")
419
420
421 create_vm_simple()
422 machine.succeed(ru("VirtualBox >&2 &"))
423 machine.wait_until_succeeds(ru("xprop -name 'Oracle VM VirtualBox Manager'"))
424 machine.sleep(5)
425 machine.screenshot("gui_manager_started")
426 send_vm_startup()
427 machine.screenshot("gui_manager_sent_startup")
428 wait_for_startup_simple(send_vm_startup)
429 machine.screenshot("gui_started")
430 wait_for_vm_boot_simple()
431 machine.screenshot("gui_booted")
432 shutdown_vm_simple()
433 machine.sleep(5)
434 machine.screenshot("gui_stopped")
435 machine.send_key("ctrl-q")
436 machine.sleep(5)
437 machine.screenshot("gui_manager_stopped")
438 destroy_vm_simple()
439 '';
440
441 simple-cli = ''
442 create_vm_simple()
443 vbm("startvm simple")
444 wait_for_startup_simple()
445 machine.screenshot("cli_started")
446 wait_for_vm_boot_simple()
447 machine.screenshot("cli_booted")
448
449 with machine.nested("Checking for privilege escalation"):
450 machine.fail("test -e '/root/VirtualBox VMs'")
451 machine.fail("test -e '/root/.config/VirtualBox'")
452 machine.succeed("test -e '/home/alice/VirtualBox VMs'")
453
454 shutdown_vm_simple()
455 destroy_vm_simple()
456 '';
457
458 headless = ''
459 create_vm_headless()
460 machine.succeed(ru("VBoxHeadless --startvm headless >&2 & disown %1"))
461 wait_for_startup_headless()
462 wait_for_vm_boot_headless()
463 shutdown_vm_headless()
464 destroy_vm_headless()
465 '';
466
467 host-usb-permissions = ''
468 import sys
469
470 user_usb = remove_uuids(vbm("list usbhost"))
471 print(user_usb, file=sys.stderr)
472 root_usb = remove_uuids(machine.succeed("VBoxManage list usbhost"))
473 print(root_usb, file=sys.stderr)
474
475 if user_usb != root_usb:
476 raise Exception("USB host devices differ for root and normal user")
477 if "<none>" in user_usb:
478 raise Exception("No USB host devices found")
479 '';
480
481 systemd-detect-virt = ''
482 create_vm_detectvirt()
483 vbm("startvm detectvirt")
484 wait_for_startup_detectvirt()
485 wait_for_vm_boot_detectvirt()
486 shutdown_vm_detectvirt()
487 result = machine.succeed(f"cat '{detectvirt_sharepath}/result'").strip()
488 destroy_vm_detectvirt()
489 if result != "oracle":
490 raise Exception(f'systemd-detect-virt returned "{result}" instead of "oracle"')
491 '';
492
493 net-hostonlyif = ''
494 create_vm_test1()
495 create_vm_test2()
496
497 vbm("startvm test1")
498 wait_for_startup_test1()
499 wait_for_vm_boot_test1()
500
501 vbm("startvm test2")
502 wait_for_startup_test2()
503 wait_for_vm_boot_test2()
504
505 machine.screenshot("net_booted")
506
507 test1_ip = wait_for_ip_test1(1)
508 test2_ip = wait_for_ip_test2(1)
509
510 machine.succeed(f"echo '{test2_ip}' | nc -N '{test1_ip}' 1234")
511 machine.succeed(f"echo '{test1_ip}' | nc -N '{test2_ip}' 1234")
512
513 machine.wait_until_succeeds(f"nc -N '{test1_ip}' 5678 < /dev/null >&2")
514 machine.wait_until_succeeds(f"nc -N '{test2_ip}' 5678 < /dev/null >&2")
515
516 shutdown_vm_test1()
517 shutdown_vm_test2()
518
519 destroy_vm_test1()
520 destroy_vm_test2()
521 '';
522} // (if enableUnfree then unfreeTests else {})