diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 7a84e2620f82..21f421abfece 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -177,6 +177,7 @@
./programs/tmux.nix
./programs/traceroute.nix
./programs/tsm-client.nix
+ ./programs/turbovnc.nix
./programs/udevil.nix
./programs/usbtop.nix
./programs/vim.nix
diff --git a/nixos/modules/programs/turbovnc.nix b/nixos/modules/programs/turbovnc.nix
new file mode 100644
index 000000000000..e6f8836aa367
--- /dev/null
+++ b/nixos/modules/programs/turbovnc.nix
@@ -0,0 +1,54 @@
+# Global configuration for the SSH client.
+
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+ cfg = config.programs.turbovnc;
+in
+{
+ options = {
+
+ programs.turbovnc = {
+
+ ensureHeadlessSoftwareOpenGL = mkOption {
+ type = types.bool;
+ default = false;
+ description = ''
+ Whether to set up NixOS such that TurboVNC's built-in software OpenGL
+ implementation works.
+
+ This will enable so that OpenGL
+ programs can find Mesa's llvmpipe drivers.
+
+ Setting this option to false
does not mean that software
+ OpenGL won't work; it may still work depending on your system
+ configuration.
+
+ This option is also intended to generate warnings if you are using some
+ configuration that's incompatible with using headless software OpenGL
+ in TurboVNC.
+ '';
+ };
+
+ };
+
+ };
+
+ config = mkIf cfg.ensureHeadlessSoftwareOpenGL {
+
+ # TurboVNC has builtin support for Mesa llvmpipe's `swrast`
+ # software rendering to implemnt GLX (OpenGL on Xorg).
+ # However, just building TurboVNC with support for that is not enough
+ # (it only takes care of the X server side part of OpenGL);
+ # the indiviudual applications (e.g. `glxgears`) also need to directly load
+ # the OpenGL libs.
+ # Thus, this creates `/run/opengl-driver` populated by Mesa so that the applications
+ # can find the llvmpipe `swrast.so` software rendering DRI lib via `libglvnd`.
+ # This comment exists to explain why `hardware.` is involved,
+ # even though 100% software rendering is used.
+ hardware.opengl.enable = true;
+
+ };
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 251f24a9a089..3ce71b0abe6d 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -408,6 +408,7 @@ in
trickster = handleTest ./trickster.nix {};
trilium-server = handleTestOn ["x86_64-linux"] ./trilium-server.nix {};
tuptime = handleTest ./tuptime.nix {};
+ turbovnc-headless-server = handleTest ./turbovnc-headless-server.nix {};
ucg = handleTest ./ucg.nix {};
udisks2 = handleTest ./udisks2.nix {};
unbound = handleTest ./unbound.nix {};
diff --git a/nixos/tests/turbovnc-headless-server.nix b/nixos/tests/turbovnc-headless-server.nix
new file mode 100644
index 000000000000..35da9a53d2db
--- /dev/null
+++ b/nixos/tests/turbovnc-headless-server.nix
@@ -0,0 +1,171 @@
+import ./make-test-python.nix ({ pkgs, lib, ... }: {
+ name = "turbovnc-headless-server";
+ meta = {
+ maintainers = with lib.maintainers; [ nh2 ];
+ };
+
+ machine = { pkgs, ... }: {
+
+ environment.systemPackages = with pkgs; [
+ glxinfo
+ procps # for `pkill`, `pidof` in the test
+ scrot # for screenshotting Xorg
+ turbovnc
+ ];
+
+ programs.turbovnc.ensureHeadlessSoftwareOpenGL = true;
+
+ networking.firewall = {
+ # Reject instead of drop, for failures instead of hangs.
+ rejectPackets = true;
+ allowedTCPPorts = [
+ 5900 # VNC :0, for seeing what's going on in the server
+ ];
+ };
+
+ # So that we can ssh into the VM, see e.g.
+ # http://blog.patapon.info/nixos-local-vm/#accessing-the-vm-with-ssh
+ services.openssh.enable = true;
+ services.openssh.permitRootLogin = "yes";
+ users.extraUsers.root.password = "";
+ users.mutableUsers = false;
+ };
+
+ testScript = ''
+ def wait_until_terminated_or_succeeds(
+ termination_check_shell_command,
+ success_check_shell_command,
+ get_detail_message_fn,
+ retries=60,
+ retry_sleep=0.5,
+ ):
+ def check_success():
+ command_exit_code, _output = machine.execute(success_check_shell_command)
+ return command_exit_code == 0
+
+ for _ in range(retries):
+ exit_check_exit_code, _output = machine.execute(termination_check_shell_command)
+ is_terminated = exit_check_exit_code != 0
+ if is_terminated:
+ if check_success():
+ return
+ else:
+ details = get_detail_message_fn()
+ raise Exception(
+ f"termination check ({termination_check_shell_command}) triggered without command succeeding ({success_check_shell_command}); details: {details}"
+ )
+ else:
+ if check_success():
+ return
+ time.sleep(retry_sleep)
+
+ if not check_success():
+ details = get_detail_message_fn()
+ raise Exception(
+ f"action timed out ({success_check_shell_command}); details: {details}"
+ )
+
+
+ # Below we use the pattern:
+ # (cmd | tee stdout.log) 3>&1 1>&2 2>&3 | tee stderr.log
+ # to capture both stderr and stdout while also teeing them, see:
+ # https://unix.stackexchange.com/questions/6430/how-to-redirect-stderr-and-stdout-to-different-files-and-also-display-in-termina/6431#6431
+
+
+ # Starts headless VNC server, backgrounding it.
+ def start_xvnc():
+ xvnc_command = " ".join(
+ [
+ "Xvnc",
+ ":0",
+ "-iglx",
+ "-auth /root/.Xauthority",
+ "-geometry 1240x900",
+ "-depth 24",
+ "-rfbwait 5000",
+ "-deferupdate 1",
+ "-verbose",
+ "-securitytypes none",
+ # We don't enforce localhost listening such that we
+ # can connect from outside the VM using
+ # env QEMU_NET_OPTS=hostfwd=tcp::5900-:5900 $(nix-build nixos/tests/turbovnc-headless-server.nix -A driver)/bin/nixos-test-driver
+ # for testing purposes, and so that we can in the future
+ # add another test case that connects the TurboVNC client.
+ # "-localhost",
+ ]
+ )
+ machine.execute(
+ # Note trailing & for backgrounding.
+ f"({xvnc_command} | tee /tmp/Xvnc.stdout) 3>&1 1>&2 2>&3 | tee /tmp/Xvnc.stderr &",
+ )
+
+
+ # Waits until the server log message that tells us that GLX is ready
+ # (requires `-verbose` above), avoiding screenshoting racing below.
+ def wait_until_xvnc_glx_ready():
+ machine.wait_until_succeeds("test -f /tmp/Xvnc.stderr")
+ wait_until_terminated_or_succeeds(
+ termination_check_shell_command="pidof Xvnc",
+ success_check_shell_command="grep 'GLX: Initialized DRISWRAST' /tmp/Xvnc.stderr",
+ get_detail_message_fn=lambda: "Contents of /tmp/Xvnc.stderr:\n"
+ + machine.succeed("cat /tmp/Xvnc.stderr"),
+ )
+
+
+ # Checks that we detect glxgears failing when
+ # `LIBGL_DRIVERS_PATH=/nonexistent` is set
+ # (in which case software rendering should not work).
+ def test_glxgears_failing_with_bad_driver_path():
+ machine.execute(
+ # Note trailing & for backgrounding.
+ "(env DISPLAY=:0 LIBGL_DRIVERS_PATH=/nonexistent glxgears -info | tee /tmp/glxgears-should-fail.stdout) 3>&1 1>&2 2>&3 | tee /tmp/glxgears-should-fail.stderr &"
+ )
+ machine.wait_until_succeeds("test -f /tmp/glxgears-should-fail.stderr")
+ wait_until_terminated_or_succeeds(
+ termination_check_shell_command="pidof glxgears",
+ success_check_shell_command="grep 'libGL error: failed to load driver: swrast' /tmp/glxgears-should-fail.stderr",
+ get_detail_message_fn=lambda: "Contents of /tmp/glxgears-should-fail.stderr:\n"
+ + machine.succeed("cat /tmp/glxgears-should-fail.stderr"),
+ )
+ machine.wait_until_fails("pidof glxgears")
+
+
+ # Starts glxgears, backgrounding it. Waits until it prints the `GL_RENDERER`.
+ # Does not quit glxgears.
+ def test_glxgears_prints_renderer():
+ machine.execute(
+ # Note trailing & for backgrounding.
+ "(env DISPLAY=:0 glxgears -info | tee /tmp/glxgears.stdout) 3>&1 1>&2 2>&3 | tee /tmp/glxgears.stderr &"
+ )
+ machine.wait_until_succeeds("test -f /tmp/glxgears.stderr")
+ wait_until_terminated_or_succeeds(
+ termination_check_shell_command="pidof glxgears",
+ success_check_shell_command="grep 'GL_RENDERER' /tmp/glxgears.stdout",
+ get_detail_message_fn=lambda: "Contents of /tmp/glxgears.stderr:\n"
+ + machine.succeed("cat /tmp/glxgears.stderr"),
+ )
+
+
+ with subtest("Start Xvnc"):
+ start_xvnc()
+ wait_until_xvnc_glx_ready()
+
+ with subtest("Ensure bad driver path makes glxgears fail"):
+ test_glxgears_failing_with_bad_driver_path()
+
+ with subtest("Run 3D application (glxgears)"):
+ test_glxgears_prints_renderer()
+
+ # Take screenshot; should display the glxgears.
+ machine.succeed("scrot --display :0 /tmp/glxgears.png")
+
+ # Copy files down.
+ machine.copy_from_vm("/tmp/glxgears.png")
+ machine.copy_from_vm("/tmp/glxgears.stdout")
+ machine.copy_from_vm("/tmp/glxgears-should-fail.stdout")
+ machine.copy_from_vm("/tmp/glxgears-should-fail.stderr")
+ machine.copy_from_vm("/tmp/Xvnc.stdout")
+ machine.copy_from_vm("/tmp/Xvnc.stderr")
+ '';
+
+})
diff --git a/pkgs/tools/admin/turbovnc/default.nix b/pkgs/tools/admin/turbovnc/default.nix
index 16ae53d25b91..33d248ffde88 100644
--- a/pkgs/tools/admin/turbovnc/default.nix
+++ b/pkgs/tools/admin/turbovnc/default.nix
@@ -1,6 +1,7 @@
{ lib
, stdenv
, fetchFromGitHub
+, nixosTests
# Dependencies
, cmake
@@ -101,6 +102,8 @@ stdenv.mkDerivation rec {
--prefix PATH : ${lib.makeBinPath [ openssh ]}
'';
+ passthru.tests.turbovnc-headless-server = nixosTests.turbovnc-headless-server;
+
meta = {
homepage = "https://turbovnc.org/";
license = lib.licenses.gpl2Plus;