diff --git a/AGENTS.md b/AGENTS.md index cca01d0a..4ed264e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,20 @@ The Nix config in `nix/` is organized as hosts, profiles, and atomic modules. Ho Nix flake inputs and source files must be tracked by git to be evaluated; remember to `git add` new files before evaluation/checks. +### Environment variables + +- Every user on one machine: + - NixOS: use `environment.sessionVariables`. + - nix-darwin: use `environment.variables`. +- One user's login environment: use `home.sessionVariables`. +- One service: + - systemd: + - System service: use `systemd.services..environment`. System services are not available in Home Manager. + - User service: use `systemd.user.services..Service.Environment`. + - launchd: + - System daemon: use `launchd.daemons..serviceConfig.EnvironmentVariables`. System daemons are not available in Home Manager. + - User agent: use `launchd.agents..config.EnvironmentVariables`. + Style constraints: - If a Nix module needs no arguments, use a plain attribute set. Omit `{ ... }:` and `_:`. diff --git a/nix/hosts.nix b/nix/hosts.nix index 4eabcb81..9411fe6b 100644 --- a/nix/hosts.nix +++ b/nix/hosts.nix @@ -129,6 +129,7 @@ in cfg.modules.nixos.user-primary cfg.modules.nixos.agent-browser-shared cfg.modules.nixos.nvidia-graphics + cfg.modules.nixos.windows-vm cfg.modules.nixos.xremap cfg.modules.nixos.steam cfg.modules.nixos.prismlauncher @@ -163,10 +164,6 @@ in loader.systemd-boot = { enable = true; edk2-uefi-shell.enable = true; - windows."11-pro" = { - title = "Windows 11 Pro"; - efiDeviceHandle = "HD0d"; - }; }; loader.efi.canTouchEfiVariables = true; kernelPackages = pkgs.linuxPackages_latest; @@ -184,36 +181,6 @@ in "dmask=0077" ]; }; - "/mnt/windows" = { - device = "/dev/disk/by-uuid/0EA45F71A45F59F3"; - fsType = "ntfs3"; - options = [ - "rw" - "uid=1000" - "umask=022" - "iocharset=utf8" - "windows_names" - "nofail" - "x-systemd.automount" - "x-systemd.idle-timeout=10min" - ]; - }; - }; - # Samba keeps its own password database. After rebuilding, check for an - # existing login with `sudo pdbedit -L -u rafiq`; if absent, set one - # with `sudo smbpasswd -a rafiq` (the same login password can be used). - services.samba = { - enable = true; - openFirewall = true; - settings = { - global."security" = "user"; - windows-profile = { - path = "/mnt/windows/Users/rafiq"; - "valid users" = "rafiq"; - "guest ok" = "no"; - "read only" = "no"; - }; - }; }; } ) diff --git a/nix/modules/quickshell.nix b/nix/modules/quickshell.nix index 82fa150d..bd74f738 100644 --- a/nix/modules/quickshell.nix +++ b/nix/modules/quickshell.nix @@ -18,7 +18,7 @@ in cp -r ${../../quickshell/nemesis-shell} "$out" chmod -R u+w "$out" substituteInPlace "$out/BarHost.qml" \ - --replace-fail "@systemctl@" "${lib.getExe' pkgs.systemd "systemctl"}" + --replace-fail "@rebootToWindows@" "/run/current-system/sw/bin/reboot-to-windows" ''; in { diff --git a/nix/modules/windows-vm.nix b/nix/modules/windows-vm.nix new file mode 100644 index 00000000..4801778f --- /dev/null +++ b/nix/modules/windows-vm.nix @@ -0,0 +1,377 @@ +{ + config.flake.modules.nixos.windows-vm = + { + config, + lib, + pkgs, + primaryUser, + ... + }: + let + # The boot entry name is shared by systemd-boot and the reboot helper so + # the top bar cannot drift to a different firmware target. + windowsBootName = "11-pro"; + windowsBootEntry = "windows_${windowsBootName}.conf"; + # The serial-based path remains stable when NVMe discovery order changes. + windowsDisk = "/dev/disk/by-id/nvme-CT2000P3SSD8_2325E6E6888E"; + # Make the Windows driver bundle and standalone guest-agent installer + # available without a mutable download. Keep the CD small by omitting the + # package's duplicate per-version driver tree. + virtioWinIso = pkgs.runCommand "virtio-win.iso" { nativeBuildInputs = [ pkgs.xorriso ]; } '' + xorriso -as mkisofs -quiet -R -J -graft-points -o "$out" \ + virtio-win-guest-tools.exe=${pkgs.virtio-win}/virtio-win-guest-tools.exe \ + qemu-ga-x86_64.msi=${pkgs.virtio-win}/guest-agent/qemu-ga-x86_64.msi + ''; + # RDP is the normal desktop path. VNC remains a firmware/recovery console. + # Credentials stay interactive and never enter the Nix store. + zagreusRdp = pkgs.writeShellApplication { + name = "zagreus-rdp"; + runtimeInputs = [ pkgs.freerdp ]; + text = '' + exec xfreerdp \ + /v:10.10.0.96 \ + /u:rafiq \ + /d:zagreus \ + /cert:ignore \ + /f \ + /scale:180 \ + /audio-mode:redirect \ + /sound:sys:pulse \ + "$@" + ''; + }; + windowsDomain = pkgs.writeText "zagreus.xml" '' + + zagreus + 799a733f-d0ea-4f6f-aec4-76889cf6210c + 8 + 8 + 4 + + hvm + + + + + + + + + + + + + + + + + + + + + + + + destroy + restart + destroy + + /run/libvirt/nix-emulators/qemu-system-x86_64 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''; + # Both VM startup and native reboot use the same block ownership checks. + diskSafetyCheck = '' + windows_device=$(${lib.getExe' pkgs.coreutils "readlink"} -f ${windowsDisk}) + if [[ -z "$windows_device" || ! -b "$windows_device" ]]; then + echo "Windows disk is unavailable" >&2 + exit 1 + fi + + if ${lib.getExe' pkgs.util-linux "findmnt"} --noheadings --source "$windows_device" >/dev/null; then + echo "Windows disk is mounted" >&2 + exit 1 + fi + for partition in "$windows_device"p*; do + if [[ -e "$partition" ]] && ${lib.getExe' pkgs.util-linux "findmnt"} --noheadings --source "$partition" >/dev/null; then + echo "Windows partition $partition is mounted" >&2 + exit 1 + fi + done + + if ${pkgs.findutils}/bin/find "/sys/class/block/$(${lib.getExe' pkgs.coreutils "basename"} "$windows_device")/holders" -mindepth 1 -maxdepth 1 | ${lib.getExe pkgs.gnugrep} -q .; then + echo "Windows disk still has kernel holders" >&2 + exit 1 + fi + ''; + ownershipHook = pkgs.writeShellScript "zagreus-disk-ownership" '' + if [[ "$1" != "zagreus" || "$2" != "prepare" || "$3" != "begin" ]]; then + exit 0 + fi + ${diskSafetyCheck} + ''; + # Only this immutable helper receives passwordless sudo. It refuses to + # arm native boot until libvirt and the block layer both report safety. + rebootToWindowsRoot = pkgs.writeShellApplication { + name = "reboot-to-windows-root"; + runtimeInputs = [ + config.virtualisation.libvirtd.package + pkgs.coreutils + pkgs.findutils + pkgs.glibc.bin + pkgs.gnugrep + pkgs.jq + pkgs.procps + pkgs.psmisc + pkgs.systemd + pkgs.util-linux + ]; + text = '' + mode="''${1:---reboot}" + if [[ "$mode" != "--reboot" && "$mode" != "--check" && "$mode" != "--prepare-only" ]]; then + echo "usage: reboot-to-windows-root [--reboot|--check|--prepare-only]" >&2 + exit 2 + fi + + exec 9>/run/reboot-to-windows.lock + if ! flock --nonblock 9; then + echo "Another Windows reboot request is already running" >&2 + exit 1 + fi + + state=$(virsh -c qemu:///system domstate zagreus 2>/dev/null || true) + if [[ "$state" != "shut off" ]]; then + echo "Shut down the zagreus VM first; current state: ''${state:-unknown}" >&2 + exit 1 + fi + if pgrep --full -- 'qemu-system-x86_64 .*guest=zagreus' >/dev/null; then + echo "The zagreus QEMU process is still running" >&2 + exit 1 + fi + + ${diskSafetyCheck} + devices=("$windows_device") + for partition in "$windows_device"p*; do + [[ -e "$partition" ]] && devices+=("$partition") + done + if fuser --silent "''${devices[@]}"; then + echo "A process still has the Windows disk open" >&2 + exit 1 + fi + + if ! bootctl --variables=yes list --json=short | jq --exit-status --arg entry ${lib.escapeShellArg windowsBootEntry} 'any(.[]; .id == $entry)' >/dev/null; then + echo "Windows boot entry ${windowsBootEntry} is unavailable" >&2 + exit 1 + fi + + if [[ "$mode" == "--check" ]]; then + echo "Ready to reboot into Windows 11 Pro" + exit 0 + fi + + read_oneshot() { + for variable in /sys/firmware/efi/efivars/LoaderEntryOneShot-*; do + [[ -e "$variable" ]] || continue + dd if="$variable" bs=1 skip=4 status=none | iconv -f UTF-16LE -t UTF-8 | tr -d '\0' + return + done + } + clear_oneshot() { + bootctl --variables=yes set-oneshot "" + } + + bootctl --variables=yes set-oneshot ${lib.escapeShellArg windowsBootEntry} + actual=$(read_oneshot) + if [[ "$actual" != ${lib.escapeShellArg windowsBootEntry} ]]; then + clear_oneshot + echo "Could not verify the Windows one-shot boot entry" >&2 + exit 1 + fi + + if [[ "$mode" == "--prepare-only" ]]; then + clear_oneshot + if [[ -n "$(read_oneshot)" ]]; then + echo "Could not clear the Windows one-shot boot entry" >&2 + exit 1 + fi + echo "Set, verified and cleared the Windows one-shot boot entry" + exit 0 + fi + + sync + if ! systemctl reboot; then + clear_oneshot + echo "Reboot failed; cleared the Windows one-shot boot entry" >&2 + exit 1 + fi + ''; + }; + # The user-facing wrapper turns safety failures into a desktop + # notification while keeping EFI and reboot operations inside sudo. + rebootToWindows = pkgs.writeShellApplication { + name = "reboot-to-windows"; + runtimeInputs = [ pkgs.libnotify ]; + text = '' + mode="''${1:---reboot}" + if ! output=$(/run/wrappers/bin/sudo ${lib.getExe rebootToWindowsRoot} "$mode" 2>&1); then + notify-send --urgency=critical "Cannot reboot to Windows" "$output" + printf '%s\n' "$output" >&2 + exit 1 + fi + [[ -n "$output" ]] && printf '%s\n' "$output" + ''; + }; + in + { + boot.loader.systemd-boot.windows.${windowsBootName} = { + title = "Windows 11 Pro"; + efiDeviceHandle = "HD0d"; + }; + environment = { + sessionVariables.LIBVIRT_DEFAULT_URI = "qemu:///system"; + systemPackages = [ + pkgs.cifs-utils + rebootToWindows + zagreusRdp + ]; + }; + networking = { + firewall.allowedTCPPorts = [ 5900 ]; + networkmanager = { + settings.main.no-auto-default = "enp12s0"; + # The host keeps its existing MAC and DHCP identity on the bridge; + # enp12s0 becomes a Layer 2 port shared with the VM tap device. + ensureProfiles.profiles = { + lan-bridge = { + connection = { + id = "lan-bridge"; + uuid = "90f94c60-a8ea-49c4-8867-12b7ec620f77"; + type = "bridge"; + interface-name = "br0"; + autoconnect = true; + autoconnect-priority = 100; + }; + bridge = { + stp = false; + mac-address = "74:56:3C:35:AB:E5"; + }; + ipv4.method = "auto"; + ipv6 = { + method = "auto"; + addr-gen-mode = "stable-privacy"; + }; + }; + lan-bridge-port = { + connection = { + id = "lan-bridge-port"; + uuid = "19af3f2e-63f4-47ff-a4a7-91eb30648b22"; + type = "ethernet"; + interface-name = "enp12s0"; + autoconnect = true; + controller = "br0"; + port-type = "bridge"; + }; + ethernet.mac-address = "74:56:3C:35:AB:E5"; + }; + }; + }; + }; + # QEMU is deliberately unprivileged. Grant only its service group access + # to the dedicated Windows disk instead of running the whole VM as root. + services.udev.extraRules = '' + ACTION=="add|change", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", ENV{ID_SERIAL_SHORT}=="2325E6E6888E", GROUP="qemu-libvirtd", MODE="0660" + ''; + security.sudo.extraRules = [ + { + users = [ primaryUser.name ]; + commands = [ + { + command = lib.getExe rebootToWindowsRoot; + options = [ "NOPASSWD" ]; + } + ]; + } + ]; + users.users.${primaryUser.name}.extraGroups = [ "libvirtd" ]; + + virtualisation.libvirtd = { + enable = true; + onBoot = "ignore"; + onShutdown = "shutdown"; + qemu.runAsRoot = false; + # The hook runs for every libvirt start path and rejects concurrent + # Linux ownership before QEMU is allowed to open the physical disk. + hooks.qemu.zagreus-disk-ownership = ownershipHook; + }; + + systemd.services = { + # Keep the XML declarative while leaving VM start/stop under explicit + # user control. Redefining an active domain only changes its next boot. + windows-vm-define = { + description = "Define the zagreus Windows VM"; + wantedBy = [ "multi-user.target" ]; + requires = [ + "libvirtd-config.service" + "libvirtd.service" + ]; + after = [ + "libvirtd-config.service" + "libvirtd.service" + ]; + path = [ + config.virtualisation.libvirtd.package + pkgs.coreutils + pkgs.gawk + pkgs.gnugrep + ]; + script = '' + virt-xml-validate ${windowsDomain} + virsh -c qemu:///system define ${windowsDomain} + ''; + serviceConfig.Type = "oneshot"; + }; + }; + }; +} diff --git a/quickshell/nemesis-shell/BarHost.qml b/quickshell/nemesis-shell/BarHost.qml index 3061b46b..df0af0f3 100644 --- a/quickshell/nemesis-shell/BarHost.qml +++ b/quickshell/nemesis-shell/BarHost.qml @@ -213,7 +213,7 @@ Scope { cursorShape: Qt.PointingHandCursor onClicked: { root.powerMenuOpen = false; - Quickshell.execDetached(["/run/wrappers/bin/sudo", "@systemctl@", "reboot", "--boot-loader-entry", "windows_11-pro.conf"]); + Quickshell.execDetached(["@rebootToWindows@"]); } }