From ddd96164fb6eb013edee367d2741ce21a29b9fc3 Mon Sep 17 00:00:00 2001 From: Tomblarom <12703270+Tomblarom@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:09:27 +0100 Subject: [PATCH 1/2] feat(vm): add Debian Sunshine and Moonlight VMs --- .../scripts/vm-game-streaming-test.py | 279 ++++++++ .../workflows/scripts/vm-lifecycle-test.sh | 72 ++- .github/workflows/vm-wizard.yml | 4 + json/moonlight-vm.json | 56 ++ json/sunshine-vm.json | 56 ++ vm/moonlight-vm.sh | 477 ++++++++++++++ vm/sunshine-vm.sh | 604 ++++++++++++++++++ 7 files changed, 1544 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/scripts/vm-game-streaming-test.py create mode 100644 json/moonlight-vm.json create mode 100644 json/sunshine-vm.json create mode 100644 vm/moonlight-vm.sh create mode 100644 vm/sunshine-vm.sh diff --git a/.github/workflows/scripts/vm-game-streaming-test.py b/.github/workflows/scripts/vm-game-streaming-test.py new file mode 100644 index 0000000000..2fa85fad98 --- /dev/null +++ b/.github/workflows/scripts/vm-game-streaming-test.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Exercise device selection against synthetic sysfs and Proxmox configuration.""" + +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPTS = ("sunshine-vm", "moonlight-vm") + + +class PassthroughTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.pci = self.root / "pci" + self.usb = self.root / "usb" + self.config = self.root / "config" + for path in (self.pci, self.usb, self.config): + path.mkdir() + self.gpu() + self.gpu("0000:01:00.1", "0x040300") + self.usb_device() + + def gpu(self, address="0000:01:00.0", pci_class="0x030000", driver="vfio-pci", group="7"): + device = self.pci / address + device.mkdir(exist_ok=True) + (device / "class").write_text(pci_class) + (device / "boot_vga").write_text("0") + drivers = self.root / "drivers" / driver + drivers.mkdir(parents=True, exist_ok=True) + if (device / "driver").is_symlink(): + (device / "driver").unlink() + (device / "driver").symlink_to(drivers) + group_path = self.root / "groups" / group + (group_path / "devices").mkdir(parents=True, exist_ok=True) + (device / "iommu_group").symlink_to(group_path) + (group_path / "devices" / address).symlink_to(device) + return device + + def usb_device(self, port="1-2", usb_class="00", interface_class="03"): + device = self.usb / port + device.mkdir(exist_ok=True) + for name, value in {"idVendor": "1234", "idProduct": "5678", "bDeviceClass": usb_class, "product": "Test controller"}.items(): + (device / name).write_text(value) + interface = self.usb / f"{port}:1.0" + interface.mkdir(exist_ok=True) + (interface / "bInterfaceClass").write_text(interface_class) + + def run_case(self, slug, body="select_passthrough && attach_passthrough", **env): + source = (ROOT / "vm" / f"{slug}.sh").read_text() + functions = [] + for name in ("validate_gpu_passthrough", "validate_usb_passthrough", "select_passthrough", "attach_passthrough"): + match = re.search(rf"^function {name}\(\) \{{\n.*?^\}}", source, re.M | re.S) + self.assertIsNotNone(match, name) + functions.append(match.group()) + code = "\n".join(functions) + code = code.replace("/sys/bus/pci/devices", str(self.pci)) + code = code.replace("/sys/bus/usb/devices", str(self.usb)) + code = code.replace("/etc/pve/qemu-server", str(self.config)) + preamble = r''' +set -Eeuo pipefail +msg_error() { echo "ERROR: $*" >&2; } +qm() { echo "qm $*" >>"$EVENTS"; } +lspci() { echo "Synthetic GPU $*"; } +exit_script() { exit 130; } +vm_dialog() { + echo "dialog $*" >&2 + case "$2" in + "GPU PASSTHROUGH") + [[ "${CANCEL:-}" != gpu ]] || return 1 + VM_DIALOG_RESULT="${PICK_GPU:-none}" + ;; + "USB PASSTHROUGH") + [[ "${CANCEL:-}" != usb ]] || return 1 + VM_DIALOG_RESULT="${PICK_USB:-none}" + ;; + "CONFIRM DEVICE ASSIGNMENT") [[ "${CANCEL:-}" != confirm ]] || return 1 ;; + *) return 2 ;; + esac +} +''' + events = self.root / "events" + events.unlink(missing_ok=True) + variables = {"PATH": os.environ["PATH"], "EVENTS": str(events), "NSAPP": slug, "VMID": "100", "VM_UNATTENDED": "1", "VM_PASSTHROUGH_CONFIRM": "yes", **env} + result = subprocess.run(["bash", "-c", preamble + code + "\n" + body], env=variables, text=True, capture_output=True, timeout=10) + if events.exists(): + result.stdout += events.read_text() + return result + + def assert_rejected(self, **env): + for slug in SCRIPTS: + with self.subTest(slug=slug, env=env): + result = self.run_case(slug, VM_GPU_PCI="0000:01:00.0", **env) + self.assertNotEqual(result.returncode, 0, result.stderr) + self.assertNotIn("qm ", result.stdout) + + def test_no_automatic_assignment(self): + for slug in SCRIPTS: + result = self.run_case(slug) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("qm ", result.stdout) + + def test_gpu_slot_contains_gpu_and_audio(self): + for slug in SCRIPTS: + result = self.run_case(slug, VM_GPU_PCI="01:00.0") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "qm set 100 --hostpci0 0000:01:00,pcie=1 --vga none") + + def test_unattended_requires_confirmation(self): + self.assert_rejected(VM_PASSTHROUGH_CONFIRM="no") + + def test_missing_iommu(self): + (self.pci / "0000:01:00.0" / "iommu_group").unlink() + self.assert_rejected() + + def test_unrelated_iommu_device(self): + self.gpu("0000:02:00.0", "0x010802") + self.assert_rejected() + + def test_bridge_in_group_is_allowed_but_not_attached(self): + self.gpu("0000:00:01.0", "0x060400", driver="pcieport") + for slug in SCRIPTS: + result = self.run_case(slug, VM_GPU_PCI="01:00.0") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("0000:00:01", result.stdout) + + def test_host_driver_is_not_unbound(self): + device = self.pci / "0000:01:00.1" / "driver" + device.unlink() + device.symlink_to(self.root / "drivers" / "snd_hda_intel") + self.assert_rejected() + + def test_boot_gpu_is_not_taken(self): + (self.pci / "0000:01:00.0" / "boot_vga").write_text("1") + self.assert_rejected() + + def test_existing_assignment_forms(self): + for value in ("0000:01:00.0,pcie=1", "01:00,pcie=1", "host=01:00.1", "03:00.0;01:00.1,pcie=1", "mapping=render-gpu"): + with self.subTest(value=value): + (self.config / "101.conf").write_text(f"hostpci0: {value}\n") + self.assert_rejected() + + def test_invalid_gpu_values(self): + for slug in SCRIPTS: + for address in ("../../etc/passwd", "01:00", "0000:01:00.1", "01:00.8", "01:00.0,pcie=1"): + result = self.run_case(slug, VM_GPU_PCI=address) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("qm ", result.stdout) + + def test_gpu_ownership_is_checked_again_before_attachment(self): + for slug in SCRIPTS: + config = self.config / "102.conf" + config.unlink(missing_ok=True) + result = self.run_case(slug, body=f'select_passthrough && echo "hostpci0: 01:00" > "{config}" && attach_passthrough', VM_GPU_PCI="01:00.0") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("qm ", result.stdout) + + def test_usb_physical_port_assignment(self): + result = self.run_case("moonlight-vm", VM_USB_PORTS="1-2") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "qm set 100 --usb0 host=1-2,usb3=1") + + def test_usb_invalid_or_duplicate(self): + for value in ("1-2,1-2", "1-9", "usb1", "1-2,", ",1-2", "1-2,,1-3", "1-2,usb3=1"): + result = self.run_case("moonlight-vm", VM_USB_PORTS=value) + self.assertNotEqual(result.returncode, 0, value) + self.assertNotIn("qm ", result.stdout) + + def test_usb_storage_network_and_hubs_are_rejected(self): + for usb_class in ("08", "09", "02", "0a"): + (self.usb / "1-2:1.0" / "bInterfaceClass").write_text(usb_class) + result = self.run_case("moonlight-vm", VM_USB_PORTS="1-2") + self.assertNotEqual(result.returncode, 0, usb_class) + self.assertNotIn("qm ", result.stdout) + + def test_usb_existing_assignment(self): + for value in ("host=1-2,usb3=1", "host=1-2", "host=1234:5678", "mapping=controller"): + (self.config / "101.conf").write_text(f"usb0: {value}\n") + result = self.run_case("moonlight-vm", VM_USB_PORTS="1-2") + self.assertNotEqual(result.returncode, 0, value) + self.assertNotIn("qm ", result.stdout) + + def test_usb_similar_port_is_not_a_conflict(self): + (self.config / "101.conf").write_text("usb0: host=1-20,usb3=1\n") + result = self.run_case("moonlight-vm", VM_USB_PORTS="1-2") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_usb_requires_unattended_confirmation(self): + result = self.run_case("moonlight-vm", VM_USB_PORTS="1-2", VM_PASSTHROUGH_CONFIRM="no") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("qm ", result.stdout) + + def test_interactive_default_and_cancellation(self): + for slug in SCRIPTS: + result = self.run_case(slug, VM_UNATTENDED="0") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("qm ", result.stdout) + self.assertIn("none Configure GPU later (no hardware acceleration promised) ON", result.stderr) + for cancel in (("gpu", "confirm", "usb") if slug == "moonlight-vm" else ("gpu", "confirm")): + result = self.run_case(slug, VM_UNATTENDED="0", CANCEL=cancel, PICK_GPU="01:00.0") + self.assertEqual(result.returncode, 130, result.stderr) + self.assertNotIn("qm ", result.stdout) + + +class FirstBootTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + + def render(self, slug): + source = (ROOT / "vm" / f"{slug}.sh").read_text() + match = re.search(r'cat >"\$(SUNSHINE_FIRSTBOOT_TMP|MOONLIGHT_SETUP_TMP)" <\n2: vmbr1: \n'; } whiptail() { echo "Unexpected interactive dialog: $*" >&2; return 2; } vm_prompt_cloud_init() { - USE_CLOUD_INIT=yes CLOUDINIT_USER="$1" CLOUDINIT_PASSWORD=test-password + USE_CLOUD_INIT=yes CLOUDINIT_USER="${CLOUDINIT_USER:-$1}" CLOUDINIT_PASSWORD=test-password CLOUDINIT_NETWORK_MODE=dhcp if [[ "$FAILURE" == ssh ]]; then CLOUDINIT_SSH_KEYS="$CASE_DIR/keys.pub" @@ -80,11 +80,21 @@ vm_release_asset() { IceWhaleTech/ZimaOS) tag="1.8.0-beta2" asset="zimaos-x86_64-1.8.0-beta2_installer.iso" ;; home-assistant/operating-system) tag="${4:-16.0}" asset="haos_generic-aarch64-${4:-16.0}.qcow2.xz" ;; derailed/k9s) tag="v0.50.0" asset="k9s_Linux_amd64.tar.gz" ;; + LizardByte/Sunshine) tag="v2026.914.233613" asset="sunshine_2026.914.233613-1+debiantrixie_amd64.deb" ;; + moonlight-stream/moonlight-qt) tag="v6.2.0" asset="Moonlight-6.2.0-x86_64.AppImage" ;; + mmatyas/pegasus-frontend) tag="continuous" asset="pegasus-frontend_0.16.109_amd64.deb" ;; *) echo "Unexpected release repository: $2" >&2; return 2 ;; esac [[ "$asset" =~ $3 ]] || { echo "Release pattern $3 does not match $asset" >&2; return 2; } VM_RELEASE_TAG="$tag" VM_RELEASE_VERSION="${tag#v}" VM_RELEASE_ASSET="$asset" VM_RELEASE_URL="https://example.invalid/$asset" VM_RELEASE_SHA256="" + case "$2" in + LizardByte/Sunshine | moonlight-stream/moonlight-qt | mmatyas/pegasus-frontend) + VM_RELEASE_SHA256="$(printf 'a%.0s' {1..64})" + ;; + esac + [[ "$FAILURE" != digest ]] || VM_RELEASE_SHA256="" + return 0 } setup_cloud_init() { echo "provision $*" >>"$EVENTS" @@ -117,7 +127,11 @@ vm_image_cache_path() { if [[ "$FAILURE" == cache ]]; then (exit 7); fi echo "$CASE_DIR/cache-$(basename "$1")" } -vm_fetch_image() { printf 'mock-image' >"$2"; echo "fetch $1" >>"$EVENTS"; } +vm_fetch_image() { + if [[ "$FAILURE" == download && "$1" == https://example.invalid/* ]]; then return 7; fi + printf 'mock-image' >"$2" + echo "fetch $1" >>"$EVENTS" +} vm_extract_image() { local target="${2:-$CASE_DIR/extracted-$(basename "${1%.*}")}" printf 'mock-image' >"$target" @@ -133,6 +147,21 @@ set_description() { } virt-customize() { echo "customize $*" >>"$EVENTS" + local argument previous="" upload + for argument in "$@"; do + if [[ "$previous" == --upload && "$argument" == *:/usr/local/sbin/* ]]; then + upload="${argument%%:*}" + bash -n "$upload" || return 1 + cp "$upload" "$CASE_DIR/firstboot.sh" + fi + if [[ "$previous" == --upload && "$argument" == *:/etc/systemd/system/*.service ]]; then + upload="${argument%%:*}" + if grep -q 'After=.*cloud-final.service' "$upload"; then + echo "firstboot-after-cloud-final" >>"$EVENTS" + fi + fi + previous="$argument" + done [[ "$FAILURE" != customize ]] || return 7 } qemu-img() { echo "image $*" >>"$EVENTS"; } @@ -199,7 +228,7 @@ curl() { printf '\nsource %q\n' "$MOCK" return ;; - */home-assistant/version/*) payload='{"ova":"16.0"}' ;; + */home-assistant/version/*) payload='{"ova":"16.0","hassos":{"generic-aarch64":"16.0"}}' ;; *blissos-x86*) payload='Bliss-v16.9.7-x86_64-OFFICIAL-foss-20261001.iso' ;; *cachyos-arch*) payload='desktop/261001/' ;; *api.umbrel.com*) payload='{"version":"1.7.4"}' ;; @@ -279,6 +308,13 @@ for script in "$ROOT"/vm/*.sh; do imported="$(grep -nE '^qm (importdisk|disk import) [0-9]' "$EVENTS" | cut -d: -f1)" ((last_custom < imported)) || fail "K3s customized after import" fi + case "$slug" in + sunshine-vm | moonlight-vm) + [[ -s "$CASE_DIR/firstboot.sh" ]] || fail "$slug did not stage a parseable first-boot installer" + grep -q '^firstboot-after-cloud-final$' "$EVENTS" || fail "$slug first boot is not ordered after Cloud-Init" + ! grep -Eq 'qm set .*--(hostpci|usb)[0-9]' "$EVENTS" || fail "$slug assigned devices without an explicit selection" + ;; + esac ! grep -q '10\.88\.0\.1' "$CASE_DIR/output" || fail "$slug reported a container bridge address as the VM IP" if awk '$1 == "ip-lookup" && NF < 4' "$EVENTS" | grep -q .; then fail "$slug looked up the IP without the VM's MAC" @@ -292,7 +328,7 @@ for script in "$ROOT"/vm/*.sh; do done failures=(description start cache) case "$slug" in - k3s-vm | netbird-server | ubuntu-vm | unifi-os-server-vm | waydroid-vm) failures+=(ssh) ;; + k3s-vm | netbird-server | ubuntu-vm | unifi-os-server-vm | waydroid-vm | sunshine-vm | moonlight-vm) failures+=(ssh) ;; esac case "$slug" in blissos-vm | cachyos-vm | nextcloud-vm | owncloud-vm | umbrel-os-vm | zimaos-vm) ;; @@ -322,6 +358,34 @@ for script in "$ROOT"/vm/*.sh; do echo "PASS $slug $failure failure does not report success" failures_checked=$((failures_checked + 1)) done + case "$slug" in + sunshine-vm | moonlight-vm) + for invalid in gpu machine user digest download customize; do + CASE_DIR="$TEST_DIR/$slug-invalid-$invalid" + mkdir "$CASE_DIR" + EVENTS="$CASE_DIR/events" + export CASE_DIR EVENTS + overrides=() + case "$invalid" in + gpu) overrides=(VM_GPU_PCI=not-a-pci-address) ;; + machine) overrides=(VM_MACHINE=i440fx) ;; + user) overrides=(CLOUDINIT_USER=root) ;; + digest) overrides=(FAILURE=digest) ;; + download) overrides=(FAILURE=download) ;; + customize) overrides=(FAILURE=customize) ;; + esac + rc=0 + env BASH_ENV="$MOCK" VM_UNATTENDED=1 VM_START=no VM_ARCH=amd64 \ + STORAGE_KIND=dir FAILURE=none "${overrides[@]}" \ + bash "$script" >"$CASE_DIR/output" 2>&1 || rc=$? + [[ "$rc" != 0 ]] || fail "$slug accepted invalid $invalid" + ! grep -q '^qm create ' "$EVENTS" || fail "$slug created a VM despite invalid $invalid" + ! grep -q '^api done ' "$EVENTS" || fail "$slug reported completion despite invalid $invalid" + failures_checked=$((failures_checked + 1)) + echo "PASS $slug rejects invalid $invalid before creation" + done + ;; + esac checked=$((checked + 1)) done echo "All $checked VM scripts: $runs_checked complete mocked runs and $failures_checked failure cases passed." diff --git a/.github/workflows/vm-wizard.yml b/.github/workflows/vm-wizard.yml index 5f1a5308ad..6ecdb08a35 100644 --- a/.github/workflows/vm-wizard.yml +++ b/.github/workflows/vm-wizard.yml @@ -6,11 +6,13 @@ on: paths: - "vm/**.sh" - ".github/workflows/scripts/vm-*.sh" + - ".github/workflows/scripts/vm-game-streaming-test.py" - ".github/workflows/vm-wizard.yml" pull_request: paths: - "vm/**.sh" - ".github/workflows/scripts/vm-*.sh" + - ".github/workflows/scripts/vm-game-streaming-test.py" - ".github/workflows/vm-wizard.yml" workflow_dispatch: @@ -51,3 +53,5 @@ jobs: run: bash .github/workflows/scripts/vm-wizard-test.sh - name: Run VM lifecycle regressions run: bash .github/workflows/scripts/vm-lifecycle-test.sh + - name: Check game streaming device selection + run: python3 .github/workflows/scripts/vm-game-streaming-test.py diff --git a/json/moonlight-vm.json b/json/moonlight-vm.json new file mode 100644 index 0000000000..adc7ce4879 --- /dev/null +++ b/json/moonlight-vm.json @@ -0,0 +1,56 @@ +{ + "name": "Moonlight", + "slug": "moonlight-vm", + "categories": [ + 24, + 13 + ], + "date_created": "2026-10-27", + "type": "vm", + "updateable": false, + "privileged": false, + "architectures": [ + "amd64" + ], + "interface_port": null, + "documentation": "https://github.com/moonlight-stream/moonlight-docs/wiki", + "website": "https://moonlight-stream.org/", + "logo": "https://cdn.jsdelivr.net/gh/moonlight-stream/moonlight-qt@master/app/res/moonlight.svg", + "description": "Moonlight is an open-source game-streaming client for Sunshine hosts. It supports hardware video decoding, gamepads, and keyboard and mouse input.", + "install_methods": [ + { + "type": "default", + "script": "vm/moonlight-vm.sh", + "config_path": "", + "resources": { + "cpu": 2, + "ram": 2048, + "hdd": 16, + "os": "Debian", + "version": "13" + } + } + ], + "default_credentials": { + "username": "debian", + "password": null + }, + "notes": [ + { + "text": "Wait for moonlight-setup.service, then reboot the guest. Sign in with your Cloud-Init user; desktop autologin is optional.", + "type": "info" + }, + { + "text": "Connect the display to the selected GPU. Its output is unavailable in noVNC; selected USB ports are dedicated to this VM.", + "type": "info" + }, + { + "text": "Add the Sunshine host in Moonlight and enter the PIN displayed by Moonlight on Sunshine's PIN page.", + "type": "info" + } + ], + "platforms": [ + "pve" + ], + "repository": "https://github.com/moonlight-stream/moonlight-qt" +} diff --git a/json/sunshine-vm.json b/json/sunshine-vm.json new file mode 100644 index 0000000000..459a1f7c67 --- /dev/null +++ b/json/sunshine-vm.json @@ -0,0 +1,56 @@ +{ + "name": "Sunshine", + "slug": "sunshine-vm", + "categories": [ + 24, + 13 + ], + "date_created": "2026-10-27", + "type": "vm", + "updateable": false, + "privileged": false, + "architectures": [ + "amd64" + ], + "interface_port": 47990, + "documentation": "https://docs.lizardbyte.dev/projects/sunshine/", + "website": "https://app.lizardbyte.dev", + "repository": "https://github.com/LizardByte/Sunshine", + "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/sunshine.webp", + "description": "Sunshine streams games and graphical desktops to Moonlight clients using hardware video encoding. It provides a web interface for applications, credentials and client pairing.", + "install_methods": [ + { + "type": "default", + "script": "vm/sunshine-vm.sh", + "config_path": "", + "resources": { + "cpu": 8, + "ram": 8192, + "hdd": 64, + "os": "Debian", + "version": "13" + } + } + ], + "default_credentials": { + "username": "debian", + "password": null + }, + "notes": [ + { + "text": "Wait for sunshine-firstboot.service, then reboot the guest. Sign in with your Cloud-Init user (debian by default).", + "type": "info" + }, + { + "text": "GPU output needs a monitor/dummy. Dummy-only operation requires VM_DESKTOP_AUTOLOGIN=yes; GPU output is unavailable in noVNC.", + "type": "info" + }, + { + "text": "After graphical login, open https://:47990 and set credentials. Pegasus is an optional alpha build.", + "type": "info" + } + ], + "platforms": [ + "pve" + ] +} diff --git a/vm/moonlight-vm.sh b/vm/moonlight-vm.sh new file mode 100644 index 0000000000..c6b739407f --- /dev/null +++ b/vm/moonlight-vm.sh @@ -0,0 +1,477 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2026 community-scripts ORG +# Author: Tomblarom +# License: MIT | https://github.com/community-scripts/DevScripts/raw/main/LICENSE +# Source: https://moonlight-stream.org + +COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://raw.githubusercontent.com/community-scripts/DevScripts/main}" +source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/pve/vm-core.func") +load_functions + +APP="Moonlight" +APP_TYPE="vm" +NSAPP="moonlight-vm" +var_os="debian" +var_version="13" +GEN_MAC=02:$(openssl rand -hex 5 | awk '{print toupper($0)}' | sed 's/\(..\)/\1:/g; s/.$//') +RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)" +METHOD="" +THIN="discard=on,ssd=1," +USE_CLOUD_INIT="no" + +header_info +echo -e "\n Loading..." + +set -Eeo pipefail +shopt -s inherit_errexit +trap 'error_handler $LINENO "$BASH_COMMAND"' ERR +trap cleanup EXIT +trap 'post_update_to_api "failed" "130"' SIGINT +trap 'post_update_to_api "failed" "143"' SIGTERM +trap 'post_update_to_api "failed" "129"; exit 129' SIGHUP + +vm_require_arch amd64 + +TEMP_DIR=$(mktemp -d) +pushd "$TEMP_DIR" >/dev/null + +vm_preflight +vm_require_tools virt-customize jq lspci + +function select_client_options() { + if [[ "${VM_UNATTENDED:-0}" == 1 ]]; then + VM_CLIENT_AUTOSTART="${VM_CLIENT_AUTOSTART:-yes}" + VM_DESKTOP_AUTOLOGIN="${VM_DESKTOP_AUTOLOGIN:-no}" + else + if [[ -z "${VM_CLIENT_AUTOSTART:-}" ]]; then + vm_dialog radiolist "MOONLIGHT AUTOSTART" "Start Moonlight after graphical login?" 12 68 2 \ + yes "Start Moonlight" ON no "Open the desktop" OFF || exit_script + VM_CLIENT_AUTOSTART="$VM_DIALOG_RESULT" + fi + if [[ -z "${VM_DESKTOP_AUTOLOGIN:-}" ]]; then + vm_dialog radiolist "DESKTOP AUTOLOGIN" "Log ${CLOUDINIT_USER} into the desktop without asking for a password?" 12 68 2 \ + no "Ask for credentials" ON yes "Log in automatically" OFF || exit_script + VM_DESKTOP_AUTOLOGIN="$VM_DIALOG_RESULT" + fi + fi + if [[ ! "$VM_CLIENT_AUTOSTART" =~ ^(yes|no)$ || ! "$VM_DESKTOP_AUTOLOGIN" =~ ^(yes|no)$ ]]; then + msg_error "VM_CLIENT_AUTOSTART and VM_DESKTOP_AUTOLOGIN must be yes or no." + exit 1 + fi + echo -e "${INFO}${BOLD}${DGN}Moonlight autostart: ${BGN}${VM_CLIENT_AUTOSTART}${CL}" + echo -e "${INFO}${BOLD}${DGN}Desktop autologin: ${BGN}${VM_DESKTOP_AUTOLOGIN}${CL}" +} + +function validate_gpu_passthrough() { + local address="${VM_GPU_PCI:-}" device function member class config value slot driver + GPU_SLOT="" + [[ -z "$address" || "$address" == none ]] && return 0 + [[ "$address" =~ ^([[:xdigit:]]{4}:)?[[:xdigit:]]{2}:[[:xdigit:]]{2}\.[0-7]$ ]] || { + msg_error "VM_GPU_PCI must name one GPU function, for example 0000:01:00.0." + return 1 + } + [[ "$address" == ????:??:??.? ]] || address="0000:$address" + address="${address,,}" + device="/sys/bus/pci/devices/$address" + [[ -r "$device/class" && "$(<"$device/class")" == 0x03* ]] || { + msg_error "The selected PCI function is not a display controller: $address" + return 1 + } + slot="${address%.*}" + for function in /sys/bus/pci/devices/"$slot".*; do + [[ -e "$function/iommu_group" ]] || { + msg_error "No IOMMU group for ${function##*/}. Enable VT-d/AMD-Vi in firmware and prepare PCI passthrough first: https://pve.proxmox.com/wiki/PCI_Passthrough" + return 1 + } + [[ ! -r "$function/boot_vga" || "$(<"$function/boot_vga")" != 1 ]] || { + msg_error "${function##*/} is the boot display. Configure this GPU manually to protect the host console." + return 1 + } + driver="$(readlink "$function/driver" 2>/dev/null || true)" + [[ "${driver##*/}" == vfio-pci ]] || { + msg_error "${function##*/} must already use vfio-pci. Prepare this specific device using the Proxmox PCI passthrough guide, or choose no GPU. Host drivers and boot settings are not changed here." + return 1 + } + for member in "$function"/iommu_group/devices/*; do + [[ -r "$member/class" ]] || return 1 + class="$(<"$member/class")" + [[ "${member##*/}" == "$slot".* || "$class" == 0x0604* ]] || { + msg_error "IOMMU group also contains ${member##*/}. Select an isolated GPU; ACS overrides are not configured." + return 1 + } + done + done + for config in /etc/pve/qemu-server/*.conf; do + [[ -f "$config" ]] || continue + while IFS= read -r value; do + [[ "$value" != *mapping=* ]] || { + msg_error "A local VM uses a PCI resource mapping. Check mapped-device ownership and attach the GPU manually in Proxmox." + return 1 + } + value="${value,,}" + # hostpci accepts domain-less addresses, individual functions and semicolon lists. + value="${value//0000:/}" + value="${value//host=/}" + if [[ "$value" =~ (^|[\;[:space:]])${slot#0000:}(\.[0-7])?([\;,[:space:]]|$) ]]; then + msg_error "The selected GPU is already assigned in ${config##*/}." + return 1 + fi + done < <(sed -n 's/^hostpci[0-9][0-9]*: *//p' "$config") + done + GPU_SLOT="$slot" +} + +function validate_usb_passthrough() { + local port interface class config value vendor product + USB_PORTS=() + [[ -z "${VM_USB_PORTS:-}" || "$VM_USB_PORTS" == none ]] && return 0 + [[ "$VM_USB_PORTS" != ,* && "$VM_USB_PORTS" != *, && "$VM_USB_PORTS" != *,,* ]] || return 1 + IFS=, read -r -a USB_PORTS <<<"$VM_USB_PORTS" + (( ${#USB_PORTS[@]} <= 14 )) || { msg_error "Select at most 14 USB ports."; return 1; } + local -A seen=() + for port in "${USB_PORTS[@]}"; do + [[ "$port" =~ ^[1-9][0-9]*-[1-9][0-9]*(\.[1-9][0-9]*)*$ && -r "/sys/bus/usb/devices/$port/idVendor" ]] || { + msg_error "USB port is invalid or disconnected: $port" + return 1 + } + [[ -z "${seen[$port]:-}" ]] || { msg_error "USB port selected twice: $port"; return 1; } + seen[$port]=1 + for interface in /sys/bus/usb/devices/"$port"/bDeviceClass /sys/bus/usb/devices/"$port":*/bInterfaceClass; do + [[ -r "$interface" ]] || continue + class="$(<"$interface")" + case "${class,,}" in + 08 | 09 | 02 | 0a) msg_error "USB storage, hubs and network devices are not supported by this picker: $port"; return 1 ;; + esac + done + vendor="$(<"/sys/bus/usb/devices/$port/idVendor")" + product="$(<"/sys/bus/usb/devices/$port/idProduct")" + for config in /etc/pve/qemu-server/*.conf; do + [[ -f "$config" ]] || continue + while IFS= read -r value; do + if [[ "$value" == *mapping=* || "$value" == *"host=$port,"* || "$value" == *"host=$port" || "$value" == *"host=$vendor:$product"* ]]; then + msg_error "USB port or matching USB ID may already be assigned in ${config##*/}. Check ownership manually." + return 1 + fi + done < <(sed -n 's/^usb[0-9][0-9]*: *//p' "$config") + done + done +} + +function select_passthrough() { + local device address label port summary="" vendor product + local -a choices=(none "Configure GPU later (no hardware acceleration promised)" ON) + GPU_SLOT="" + USB_PORTS=() + if [[ -z "${VM_GPU_PCI:-}" && "${VM_UNATTENDED:-0}" != 1 ]]; then + for device in /sys/bus/pci/devices/*; do + [[ -r "$device/class" && "$(<"$device/class")" == 0x03* ]] || continue + address="${device##*/}" + label="$(lspci -D -s "$address" -nn)" + choices+=("$address" "$label" OFF) + done + vm_dialog radiolist "GPU PASSTHROUGH" "Select one prepared GPU. Every function in its PCI slot will be dedicated to this VM. No GPU is selected automatically." 18 90 8 "${choices[@]}" || exit_script + VM_GPU_PCI="$VM_DIALOG_RESULT" + fi + validate_gpu_passthrough || return 1 + if [[ -n "$GPU_SLOT" && -r "/sys/bus/pci/devices/$GPU_SLOT.0/vendor" ]] && + [[ "$(<"/sys/bus/pci/devices/$GPU_SLOT.0/vendor")" == 0x10de ]]; then + msg_warn "Install a compatible NVIDIA driver inside the guest before testing hardware encoding/decoding; this installer stages AMD/Intel Mesa drivers." + fi + if [[ "$NSAPP" == moonlight-vm ]]; then + if [[ -z "${VM_USB_PORTS:-}" && "${VM_UNATTENDED:-0}" != 1 ]]; then + summary="USB devices by physical port (none are preselected):" + for device in /sys/bus/usb/devices/*/idVendor; do + port="${device%/idVendor}" + port="${port##*/}" + [[ "$port" =~ ^[1-9][0-9]*-[1-9][0-9]*(\.[1-9][0-9]*)*$ ]] || continue + vendor="$(<"$device")" + product="$(<"${device%/idVendor}/idProduct")" + label="" + [[ ! -r "${device%/idVendor}/product" ]] || label="$(<"${device%/idVendor}/product")" + summary+="\n$port $vendor:$product $label" + done + vm_dialog inputbox "USB PASSTHROUGH" "$summary\n\nEnter comma-separated ports, for example 1-2,1-3.4, or none. They become exclusive to the VM; reconnecting another device to the same port also passes it through." 20 90 none || exit_script + VM_USB_PORTS="$VM_DIALOG_RESULT" + fi + validate_usb_passthrough || return 1 + fi + if [[ -n "$GPU_SLOT" || ${#USB_PORTS[@]} -gt 0 ]]; then + if [[ "${VM_UNATTENDED:-0}" == 1 ]]; then + [[ "${VM_PASSTHROUGH_CONFIRM:-no}" == yes ]] || { + msg_error "Unattended device assignment requires VM_PASSTHROUGH_CONFIRM=yes." + return 1 + } + else + summary="Dedicated GPU slot: ${GPU_SLOT:-none}\nUSB ports: ${VM_USB_PORTS:-none}\n" + if [[ -n "$GPU_SLOT" ]]; then + for device in /sys/bus/pci/devices/"$GPU_SLOT".*; do + summary+="\n$(lspci -D -s "${device##*/}" -nn)" + done + fi + vm_dialog yesno "CONFIRM DEVICE ASSIGNMENT" "$summary\n\nThese devices become exclusive to this VM. GPU output requires a connected monitor/dummy and is unavailable in noVNC. SSH and serial console remain available. Apply these assignments to the new VM?" 21 90 || exit_script + fi + fi +} + +function attach_passthrough() { + local index + # Recheck after downloading/provisioning, before changing device ownership. + validate_gpu_passthrough || return 1 + if [[ "$NSAPP" == moonlight-vm ]]; then + validate_usb_passthrough || return 1 + fi + if [[ -n "$GPU_SLOT" ]]; then + qm set "$VMID" --hostpci0 "$GPU_SLOT,pcie=1" --vga none >/dev/null + fi + for index in "${!USB_PORTS[@]}"; do + qm set "$VMID" "--usb$index" "host=${USB_PORTS[$index]},usb3=1" >/dev/null + done +} + +function default_settings() { + VMID=$(get_valid_nextid) + vm_apply_machine_type "q35" + DISK_SIZE="16G" + DISK_CACHE="" + HN="moonlight" + CPU_TYPE=" -cpu host" + CORE_COUNT="2" + RAM_SIZE="2048" + BRG="vmbr0" + MAC="$GEN_MAC" + VLAN="" + MTU="" + START_VM="yes" + METHOD="default" + vm_echo_default_settings +} + +function advanced_settings() { + METHOD="advanced" + vm_prompt_vmid "${VMID:-$(get_valid_nextid)}" + vm_apply_machine_type "q35" + vm_prompt_disk_size "16G" "Set Disk Size in GiB (min. 16 recommended)" + vm_prompt_disk_cache "none" + vm_prompt_hostname "moonlight" + vm_prompt_cpu_model "host" + vm_prompt_cpu_cores "2" + vm_prompt_ram "2048" + vm_prompt_bridge "vmbr0" + vm_prompt_mac "$GEN_MAC" + vm_prompt_vlan + vm_prompt_mtu + vm_prompt_keyboard + vm_prompt_verbose "no" + vm_prompt_start_vm "yes" + + if vm_confirm_advanced_settings "Ready to create a Moonlight VM?"; then + echo -e "${CREATING}${BOLD}${DGN}Creating a Moonlight VM using the above advanced settings${CL}" + else + header_info + echo -e "${ADVANCED}${BOLD}${RD}Using Advanced Settings${CL}" + advanced_settings + fi +} + +vm_start_script "Use Default Settings?\n\nDefaults:\n• 2 CPU Cores\n• 2 GB RAM\n• 16 GB Disk" 13 58 +if [[ "${MACHINE_TYPE:-}" != q35 ]]; then + msg_error "Moonlight VMs require the q35 machine type." + exit 1 +fi +CLOUDINIT_REQUIRED=1 +CLOUDINIT_DEFAULT_USER="debian" +vm_prompt_cloud_init "debian" +if [[ "$USE_CLOUD_INIT" != "yes" ]]; then + msg_error "Moonlight needs a Cloud-Init user for the graphical session." + exit 1 +fi +CLOUDINIT_USER="${CLOUDINIT_USER:-debian}" +if [[ "$CLOUDINIT_USER" == "root" ]] || [[ ! "$CLOUDINIT_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || ((${#CLOUDINIT_USER} > 32)); then + msg_error "Refusing Cloud-Init user '${CLOUDINIT_USER}': the desktop needs a non-root Unix username." + exit 1 +fi +select_client_options +select_passthrough +post_to_api_vm + +vm_select_storage "$HN" +vm_define_disk_references 2 + +msg_info "Resolving the Moonlight release" +if ! vm_release_asset github moonlight-stream/moonlight-qt '^Moonlight-.*-x86_64\.AppImage$'; then + exit 1 +fi +if [[ -z "${VM_RELEASE_SHA256:-}" ]]; then + msg_error "Moonlight release ${VM_RELEASE_TAG} publishes no digest; refusing an unverified download." + exit 1 +fi +APPIMAGE_FILE="$TEMP_DIR/$VM_RELEASE_ASSET" +vm_fetch_image "$VM_RELEASE_URL" "$APPIMAGE_FILE" --min-bytes $((10 * 1024 * 1024)) --sha256 "$VM_RELEASE_SHA256" || exit 115 + +URL="https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2" + +msg_info "Retrieving the URL for the Debian 13 Cloud Image" +msg_ok "${CL}${BL}${URL}${CL}" + +CACHE_FILE="$(vm_image_cache_path "$URL")" +MIN_IMAGE_BYTES=$((100 * 1024 * 1024)) +vm_fetch_image "$URL" "$CACHE_FILE" --cache --min-bytes "$MIN_IMAGE_BYTES" || exit 115 + +WORK_FILE="$TEMP_DIR/moonlight.qcow2" +cp "$CACHE_FILE" "$WORK_FILE" + +msg_info "Installing prerequisites in image" +if vm_customize "Moonlight prerequisites" "$WORK_FILE" --install "qemu-guest-agent,curl,ca-certificates"; then + msg_ok "Installed prerequisites" +else + msg_warn "Package pre-install failed; the first boot installs them instead." +fi + +vm_customize "Moonlight AppImage" "$WORK_FILE" \ + --mkdir /opt/moonlight \ + --upload "${APPIMAGE_FILE}:/opt/moonlight/${VM_RELEASE_ASSET}" \ + --chmod "0755:/opt/moonlight/${VM_RELEASE_ASSET}" || exit 1 + +vm_prepare_cloud_image "$WORK_FILE" "$HN" || true + +MOONLIGHT_SETUP_TMP="$TEMP_DIR/moonlight-setup.sh" +cat >"$MOONLIGHT_SETUP_TMP" <> /var/log/moonlight-setup.log 2>&1 + +GUEST_USER="${CLOUDINIT_USER}" +AUTOSTART="${VM_CLIENT_AUTOSTART}" +AUTOLOGIN="${VM_DESKTOP_AUTOLOGIN}" + +echo "[\$(date)] Starting Moonlight setup for \$GUEST_USER" + +if [ -f /etc/apt/sources.list.d/debian.sources ]; then + grep -q non-free-firmware /etc/apt/sources.list.d/debian.sources || + sed -i 's/^Components:.*/& non-free-firmware/' /etc/apt/sources.list.d/debian.sources +else + grep -q non-free-firmware /etc/apt/sources.list 2>/dev/null || + sed -i '/^deb / s/\$/ non-free-firmware/' /etc/apt/sources.list +fi + +apt -o Acquire::Retries=3 update + +apt install -y \\ + qemu-guest-agent curl ca-certificates \\ + xorg xfce4 lightdm lightdm-gtk-greeter dbus-x11 polkitd \\ + pipewire wireplumber pipewire-pulse pipewire-alsa \\ + mesa-va-drivers mesa-vulkan-drivers intel-media-va-driver i965-va-driver \\ + firmware-amd-graphics firmware-intel-graphics firmware-misc-nonfree firmware-sof-signed vainfo vulkan-tools + +systemctl enable --now qemu-guest-agent +getent group render >/dev/null || groupadd -r render +usermod -aG video,render "\$GUEST_USER" +systemctl --global enable pipewire.socket pipewire-pulse.socket wireplumber.service + +if [ ! -x /opt/moonlight/squashfs-root/AppRun ]; then + cd /opt/moonlight + ./"${VM_RELEASE_ASSET}" --appimage-extract + rm -f ./*.AppImage +fi +cat >/opt/moonlight/version.txt <<'VERSION' +${VM_RELEASE_VERSION} +VERSION + +cat > /usr/local/bin/moonlight <<'LAUNCHER' +#!/bin/sh +exec /opt/moonlight/squashfs-root/AppRun "\$@" +LAUNCHER +chmod 0755 /usr/local/bin/moonlight + +cat > /usr/share/applications/com.moonlight_stream.Moonlight.desktop <<'DESKTOP' +[Desktop Entry] +Name=Moonlight +Comment=Stream games and other applications from another PC running Sunshine or GeForce Experience +Exec=/opt/moonlight/squashfs-root/AppRun +Icon=moonlight +Terminal=false +Type=Application +Categories=Game;Network;RemoteAccess; +Keywords=nvidia;gamestream;stream;sunshine;remote play; +DESKTOP + +install -m 0644 -D /opt/moonlight/squashfs-root/moonlight.svg /usr/share/icons/hicolor/scalable/apps/moonlight.svg +gtk-update-icon-cache -f /usr/share/icons/hicolor 2>/dev/null || true + +if [ "\$AUTOSTART" = yes ]; then + install -m 0644 -D /usr/share/applications/com.moonlight_stream.Moonlight.desktop "/home/\$GUEST_USER/.config/autostart/com.moonlight_stream.Moonlight.desktop" + chown "\$GUEST_USER:\$GUEST_USER" "/home/\$GUEST_USER/.config" + chown -R "\$GUEST_USER:\$GUEST_USER" "/home/\$GUEST_USER/.config/autostart" +fi + +if [ "\$AUTOLOGIN" = yes ]; then + mkdir -p /etc/lightdm/lightdm.conf.d + cat > /etc/lightdm/lightdm.conf.d/12-autologin.conf <<'LIGHTDM' +[Seat:*] +autologin-user=${CLOUDINIT_USER} +autologin-user-timeout=0 +LIGHTDM +fi + +systemctl enable --now lightdm +systemctl set-default graphical.target +echo "[\$(date)] Moonlight setup complete" +FIRSTBOOT + +vm_firstboot_unit "$WORK_FILE" moonlight-setup "$MOONLIGHT_SETUP_TMP" \ + --description "Moonlight desktop first boot installation" \ + --cloud-init yes || exit 1 + +vm_claim_vmid +msg_info "Creating a Debian 13 Moonlight VM" +qm create "$VMID" -agent 1${MACHINE} -tablet 0 -bios ovmf${CPU_TYPE} -cores "$CORE_COUNT" -memory "$RAM_SIZE" \ + -name "$HN" -tags community-script,moonlight -net0 "virtio,bridge=$BRG,macaddr=$MAC$VLAN$MTU" -onboot 1 -ostype l26 -scsihw virtio-scsi-pci +vm_mark_created + +vm_alloc_efi_disk "$DISK0" +vm_import_disk "$VMID" "$WORK_FILE" "$STORAGE" + +DISK_OPTIONS="${DISK_CACHE}${THIN}" +DISK_OPTIONS="${DISK_OPTIONS%,}" +ROOT_DISK="$VM_IMPORTED_DISK" +[[ -n "$DISK_OPTIONS" ]] && ROOT_DISK="${ROOT_DISK},${DISK_OPTIONS}" + +qm set "$VMID" \ + --efidisk0 "${DISK0_REF}${FORMAT}" \ + --scsi0 "$ROOT_DISK" \ + --boot order=scsi0 \ + --serial0 socket >/dev/null +vm_resize_disk "scsi0" "$DISK_SIZE" +set_description + +vm_provision "$VMID" +if [[ -n "${CLOUDINIT_SSH_KEYS:-}" ]]; then + $STD qm set "$VMID" --sshkeys "$CLOUDINIT_SSH_KEYS" +fi + +msg_ok "Created a Debian 13 Moonlight VM ${CL}${BL}(${HN})" +attach_passthrough +vm_start_vm "Moonlight VM" +vm_wait_for_ip 120 || true + +display_cloud_init_info "$VMID" "$HN" + +vm_print_summary \ + "GPU=${GPU_SLOT:-not assigned}" \ + "OS=Debian 13 (Trixie)" \ + "Moonlight=${VM_RELEASE_VERSION} (installs on first boot)" \ + "Desktop=Xfce + LightDM (user ${CLOUDINIT_USER})" \ + "USB=${VM_USB_PORTS:-not assigned}" \ + "Autostart=${VM_CLIENT_AUTOSTART}" \ + "Autologin=${VM_DESKTOP_AUTOLOGIN}" \ + "Pairing=In the Moonlight UI (enter the PIN in Sunshine)" \ + "Documentation=https://moonlight-stream.org/" +vm_next_steps \ + "Creating the VM does not mean the guest setup finished: the desktop and Moonlight install on first boot; follow it with: tail -f /var/log/moonlight-setup.log (unit: systemctl status moonlight-setup)." \ + "After the first boot completes, reboot the guest once before judging GPU behavior: firmware installed that late is picked up on the next boot. No host reboot is needed." \ + "Log in on the GPU-connected monitor as ${CLOUDINIT_USER} (noVNC works only without GPU assignment) with the Cloud-Init password (or straight into the desktop when autologin is on) and pair from the Moonlight UI: add the Sunshine host, then enter the PIN shown here in Sunshine." \ + "For a capable display and decoder, select 3840x2160 at 60 FPS and HEVC in Moonlight. Select the guest HDMI/DP or passed-through USB audio output and check the performance overlay." \ + "A passed-through GPU is assigned through the wizard device options; NVIDIA cards additionally need the vendor driver installed in the guest as a follow-up." +vm_finish "VM created. Moonlight and the desktop install in the VM on first boot." diff --git a/vm/sunshine-vm.sh b/vm/sunshine-vm.sh new file mode 100644 index 0000000000..6a9a2eb4a3 --- /dev/null +++ b/vm/sunshine-vm.sh @@ -0,0 +1,604 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2026 community-scripts ORG +# Author: Tomblarom +# License: MIT | https://github.com/community-scripts/DevScripts/raw/main/LICENSE +# Source: https://app.lizardbyte.dev + +COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://raw.githubusercontent.com/community-scripts/DevScripts/main}" +source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/pve/vm-core.func") +load_functions + +APP="Sunshine" +APP_TYPE="vm" +NSAPP="sunshine-vm" +var_os="debian" +var_version="13" +GEN_MAC=02:$(openssl rand -hex 5 | awk '{print toupper($0)}' | sed 's/\(..\)/\1:/g; s/.$//') +RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)" +METHOD="" +THIN="discard=on,ssd=1," +USE_CLOUD_INIT="no" +CLOUDINIT_DEFAULT_USER="debian" +OS_LABEL="Debian 13 (Trixie)" +INSTALL_STEAM="" +INSTALL_DOLPHIN="" +INSTALL_PEGASUS="" +DESKTOP_AUTOLOGIN="" +SUNSHINE_TAG="" +SUNSHINE_FIRSTBOOT_MARKER="" + +header_info +echo -e "\n Loading..." + +set -Eeo pipefail +shopt -s inherit_errexit +trap 'error_handler $LINENO "$BASH_COMMAND"' ERR +trap cleanup EXIT +trap 'post_update_to_api "failed" "130"' SIGINT +trap 'post_update_to_api "failed" "143"' SIGTERM +trap 'post_update_to_api "failed" "129"; exit 129' SIGHUP + +vm_require_arch amd64 + +TEMP_DIR=$(mktemp -d) +pushd "$TEMP_DIR" >/dev/null + +vm_preflight +vm_require_tools virt-customize jq lspci + +function validate_gpu_passthrough() { + local address="${VM_GPU_PCI:-}" device function member class config value slot driver + GPU_SLOT="" + [[ -z "$address" || "$address" == none ]] && return 0 + [[ "$address" =~ ^([[:xdigit:]]{4}:)?[[:xdigit:]]{2}:[[:xdigit:]]{2}\.[0-7]$ ]] || { + msg_error "VM_GPU_PCI must name one GPU function, for example 0000:01:00.0." + return 1 + } + [[ "$address" == ????:??:??.? ]] || address="0000:$address" + address="${address,,}" + device="/sys/bus/pci/devices/$address" + [[ -r "$device/class" && "$(<"$device/class")" == 0x03* ]] || { + msg_error "The selected PCI function is not a display controller: $address" + return 1 + } + slot="${address%.*}" + for function in /sys/bus/pci/devices/"$slot".*; do + [[ -e "$function/iommu_group" ]] || { + msg_error "No IOMMU group for ${function##*/}. Enable VT-d/AMD-Vi in firmware and prepare PCI passthrough first: https://pve.proxmox.com/wiki/PCI_Passthrough" + return 1 + } + [[ ! -r "$function/boot_vga" || "$(<"$function/boot_vga")" != 1 ]] || { + msg_error "${function##*/} is the boot display. Configure this GPU manually to protect the host console." + return 1 + } + driver="$(readlink "$function/driver" 2>/dev/null || true)" + [[ "${driver##*/}" == vfio-pci ]] || { + msg_error "${function##*/} must already use vfio-pci. Prepare this specific device using the Proxmox PCI passthrough guide, or choose no GPU. Host drivers and boot settings are not changed here." + return 1 + } + for member in "$function"/iommu_group/devices/*; do + [[ -r "$member/class" ]] || return 1 + class="$(<"$member/class")" + [[ "${member##*/}" == "$slot".* || "$class" == 0x0604* ]] || { + msg_error "IOMMU group also contains ${member##*/}. Select an isolated GPU; ACS overrides are not configured." + return 1 + } + done + done + for config in /etc/pve/qemu-server/*.conf; do + [[ -f "$config" ]] || continue + while IFS= read -r value; do + [[ "$value" != *mapping=* ]] || { + msg_error "A local VM uses a PCI resource mapping. Check mapped-device ownership and attach the GPU manually in Proxmox." + return 1 + } + value="${value,,}" + # hostpci accepts domain-less addresses, individual functions and semicolon lists. + value="${value//0000:/}" + value="${value//host=/}" + if [[ "$value" =~ (^|[\;[:space:]])${slot#0000:}(\.[0-7])?([\;,[:space:]]|$) ]]; then + msg_error "The selected GPU is already assigned in ${config##*/}." + return 1 + fi + done < <(sed -n 's/^hostpci[0-9][0-9]*: *//p' "$config") + done + GPU_SLOT="$slot" +} + +function validate_usb_passthrough() { + local port interface class config value vendor product + USB_PORTS=() + [[ -z "${VM_USB_PORTS:-}" || "$VM_USB_PORTS" == none ]] && return 0 + [[ "$VM_USB_PORTS" != ,* && "$VM_USB_PORTS" != *, && "$VM_USB_PORTS" != *,,* ]] || return 1 + IFS=, read -r -a USB_PORTS <<<"$VM_USB_PORTS" + (( ${#USB_PORTS[@]} <= 14 )) || { msg_error "Select at most 14 USB ports."; return 1; } + local -A seen=() + for port in "${USB_PORTS[@]}"; do + [[ "$port" =~ ^[1-9][0-9]*-[1-9][0-9]*(\.[1-9][0-9]*)*$ && -r "/sys/bus/usb/devices/$port/idVendor" ]] || { + msg_error "USB port is invalid or disconnected: $port" + return 1 + } + [[ -z "${seen[$port]:-}" ]] || { msg_error "USB port selected twice: $port"; return 1; } + seen[$port]=1 + for interface in /sys/bus/usb/devices/"$port"/bDeviceClass /sys/bus/usb/devices/"$port":*/bInterfaceClass; do + [[ -r "$interface" ]] || continue + class="$(<"$interface")" + case "${class,,}" in + 08 | 09 | 02 | 0a) msg_error "USB storage, hubs and network devices are not supported by this picker: $port"; return 1 ;; + esac + done + vendor="$(<"/sys/bus/usb/devices/$port/idVendor")" + product="$(<"/sys/bus/usb/devices/$port/idProduct")" + for config in /etc/pve/qemu-server/*.conf; do + [[ -f "$config" ]] || continue + while IFS= read -r value; do + if [[ "$value" == *mapping=* || "$value" == *"host=$port,"* || "$value" == *"host=$port" || "$value" == *"host=$vendor:$product"* ]]; then + msg_error "USB port or matching USB ID may already be assigned in ${config##*/}. Check ownership manually." + return 1 + fi + done < <(sed -n 's/^usb[0-9][0-9]*: *//p' "$config") + done + done +} + +function select_passthrough() { + local device address label port summary="" vendor product + local -a choices=(none "Configure GPU later (no hardware acceleration promised)" ON) + GPU_SLOT="" + USB_PORTS=() + if [[ -z "${VM_GPU_PCI:-}" && "${VM_UNATTENDED:-0}" != 1 ]]; then + for device in /sys/bus/pci/devices/*; do + [[ -r "$device/class" && "$(<"$device/class")" == 0x03* ]] || continue + address="${device##*/}" + label="$(lspci -D -s "$address" -nn)" + choices+=("$address" "$label" OFF) + done + vm_dialog radiolist "GPU PASSTHROUGH" "Select one prepared GPU. Every function in its PCI slot will be dedicated to this VM. No GPU is selected automatically." 18 90 8 "${choices[@]}" || exit_script + VM_GPU_PCI="$VM_DIALOG_RESULT" + fi + validate_gpu_passthrough || return 1 + if [[ -n "$GPU_SLOT" && -r "/sys/bus/pci/devices/$GPU_SLOT.0/vendor" ]] && + [[ "$(<"/sys/bus/pci/devices/$GPU_SLOT.0/vendor")" == 0x10de ]]; then + msg_warn "Install a compatible NVIDIA driver inside the guest before testing hardware encoding/decoding; this installer stages AMD/Intel Mesa drivers." + fi + if [[ "$NSAPP" == moonlight-vm ]]; then + if [[ -z "${VM_USB_PORTS:-}" && "${VM_UNATTENDED:-0}" != 1 ]]; then + summary="USB devices by physical port (none are preselected):" + for device in /sys/bus/usb/devices/*/idVendor; do + port="${device%/idVendor}" + port="${port##*/}" + [[ "$port" =~ ^[1-9][0-9]*-[1-9][0-9]*(\.[1-9][0-9]*)*$ ]] || continue + vendor="$(<"$device")" + product="$(<"${device%/idVendor}/idProduct")" + label="" + [[ ! -r "${device%/idVendor}/product" ]] || label="$(<"${device%/idVendor}/product")" + summary+="\n$port $vendor:$product $label" + done + vm_dialog inputbox "USB PASSTHROUGH" "$summary\n\nEnter comma-separated ports, for example 1-2,1-3.4, or none. They become exclusive to the VM; reconnecting another device to the same port also passes it through." 20 90 none || exit_script + VM_USB_PORTS="$VM_DIALOG_RESULT" + fi + validate_usb_passthrough || return 1 + fi + if [[ -n "$GPU_SLOT" || ${#USB_PORTS[@]} -gt 0 ]]; then + if [[ "${VM_UNATTENDED:-0}" == 1 ]]; then + [[ "${VM_PASSTHROUGH_CONFIRM:-no}" == yes ]] || { + msg_error "Unattended device assignment requires VM_PASSTHROUGH_CONFIRM=yes." + return 1 + } + else + summary="Dedicated GPU slot: ${GPU_SLOT:-none}\nUSB ports: ${VM_USB_PORTS:-none}\n" + if [[ -n "$GPU_SLOT" ]]; then + for device in /sys/bus/pci/devices/"$GPU_SLOT".*; do + summary+="\n$(lspci -D -s "${device##*/}" -nn)" + done + fi + vm_dialog yesno "CONFIRM DEVICE ASSIGNMENT" "$summary\n\nThese devices become exclusive to this VM. GPU output requires a connected monitor/dummy and is unavailable in noVNC. SSH and serial console remain available. Apply these assignments to the new VM?" 21 90 || exit_script + fi + fi +} + +function attach_passthrough() { + local index + # Recheck after downloading/provisioning, before changing device ownership. + validate_gpu_passthrough || return 1 + if [[ "$NSAPP" == moonlight-vm ]]; then + validate_usb_passthrough || return 1 + fi + if [[ -n "$GPU_SLOT" ]]; then + qm set "$VMID" --hostpci0 "$GPU_SLOT,pcie=1" --vga none >/dev/null + fi + for index in "${!USB_PORTS[@]}"; do + qm set "$VMID" "--usb$index" "host=${USB_PORTS[$index]},usb3=1" >/dev/null + done +} + +function default_settings() { + VMID=$(get_valid_nextid) + vm_apply_machine_type "q35" + DISK_SIZE="64G" + DISK_CACHE="" + HN="sunshine" + CPU_TYPE=" -cpu host" + CORE_COUNT="8" + RAM_SIZE="8192" + BRG="vmbr0" + MAC="$GEN_MAC" + VLAN="" + MTU="" + START_VM="yes" + METHOD="default" + vm_echo_default_settings +} + +function advanced_settings() { + METHOD="advanced" + vm_prompt_vmid "${VMID:-$(get_valid_nextid)}" + vm_prompt_machine_type "q35" + vm_prompt_disk_size "64G" "Set Disk Size in GiB (min. 64 recommended)" + vm_prompt_disk_cache "none" + vm_prompt_hostname "sunshine" + vm_prompt_cpu_model "host" + vm_prompt_cpu_cores "8" + vm_prompt_ram "8192" + vm_prompt_bridge "vmbr0" + vm_prompt_mac "$GEN_MAC" + vm_prompt_vlan + vm_prompt_mtu + vm_prompt_keyboard + vm_prompt_verbose "no" + vm_prompt_start_vm "yes" + + if vm_confirm_advanced_settings "Ready to create a Debian 13 Sunshine VM?"; then + echo -e "${CREATING}${BOLD}${DGN}Creating a Debian 13 Sunshine VM using the above advanced settings${CL}" + else + header_info + echo -e "${ADVANCED}${BOLD}${RD}Using Advanced Settings${CL}" + advanced_settings + fi +} + +function select_components() { + if [[ "${VM_UNATTENDED:-0}" == "1" ]]; then + INSTALL_STEAM="${VM_INSTALL_STEAM:-yes}" + INSTALL_DOLPHIN="${VM_INSTALL_DOLPHIN:-yes}" + INSTALL_PEGASUS="${VM_INSTALL_PEGASUS:-no}" + DESKTOP_AUTOLOGIN="${VM_DESKTOP_AUTOLOGIN:-no}" + else + if [[ -z "${VM_INSTALL_STEAM:-}" ]]; then + if vm_dialog radiolist "STEAM" \ + "Install Steam from Debian packages? (adds contrib and i386)" 11 68 2 \ + "yes" "Install Steam" ON \ + "no" "Skip Steam" OFF; then + INSTALL_STEAM="$VM_DIALOG_RESULT" + else + exit_script + fi + else + INSTALL_STEAM="$VM_INSTALL_STEAM" + fi + if [[ -z "${VM_INSTALL_DOLPHIN:-}" ]]; then + if vm_dialog radiolist "DOLPHIN" \ + "Install Dolphin (GameCube/Wii) from Debian packages?" 11 68 2 \ + "yes" "Install Dolphin" ON \ + "no" "Skip Dolphin" OFF; then + INSTALL_DOLPHIN="$VM_DIALOG_RESULT" + else + exit_script + fi + else + INSTALL_DOLPHIN="$VM_INSTALL_DOLPHIN" + fi + if [[ -z "${VM_INSTALL_PEGASUS:-}" ]]; then + if vm_dialog radiolist "PEGASUS" \ + "Install Pegasus Frontend alpha from its continuous build?" 11 68 2 \ + "no" "Skip Pegasus" ON \ + "yes" "Install Pegasus (alpha)" OFF; then + INSTALL_PEGASUS="$VM_DIALOG_RESULT" + else + exit_script + fi + else + INSTALL_PEGASUS="$VM_INSTALL_PEGASUS" + fi + if [[ -z "${VM_DESKTOP_AUTOLOGIN:-}" ]]; then + if vm_dialog radiolist "DESKTOP AUTOLOGIN" \ + "Log the debian user into Plasma automatically?" 11 68 2 \ + "no" "Ask for credentials" ON \ + "yes" "Autologin as debian" OFF; then + DESKTOP_AUTOLOGIN="$VM_DIALOG_RESULT" + else + exit_script + fi + else + DESKTOP_AUTOLOGIN="$VM_DESKTOP_AUTOLOGIN" + fi + fi + + local var + for var in INSTALL_STEAM INSTALL_DOLPHIN INSTALL_PEGASUS DESKTOP_AUTOLOGIN; do + if [[ "${!var}" != "yes" && "${!var}" != "no" ]]; then + msg_error "Invalid ${var}=${!var} (expected yes or no)" + exit 1 + fi + done + echo -e "${INFO}${BOLD}${DGN}Steam: ${BGN}${INSTALL_STEAM}${CL}" + echo -e "${INFO}${BOLD}${DGN}Dolphin: ${BGN}${INSTALL_DOLPHIN}${CL}" + echo -e "${INFO}${BOLD}${DGN}Pegasus: ${BGN}${INSTALL_PEGASUS}${CL}" + echo -e "${INFO}${BOLD}${DGN}Desktop autologin: ${BGN}${DESKTOP_AUTOLOGIN}${CL}" +} + +vm_start_script "Use Default Settings?\n\nDefaults:\n• 8 CPU Cores\n• 8 GB RAM\n• 64 GB Disk" 13 58 +if [[ "${MACHINE_TYPE:-}" != "q35" ]]; then + msg_error "Sunshine VMs require the q35 machine type (got '${MACHINE_TYPE:-unset}')" + exit 1 +fi +CLOUDINIT_REQUIRED=1 +vm_prompt_cloud_init "debian" +if [[ "$USE_CLOUD_INIT" != "yes" ]]; then + msg_error "Sunshine cloud images require Cloud-Init credentials." + exit 1 +fi +CLOUDINIT_USER="${CLOUDINIT_USER:-debian}" +if [[ "$CLOUDINIT_USER" == "root" ]] || + [[ ! "$CLOUDINIT_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || + ((${#CLOUDINIT_USER} > 32)); then + msg_error "Invalid Cloud-Init user '${CLOUDINIT_USER}' (expected a non-root login name)" + exit 1 +fi +select_components +select_passthrough +post_to_api_vm + +vm_select_storage "$HN" +vm_define_disk_references 2 + +URL="https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2" + +msg_info "Retrieving the URL for the ${OS_LABEL} Cloud Image" +msg_ok "${CL}${BL}${URL}${CL}" + +CACHE_FILE="$(vm_image_cache_path "$URL")" +MIN_IMAGE_BYTES=$((100 * 1024 * 1024)) +vm_fetch_image "$URL" "$CACHE_FILE" --cache --min-bytes "$MIN_IMAGE_BYTES" || exit 115 + +WORK_FILE="$TEMP_DIR/sunshine.qcow2" +cp "$CACHE_FILE" "$WORK_FILE" + +SUNSHINE_PATTERN='^sunshine_.*[+]debiantrixie_amd64\.deb$' +msg_info "Resolving the Sunshine release" +if vm_release_asset github LizardByte/Sunshine "$SUNSHINE_PATTERN"; then + SUNSHINE_TAG="$VM_RELEASE_TAG" + SUNSHINE_DEB="$TEMP_DIR/$VM_RELEASE_ASSET" + if [[ -z "${VM_RELEASE_SHA256:-}" ]]; then + msg_error "Sunshine release ${VM_RELEASE_TAG} publishes no digest" + exit 1 + fi + vm_fetch_image "$VM_RELEASE_URL" "$SUNSHINE_DEB" --sha256 "$VM_RELEASE_SHA256" --min-bytes $((1024 * 1024)) || exit 115 + msg_ok "Staged ${VM_RELEASE_ASSET}" +else + exit 1 +fi + +PEGASUS_DEB="" +if [[ "$INSTALL_PEGASUS" == "yes" ]]; then + PEGASUS_PATTERN='^pegasus-frontend_.*_amd64\.deb$' + msg_info "Resolving the Pegasus Frontend release" + if vm_release_asset github mmatyas/pegasus-frontend "$PEGASUS_PATTERN" continuous; then + PEGASUS_DEB="$TEMP_DIR/$VM_RELEASE_ASSET" + if [[ -z "${VM_RELEASE_SHA256:-}" ]]; then + msg_error "Pegasus Frontend release ${VM_RELEASE_TAG} publishes no digest" + exit 1 + fi + vm_fetch_image "$VM_RELEASE_URL" "$PEGASUS_DEB" --sha256 "$VM_RELEASE_SHA256" --min-bytes $((1024 * 1024)) || exit 115 + msg_ok "Staged ${VM_RELEASE_ASSET}" + else + exit 1 + fi +fi + +STAGE_ARGS=(--mkdir /opt/community-scripts --upload "${SUNSHINE_DEB}:/opt/community-scripts/sunshine.deb") +if [[ -n "$PEGASUS_DEB" ]]; then + STAGE_ARGS+=(--upload "${PEGASUS_DEB}:/opt/community-scripts/pegasus.deb") +fi +msg_info "Staging application packages in image" +vm_customize "Application packages" "$WORK_FILE" "${STAGE_ARGS[@]}" || exit 1 +msg_ok "Staged application packages in image" + +vm_prepare_cloud_image "$WORK_FILE" "$HN" || true + +SUNSHINE_FIRSTBOOT_TMP="$TEMP_DIR/sunshine-firstboot.sh" +cat >"$SUNSHINE_FIRSTBOOT_TMP" <> /var/log/sunshine-firstboot.log 2>&1 + +echo "[\$(date '+%F %T')] Starting Sunshine first boot installation" +for _ in {1..30}; do + getent hosts deb.debian.org >/dev/null 2>&1 && break + sleep 2 +done + +GUEST_USER="${CLOUDINIT_USER}" +INSTALL_STEAM="${INSTALL_STEAM}" +INSTALL_DOLPHIN="${INSTALL_DOLPHIN}" +INSTALL_PEGASUS="${INSTALL_PEGASUS}" +DESKTOP_AUTOLOGIN="${DESKTOP_AUTOLOGIN}" + +for component in contrib non-free non-free-firmware; do + if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then + sed -i -E "/^Components:/ { /(^|[[:space:]])\${component}([[:space:]]|\$)/! s/\$/ \${component}/; }" /etc/apt/sources.list.d/debian.sources + fi + if [[ -f /etc/apt/sources.list ]]; then + sed -i -E "/^deb / { /(^|[[:space:]])\${component}([[:space:]]|\$)/! s/\$/ \${component}/; }" /etc/apt/sources.list + fi +done + +apt update +apt install -y \\ + curl jq qemu-guest-agent \\ + kde-plasma-desktop sddm xwayland \\ + pipewire wireplumber pipewire-pulse pipewire-alsa libcap2-bin \\ + mesa-va-drivers mesa-vulkan-drivers intel-media-va-driver-non-free \\ + firmware-amd-graphics firmware-intel-graphics firmware-misc-nonfree \\ + vainfo vulkan-tools +systemctl enable --now qemu-guest-agent +systemctl --global enable pipewire.socket pipewire-pulse.socket wireplumber.service +systemctl set-default graphical.target + +if [[ "\${INSTALL_STEAM}" == "yes" ]]; then + dpkg --add-architecture i386 + apt update + apt install -y mesa-vulkan-drivers libglx-mesa0:i386 mesa-vulkan-drivers:i386 libgl1-mesa-dri:i386 steam-installer +fi + +apt install -y /opt/community-scripts/sunshine.deb +modprobe -a uinput uhid +getent group render >/dev/null || groupadd -r render +usermod -aG input,video,render "\${GUEST_USER}" + +SUNSHINE_UNIT="" +for candidate in /usr/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service /usr/lib/systemd/user/sunshine.service; do + if [[ -f "\${candidate}" ]]; then + SUNSHINE_UNIT="\${candidate}" + break + fi +done +if [[ -n "\${SUNSHINE_UNIT}" ]]; then + USER_WANTS="/home/\${GUEST_USER}/.config/systemd/user/graphical-session.target.wants" + mkdir -p "\${USER_WANTS}" + ln -sf "\${SUNSHINE_UNIT}" "\${USER_WANTS}/\$(basename "\${SUNSHINE_UNIT}")" + chown -R "\${GUEST_USER}:\${GUEST_USER}" "/home/\${GUEST_USER}/.config" +else + mkdir -p "/home/\${GUEST_USER}/.config/autostart" + cat > "/home/\${GUEST_USER}/.config/autostart/sunshine.desktop" <<'DESKTOP' +[Desktop Entry] +Type=Application +Name=Sunshine +Exec=/usr/bin/sunshine +X-GNOME-Autostart-enabled=true +DESKTOP + chown -R "\${GUEST_USER}:\${GUEST_USER}" "/home/\${GUEST_USER}/.config" +fi + +if [[ "\${INSTALL_DOLPHIN}" == "yes" ]]; then + apt install -y dolphin-emu + DOLPHIN_GAMES="/home/\${GUEST_USER}/games/dolphin" + mkdir -p "\${DOLPHIN_GAMES}" + chown "\${GUEST_USER}:\${GUEST_USER}" "/home/\${GUEST_USER}/games" "\${DOLPHIN_GAMES}" + if [[ ! -e "\${DOLPHIN_GAMES}/metadata.pegasus.txt" ]]; then + cat > "\${DOLPHIN_GAMES}/metadata.pegasus.txt" <<'METADATA' +collection: Dolphin +extensions: iso, rvz, wbfs, ciso, gcm, wad, m3u +launch: /usr/games/dolphin-emu -b -e "{file.path}" +METADATA + chown "\${GUEST_USER}:\${GUEST_USER}" "\${DOLPHIN_GAMES}/metadata.pegasus.txt" + fi +fi + +if [[ "\${INSTALL_PEGASUS}" == "yes" ]]; then + apt install -y /opt/community-scripts/pegasus.deb + if [[ "\${INSTALL_DOLPHIN}" == "yes" ]]; then + PEGASUS_CONFIG="/home/\${GUEST_USER}/.config/pegasus-frontend" + mkdir -p "\${PEGASUS_CONFIG}" + if ! grep -Fxq "\${DOLPHIN_GAMES}" "\${PEGASUS_CONFIG}/game_dirs.txt" 2>/dev/null; then + cat >>"\${PEGASUS_CONFIG}/game_dirs.txt" </dev/null + APPS_TEMP="\$(mktemp "\${SUNSHINE_CONFIG}/apps.XXXXXX")" + jq 'if any(.apps[]; .name == "Pegasus") then . else .apps += [{"name":"Pegasus", "cmd":"env QT_QPA_PLATFORM=xcb /usr/bin/pegasus-fe"}] end' \ + "\${SUNSHINE_CONFIG}/apps.json" >"\${APPS_TEMP}" + chown "\${GUEST_USER}:\${GUEST_USER}" "\${SUNSHINE_CONFIG}" "\${APPS_TEMP}" + chmod 600 "\${APPS_TEMP}" + mv "\${APPS_TEMP}" "\${SUNSHINE_CONFIG}/apps.json" +fi + +if [[ "\${DESKTOP_AUTOLOGIN}" == "yes" ]]; then + mkdir -p /etc/sddm.conf.d + for SESSION_FILE in /usr/share/wayland-sessions/plasma*.desktop; do + [[ -f "\${SESSION_FILE}" ]] || { echo "Plasma Wayland session is missing"; exit 1; } + cat > /etc/sddm.conf.d/autologin.conf </dev/null +vm_resize_disk "scsi0" "$DISK_SIZE" +set_description + +vm_provision "$VMID" +if [[ -n "${CLOUDINIT_SSH_KEYS:-}" ]]; then + $STD qm set "$VMID" --sshkeys "$CLOUDINIT_SSH_KEYS" +fi + +msg_ok "Created a Debian 13 Sunshine VM ${CL}${BL}(${HN})" +attach_passthrough +vm_start_vm "Sunshine VM" +vm_wait_for_ip 120 || true + +display_cloud_init_info "$VMID" "$HN" + +vm_print_summary \ + "GPU=${GPU_SLOT:-not assigned}" \ + "OS=${OS_LABEL}" \ + "Sunshine=${SUNSHINE_TAG}" \ + "Steam=${INSTALL_STEAM}" \ + "Dolphin=${INSTALL_DOLPHIN}" \ + "Pegasus=${INSTALL_PEGASUS}" \ + "DesktopAutologin=${DESKTOP_AUTOLOGIN}" \ + "WebUI=https://${VM_IP:-pending}:47990" +NEXT_STEPS=( + "First boot installs the desktop and Sunshine in the VM; follow it with: journalctl -u sunshine-firstboot -f (log: /var/log/sunshine-firstboot.log)" + "Reboot the guest once first boot completes, before testing anything that needs firmware or acceleration." + "Log in on the GPU-connected monitor as ${CLOUDINIT_USER} (noVNC works only without GPU assignment). Dummy-only operation needs VM_DESKTOP_AUTOLOGIN=yes; Sunshine starts at graphical login." + "Open https://:47990, set Sunshine credentials, then enter the PIN displayed by Moonlight in Sunshine's PIN page." + "For 4K60, select the GPU-connected output at 3840x2160 and 60 Hz in Plasma Display Settings if the monitor/dummy supports it; check hardware encoding in the Sunshine log." +) +if [[ "$INSTALL_PEGASUS" == "yes" ]]; then + NEXT_STEPS+=("Pegasus Frontend is an alpha install; its game collections are user-owned.") +fi +vm_next_steps "${NEXT_STEPS[@]}" + +vm_finish "VM created. Sunshine installation continues in the VM on first boot." From 1370e1b130acb14389d7203cee37091e9979a722 Mon Sep 17 00:00:00 2001 From: Tomblarom <12703270+Tomblarom@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:07:08 +0100 Subject: [PATCH 2/2] feat(vm): focus Moonlight on an Openbox kiosk Provision the client through tty1, Xorg and Openbox using the upstream AppImage. Keep GUI pairing, fullscreen relaunch and serial management, and add an explicit device-scoped Raven Ridge reset option. Limit the contribution to the Moonlight VM. Co-authored-by: Tomblarom <12703270+Tomblarom@users.noreply.github.com> --- .../scripts/vm-game-streaming-test.py | 226 ++++++- .../workflows/scripts/vm-lifecycle-test.sh | 18 +- json/moonlight-vm.json | 2 +- json/sunshine-vm.json | 56 -- vm/moonlight-vm.sh | 399 +++++++++--- vm/sunshine-vm.sh | 604 ------------------ 6 files changed, 541 insertions(+), 764 deletions(-) delete mode 100644 json/sunshine-vm.json delete mode 100644 vm/sunshine-vm.sh diff --git a/.github/workflows/scripts/vm-game-streaming-test.py b/.github/workflows/scripts/vm-game-streaming-test.py index 2fa85fad98..849bfb2ca6 100644 --- a/.github/workflows/scripts/vm-game-streaming-test.py +++ b/.github/workflows/scripts/vm-game-streaming-test.py @@ -6,13 +6,15 @@ from pathlib import Path import re import shutil +import signal import subprocess import tempfile +import time import unittest ROOT = Path(__file__).resolve().parents[3] -SCRIPTS = ("sunshine-vm", "moonlight-vm") +SCRIPTS = ("moonlight-vm",) class PassthroughTests(unittest.TestCase): @@ -23,7 +25,9 @@ def setUp(self): self.pci = self.root / "pci" self.usb = self.root / "usb" self.config = self.root / "config" - for path in (self.pci, self.usb, self.config): + self.bin = self.root / "sbin" + self.units = self.root / "units" + for path in (self.pci, self.usb, self.config, self.bin, self.units): path.mkdir() self.gpu() self.gpu("0000:01:00.1", "0x040300") @@ -34,6 +38,9 @@ def gpu(self, address="0000:01:00.0", pci_class="0x030000", driver="vfio-pci", g device.mkdir(exist_ok=True) (device / "class").write_text(pci_class) (device / "boot_vga").write_text("0") + (device / "vendor").write_text("0x1002") + (device / "device").write_text("0x15dd") + (device / "reset_method").write_text("flr bus") drivers = self.root / "drivers" / driver drivers.mkdir(parents=True, exist_ok=True) if (device / "driver").is_symlink(): @@ -57,7 +64,7 @@ def usb_device(self, port="1-2", usb_class="00", interface_class="03"): def run_case(self, slug, body="select_passthrough && attach_passthrough", **env): source = (ROOT / "vm" / f"{slug}.sh").read_text() functions = [] - for name in ("validate_gpu_passthrough", "validate_usb_passthrough", "select_passthrough", "attach_passthrough"): + for name in ("validate_gpu_passthrough", "validate_usb_passthrough", "select_passthrough", "attach_passthrough", "validate_raven_reset_guard", "install_raven_reset_guard"): match = re.search(rf"^function {name}\(\) \{{\n.*?^\}}", source, re.M | re.S) self.assertIsNotNone(match, name) functions.append(match.group()) @@ -65,10 +72,15 @@ def run_case(self, slug, body="select_passthrough && attach_passthrough", **env) code = code.replace("/sys/bus/pci/devices", str(self.pci)) code = code.replace("/sys/bus/usb/devices", str(self.usb)) code = code.replace("/etc/pve/qemu-server", str(self.config)) + code = code.replace("/usr/local/sbin", str(self.bin)) + code = code.replace("/etc/systemd/system", str(self.units)) preamble = r''' set -Eeuo pipefail msg_error() { echo "ERROR: $*" >&2; } +msg_info() { :; } +msg_ok() { :; } qm() { echo "qm $*" >>"$EVENTS"; } +systemctl() { echo "systemctl $*" >>"$EVENTS"; } lspci() { echo "Synthetic GPU $*"; } exit_script() { exit 130; } vm_dialog() { @@ -108,11 +120,11 @@ def test_no_automatic_assignment(self): self.assertEqual(result.returncode, 0, result.stderr) self.assertNotIn("qm ", result.stdout) - def test_gpu_slot_contains_gpu_and_audio(self): + def test_only_selected_display_function_is_attached(self): for slug in SCRIPTS: result = self.run_case(slug, VM_GPU_PCI="01:00.0") self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "qm set 100 --hostpci0 0000:01:00,pcie=1 --vga none") + self.assertEqual(result.stdout.strip(), "qm set 100 --hostpci0 0000:01:00.0,pcie=1 --vga serial0") def test_unattended_requires_confirmation(self): self.assert_rejected(VM_PASSTHROUGH_CONFIRM="no") @@ -142,6 +154,55 @@ def test_boot_gpu_is_not_taken(self): (self.pci / "0000:01:00.0" / "boot_vga").write_text("1") self.assert_rejected() + def test_prepared_boot_gpu_requires_explicit_override(self): + (self.pci / "0000:01:00.0" / "boot_vga").write_text("1") + result = self.run_case("moonlight-vm", VM_GPU_PCI="01:00.0", VM_ALLOW_BOOT_GPU="yes") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--hostpci0 0000:01:00.0,pcie=1 --vga serial0", result.stdout) + + def test_unrelated_slot_functions_in_other_groups_are_not_claimed(self): + self.gpu("0000:01:00.2", "0x108000", driver="ccp", group="8") + result = self.run_case("moonlight-vm", VM_GPU_PCI="01:00.0") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("01:00.2", result.stdout) + + def test_reset_guard_is_opt_in_and_hardware_scoped(self): + body = "select_passthrough && install_raven_reset_guard" + result = self.run_case("moonlight-vm", body=body, VM_GPU_PCI="01:00.0") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(list(self.units.iterdir()), []) + (self.pci / "0000:01:00.0" / "device").write_text("0x9999") + result = self.run_case("moonlight-vm", body=body, VM_GPU_PCI="01:00.0", VM_RAVEN_RESET_GUARD="yes") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(list(self.units.iterdir()), []) + self.assertNotIn("systemctl ", result.stdout) + + def test_reset_guard_rejects_no_gpu_and_invalid_options(self): + for options in ({"VM_RAVEN_RESET_GUARD": "yes"}, {"VM_RAVEN_RESET_GUARD": "maybe"}, {"VM_ALLOW_BOOT_GPU": "maybe"}): + result = self.run_case("moonlight-vm", **options) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("qm ", result.stdout) + + def test_reset_guard_runs_before_autostart_and_checks_device_identity(self): + result = self.run_case("moonlight-vm", body="select_passthrough && install_raven_reset_guard", VM_GPU_PCI="01:00.0", VM_RAVEN_RESET_GUARD="yes") + self.assertEqual(result.returncode, 0, result.stderr) + unit = self.units / "moonlight-raven-reset-100.service" + self.assertIn("Before=pve-guests.service", unit.read_text()) + self.assertIn("After=systemd-udev-trigger.service", unit.read_text()) + script = self.bin / "moonlight-raven-reset-100" + reset = self.pci / "0000:01:00.0" / "reset_method" + run = subprocess.run(["sh", str(script)], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertEqual(reset.read_text(), "\n") + # Repeated boots remain harmless; a different device at the address is refused. + run = subprocess.run(["sh", str(script)], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stderr) + reset.write_text("flr bus") + (self.pci / "0000:01:00.0" / "device").write_text("0x9999") + run = subprocess.run(["sh", str(script)], capture_output=True, text=True) + self.assertNotEqual(run.returncode, 0) + self.assertEqual(reset.read_text(), "flr bus") + def test_existing_assignment_forms(self): for value in ("0000:01:00.0,pcie=1", "01:00,pcie=1", "host=01:00.1", "03:00.0;01:00.1,pcie=1", "mapping=render-gpu"): with self.subTest(value=value): @@ -218,12 +279,11 @@ def setUp(self): def render(self, slug): source = (ROOT / "vm" / f"{slug}.sh").read_text() - match = re.search(r'cat >"\$(SUNSHINE_FIRSTBOOT_TMP|MOONLIGHT_SETUP_TMP)" <"\$(MOONLIGHT_SETUP_TMP)" <>"$EVENTS"; echo app-output; exit 1', + "wmctrl": 'echo "wmctrl $*" >>"$EVENTS"', + "sleep": 'echo "sleep $*" >>"$EVENTS"; /bin/sleep 0.05', + }.items(): + path = commands / command + path.write_text("#!/bin/sh\n" + body + "\n") + path.chmod(0o755) + snippet = snippet.replace("/usr/local/bin/moonlight", str(commands / "moonlight")) + snippet = snippet.replace("$HOME", "$TEST_USER_HOME") + env = {"PATH": f'{commands}:{os.environ["PATH"]}', "EVENTS": str(events), "TEST_USER_HOME": str(self.root)} + process = subprocess.Popen(["sh", "-c", snippet + "\nwait"], env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + try: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + output = events.read_text() if events.exists() else "" + if output.count("launch\n") >= 2 and "wmctrl -r Moonlight -b add,fullscreen" in output: + break + time.sleep(0.02) + self.assertGreaterEqual(output.count("launch\n"), 2, output) + self.assertIn("sleep 3", output) + self.assertIn("wmctrl -r Moonlight -b add,fullscreen", output) + self.assertIn("app-output", (self.root / ".local/state/moonlight-kiosk.log").read_text()) + finally: + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=5) + + def test_launcher_uses_gui_and_scopes_vaapi_override_to_amd(self): + pci = self.root / "pci" + pci.mkdir() + app = self.root / "app" + app.write_text('#!/usr/bin/env python3\nimport json,os,sys\nprint(json.dumps({"driver":os.getenv("LIBVA_DRIVER_NAME"),"scale":os.getenv("QT_SCALE_FACTOR"),"vulkan":os.getenv("PREFER_VULKAN"),"args":sys.argv[1:]}))\n') + app.chmod(0o755) + launcher = self.root / "launcher" + launcher.write_text(self.heredoc("LAUNCHER").replace("/sys/bus/pci/devices", str(pci)) + .replace("/opt/moonlight/Moonlight.AppImage", str(app))) + config = self.root / "config" + config.write_text("PREFER_VULKAN=1\nQT_SCALE_FACTOR=2.5\n") + device = pci / "0000:01:00.0" + device.mkdir() + for driver, override, expected in (("i915", "", None), ("amdgpu", "", "radeonsi"), ("amdgpu", "custom", "custom")): + with self.subTest(driver=driver, override=override): + (device / "driver").unlink(missing_ok=True) + target = self.root / "drivers" / driver + target.mkdir(parents=True, exist_ok=True) + (device / "driver").symlink_to(target) + env = {"PATH": os.environ["PATH"], "MOONLIGHT_KIOSK_CONF": str(config)} + if override: + env["LIBVA_DRIVER_NAME"] = override + result = subprocess.run(["sh", str(launcher)], env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + data = json.loads(result.stdout) + self.assertEqual(data, {"driver": expected, "scale": "2.5", "vulkan": "1", "args": []}) + config.write_text("PREFER_VULKAN=0\nQT_SCALE_FACTOR=1.5\n") + result = subprocess.run(["sh", str(launcher), "--help"], env=env, capture_output=True, text=True, check=True) + data = json.loads(result.stdout) + self.assertEqual((data["scale"], data["vulkan"], data["args"]), ("1.5", "0", ["--help"])) + + def test_display_prefers_available_4k60_and_survives_mode_failure(self): + commands = self.root / "commands" + commands.mkdir() + for command, body in { + "xset": 'echo "xset $*" >>"$EVENTS"', + "xrandr": 'if [ "$1" = --query ]; then cat "$EDID"; else echo "xrandr $*" >>"$EVENTS"; exit 1; fi', + "unclutter": 'echo "unclutter $*" >>"$EVENTS"', + }.items(): + path = commands / command + path.write_text("#!/bin/sh\n" + body + "\n") + path.chmod(0o755) + edid = self.root / "edid" + events = self.root / "events" + for modes, expected in ((" 3840x2160 60.00*+ 59.94\n", True), + (" 1920x1080 60.00*+\n", False), (" 3840x2160 30.00\n", False)): + with self.subTest(modes=modes): + edid.write_text("DisplayPort-1 connected primary\n" + modes + "HDMI-1 disconnected\n") + events.unlink(missing_ok=True) + env = {"PATH": f'{commands}:{os.environ["PATH"]}', "EVENTS": str(events), "EDID": str(edid)} + result = subprocess.run(["sh", "-c", self.heredoc("AUTOSTART")], env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + output = events.read_text() + self.assertEqual("xrandr --output DisplayPort-1 --mode 3840x2160 --rate 60" in output, expected) + for action in ("xset s off", "xset s noblank", "xset -dpms", "unclutter --timeout 1 --start-hidden --fork"): + self.assertIn(action, output) def test_debian_components_are_added_per_stanza_without_duplicates(self): - guest = self.render("sunshine-vm").read_text() + guest = self.render("moonlight-vm").read_text() block = re.search(r"for component in contrib non-free non-free-firmware; do\n.*?\ndone", guest, re.S).group() apt = self.root / "apt" (apt / "sources.list.d").mkdir(parents=True) @@ -269,10 +447,10 @@ def test_debian_components_are_added_per_stanza_without_duplicates(self): block = block.replace("/etc/apt", str(apt)) for _ in range(2): subprocess.run(["bash", "-eu", "-c", block], check=True) - fields = [line.split()[1:] for line in sources.read_text().splitlines() if line.startswith("Components:")] - for components in fields: - self.assertEqual(set(components), {"main", "contrib", "non-free", "non-free-firmware"}) - self.assertEqual(len(components), 4) + for line in sources.read_text().splitlines(): + if line.startswith("Components:"): + self.assertEqual(sorted(line.split()[1:]), sorted(("main", "contrib", "non-free", "non-free-firmware"))) + if __name__ == "__main__": diff --git a/.github/workflows/scripts/vm-lifecycle-test.sh b/.github/workflows/scripts/vm-lifecycle-test.sh index aac65abbbb..d6adff8f5e 100644 --- a/.github/workflows/scripts/vm-lifecycle-test.sh +++ b/.github/workflows/scripts/vm-lifecycle-test.sh @@ -80,16 +80,14 @@ vm_release_asset() { IceWhaleTech/ZimaOS) tag="1.8.0-beta2" asset="zimaos-x86_64-1.8.0-beta2_installer.iso" ;; home-assistant/operating-system) tag="${4:-16.0}" asset="haos_generic-aarch64-${4:-16.0}.qcow2.xz" ;; derailed/k9s) tag="v0.50.0" asset="k9s_Linux_amd64.tar.gz" ;; - LizardByte/Sunshine) tag="v2026.914.233613" asset="sunshine_2026.914.233613-1+debiantrixie_amd64.deb" ;; moonlight-stream/moonlight-qt) tag="v6.2.0" asset="Moonlight-6.2.0-x86_64.AppImage" ;; - mmatyas/pegasus-frontend) tag="continuous" asset="pegasus-frontend_0.16.109_amd64.deb" ;; *) echo "Unexpected release repository: $2" >&2; return 2 ;; esac [[ "$asset" =~ $3 ]] || { echo "Release pattern $3 does not match $asset" >&2; return 2; } VM_RELEASE_TAG="$tag" VM_RELEASE_VERSION="${tag#v}" VM_RELEASE_ASSET="$asset" VM_RELEASE_URL="https://example.invalid/$asset" VM_RELEASE_SHA256="" case "$2" in - LizardByte/Sunshine | moonlight-stream/moonlight-qt | mmatyas/pegasus-frontend) + moonlight-stream/moonlight-qt) VM_RELEASE_SHA256="$(printf 'a%.0s' {1..64})" ;; esac @@ -129,6 +127,7 @@ vm_image_cache_path() { } vm_fetch_image() { if [[ "$FAILURE" == download && "$1" == https://example.invalid/* ]]; then return 7; fi + if [[ "$FAILURE" == bashitdownload && "$1" == *Bash-it/bash-it/archive/* ]]; then return 7; fi printf 'mock-image' >"$2" echo "fetch $1" >>"$EVENTS" } @@ -223,6 +222,9 @@ curl() { previous="$arg" done case "$url" in + https://github.com/Bash-it/bash-it/releases/latest) + [[ "$FAILURE" != bashit ]] || return 7 + payload='/Bash-it/bash-it/releases/tag/v3.2.0' ;; */pve/vm-core.func) cat "$CORE" printf '\nsource %q\n' "$MOCK" @@ -309,7 +311,7 @@ for script in "$ROOT"/vm/*.sh; do ((last_custom < imported)) || fail "K3s customized after import" fi case "$slug" in - sunshine-vm | moonlight-vm) + moonlight-vm) [[ -s "$CASE_DIR/firstboot.sh" ]] || fail "$slug did not stage a parseable first-boot installer" grep -q '^firstboot-after-cloud-final$' "$EVENTS" || fail "$slug first boot is not ordered after Cloud-Init" ! grep -Eq 'qm set .*--(hostpci|usb)[0-9]' "$EVENTS" || fail "$slug assigned devices without an explicit selection" @@ -328,7 +330,7 @@ for script in "$ROOT"/vm/*.sh; do done failures=(description start cache) case "$slug" in - k3s-vm | netbird-server | ubuntu-vm | unifi-os-server-vm | waydroid-vm | sunshine-vm | moonlight-vm) failures+=(ssh) ;; + k3s-vm | netbird-server | ubuntu-vm | unifi-os-server-vm | waydroid-vm | moonlight-vm) failures+=(ssh) ;; esac case "$slug" in blissos-vm | cachyos-vm | nextcloud-vm | owncloud-vm | umbrel-os-vm | zimaos-vm) ;; @@ -359,8 +361,8 @@ for script in "$ROOT"/vm/*.sh; do failures_checked=$((failures_checked + 1)) done case "$slug" in - sunshine-vm | moonlight-vm) - for invalid in gpu machine user digest download customize; do + moonlight-vm) + for invalid in gpu machine user digest download customize bashit bashitdownload resetguard; do CASE_DIR="$TEST_DIR/$slug-invalid-$invalid" mkdir "$CASE_DIR" EVENTS="$CASE_DIR/events" @@ -373,6 +375,8 @@ for script in "$ROOT"/vm/*.sh; do digest) overrides=(FAILURE=digest) ;; download) overrides=(FAILURE=download) ;; customize) overrides=(FAILURE=customize) ;; + bashit | bashitdownload) overrides=(FAILURE="$invalid") ;; + resetguard) overrides=(VM_RAVEN_RESET_GUARD=yes) ;; esac rc=0 env BASH_ENV="$MOCK" VM_UNATTENDED=1 VM_START=no VM_ARCH=amd64 \ diff --git a/json/moonlight-vm.json b/json/moonlight-vm.json index adc7ce4879..92113185d7 100644 --- a/json/moonlight-vm.json +++ b/json/moonlight-vm.json @@ -23,7 +23,7 @@ "script": "vm/moonlight-vm.sh", "config_path": "", "resources": { - "cpu": 2, + "cpu": 4, "ram": 2048, "hdd": 16, "os": "Debian", diff --git a/json/sunshine-vm.json b/json/sunshine-vm.json deleted file mode 100644 index 459a1f7c67..0000000000 --- a/json/sunshine-vm.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "Sunshine", - "slug": "sunshine-vm", - "categories": [ - 24, - 13 - ], - "date_created": "2026-10-27", - "type": "vm", - "updateable": false, - "privileged": false, - "architectures": [ - "amd64" - ], - "interface_port": 47990, - "documentation": "https://docs.lizardbyte.dev/projects/sunshine/", - "website": "https://app.lizardbyte.dev", - "repository": "https://github.com/LizardByte/Sunshine", - "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/sunshine.webp", - "description": "Sunshine streams games and graphical desktops to Moonlight clients using hardware video encoding. It provides a web interface for applications, credentials and client pairing.", - "install_methods": [ - { - "type": "default", - "script": "vm/sunshine-vm.sh", - "config_path": "", - "resources": { - "cpu": 8, - "ram": 8192, - "hdd": 64, - "os": "Debian", - "version": "13" - } - } - ], - "default_credentials": { - "username": "debian", - "password": null - }, - "notes": [ - { - "text": "Wait for sunshine-firstboot.service, then reboot the guest. Sign in with your Cloud-Init user (debian by default).", - "type": "info" - }, - { - "text": "GPU output needs a monitor/dummy. Dummy-only operation requires VM_DESKTOP_AUTOLOGIN=yes; GPU output is unavailable in noVNC.", - "type": "info" - }, - { - "text": "After graphical login, open https://:47990 and set credentials. Pegasus is an optional alpha build.", - "type": "info" - } - ], - "platforms": [ - "pve" - ] -} diff --git a/vm/moonlight-vm.sh b/vm/moonlight-vm.sh index c6b739407f..c59d429785 100644 --- a/vm/moonlight-vm.sh +++ b/vm/moonlight-vm.sh @@ -64,7 +64,7 @@ function select_client_options() { } function validate_gpu_passthrough() { - local address="${VM_GPU_PCI:-}" device function member class config value slot driver + local address="${VM_GPU_PCI:-}" device member class config value slot driver GPU_SLOT="" [[ -z "$address" || "$address" == none ]] && return 0 [[ "$address" =~ ^([[:xdigit:]]{4}:)?[[:xdigit:]]{2}:[[:xdigit:]]{2}\.[0-7]$ ]] || { @@ -79,28 +79,32 @@ function validate_gpu_passthrough() { return 1 } slot="${address%.*}" - for function in /sys/bus/pci/devices/"$slot".*; do - [[ -e "$function/iommu_group" ]] || { - msg_error "No IOMMU group for ${function##*/}. Enable VT-d/AMD-Vi in firmware and prepare PCI passthrough first: https://pve.proxmox.com/wiki/PCI_Passthrough" - return 1 - } - [[ ! -r "$function/boot_vga" || "$(<"$function/boot_vga")" != 1 ]] || { - msg_error "${function##*/} is the boot display. Configure this GPU manually to protect the host console." + [[ -e "$device/iommu_group" ]] || { + msg_error "No IOMMU group for $address. Enable VT-d/AMD-Vi in firmware and prepare PCI passthrough first: https://pve.proxmox.com/wiki/PCI_Passthrough" + return 1 + } + [[ ! -r "$device/boot_vga" || "$(<"$device/boot_vga")" != 1 || "${VM_ALLOW_BOOT_GPU:-no}" == yes ]] || { + msg_error "$address is the host boot display. Deliberate use requires VM_ALLOW_BOOT_GPU=yes and an independent SSH management connection." + return 1 + } + driver="$(readlink "$device/driver" 2>/dev/null || true)" + [[ "${driver##*/}" == vfio-pci ]] || { + msg_error "$address must already use vfio-pci. Prepare this specific device using the Proxmox PCI passthrough guide, or choose no GPU. Host drivers and boot settings are not changed here." + return 1 + } + for member in "$device"/iommu_group/devices/*; do + [[ -r "$member/class" ]] || return 1 + class="$(<"$member/class")" + [[ "${member##*/}" == "$slot".* || "$class" == 0x0604* ]] || { + msg_error "IOMMU group also contains ${member##*/}. Select an isolated GPU; ACS overrides are not configured." return 1 } - driver="$(readlink "$function/driver" 2>/dev/null || true)" + [[ "$class" == 0x0604* ]] && continue + driver="$(readlink "$member/driver" 2>/dev/null || true)" [[ "${driver##*/}" == vfio-pci ]] || { - msg_error "${function##*/} must already use vfio-pci. Prepare this specific device using the Proxmox PCI passthrough guide, or choose no GPU. Host drivers and boot settings are not changed here." + msg_error "IOMMU group member ${member##*/} must also use vfio-pci." return 1 } - for member in "$function"/iommu_group/devices/*; do - [[ -r "$member/class" ]] || return 1 - class="$(<"$member/class")" - [[ "${member##*/}" == "$slot".* || "$class" == 0x0604* ]] || { - msg_error "IOMMU group also contains ${member##*/}. Select an isolated GPU; ACS overrides are not configured." - return 1 - } - done done for config in /etc/pve/qemu-server/*.conf; do [[ -f "$config" ]] || continue @@ -119,7 +123,7 @@ function validate_gpu_passthrough() { fi done < <(sed -n 's/^hostpci[0-9][0-9]*: *//p' "$config") done - GPU_SLOT="$slot" + GPU_SLOT="$address" } function validate_usb_passthrough() { @@ -170,14 +174,31 @@ function select_passthrough() { label="$(lspci -D -s "$address" -nn)" choices+=("$address" "$label" OFF) done - vm_dialog radiolist "GPU PASSTHROUGH" "Select one prepared GPU. Every function in its PCI slot will be dedicated to this VM. No GPU is selected automatically." 18 90 8 "${choices[@]}" || exit_script + vm_dialog radiolist "GPU PASSTHROUGH" "Select one prepared display function. Other PCI functions are not attached. No GPU is selected automatically." 18 90 8 "${choices[@]}" || exit_script VM_GPU_PCI="$VM_DIALOG_RESULT" fi + address="${VM_GPU_PCI:-none}" + [[ "$address" == ????:??:??.? ]] || address="0000:$address" + if [[ -z "${VM_ALLOW_BOOT_GPU:-}" && "${VM_UNATTENDED:-0}" != 1 && + -r "/sys/bus/pci/devices/$address/boot_vga" && "$(<"/sys/bus/pci/devices/$address/boot_vga")" == 1 ]]; then + vm_dialog yesno "HOST BOOT GPU" "This GPU is the host boot display. Passing it through removes the local host console. Continue only with working SSH management and an intentionally VFIO-bound GPU. Use this boot GPU?" 13 80 || exit_script + VM_ALLOW_BOOT_GPU=yes + fi + [[ "${VM_ALLOW_BOOT_GPU:-no}" =~ ^(yes|no)$ ]] || { msg_error "VM_ALLOW_BOOT_GPU must be yes or no."; return 1; } validate_gpu_passthrough || return 1 - if [[ -n "$GPU_SLOT" && -r "/sys/bus/pci/devices/$GPU_SLOT.0/vendor" ]] && - [[ "$(<"/sys/bus/pci/devices/$GPU_SLOT.0/vendor")" == 0x10de ]]; then + if [[ -n "$GPU_SLOT" && -r "/sys/bus/pci/devices/$GPU_SLOT/vendor" ]] && + [[ "$(<"/sys/bus/pci/devices/$GPU_SLOT/vendor")" == 0x10de ]]; then msg_warn "Install a compatible NVIDIA driver inside the guest before testing hardware encoding/decoding; this installer stages AMD/Intel Mesa drivers." fi + if [[ -z "${VM_RAVEN_RESET_GUARD:-}" && "${VM_UNATTENDED:-0}" != 1 && -n "$GPU_SLOT" && + "$(cat "/sys/bus/pci/devices/$GPU_SLOT/vendor")" == 0x1002 && + "$(cat "/sys/bus/pci/devices/$GPU_SLOT/device")" == 0x15dd ]]; then + vm_dialog radiolist "RAVEN RIDGE RESET GUARD" "Optional workaround for AMD 1002:15dd: disable this display function's PCI reset methods now and before VM autostart on host boot. This persists as a host systemd service and does not guarantee all restart scenarios." 16 86 2 \ + no "Keep the host reset behavior" ON yes "Install this device-specific workaround" OFF || exit_script + VM_RAVEN_RESET_GUARD="$VM_DIALOG_RESULT" + fi + VM_RAVEN_RESET_GUARD="${VM_RAVEN_RESET_GUARD:-no}" + validate_raven_reset_guard || return 1 if [[ "$NSAPP" == moonlight-vm ]]; then if [[ -z "${VM_USB_PORTS:-}" && "${VM_UNATTENDED:-0}" != 1 ]]; then summary="USB devices by physical port (none are preselected):" @@ -203,11 +224,9 @@ function select_passthrough() { return 1 } else - summary="Dedicated GPU slot: ${GPU_SLOT:-none}\nUSB ports: ${VM_USB_PORTS:-none}\n" + summary="Dedicated display function: ${GPU_SLOT:-none}\nUSB ports: ${VM_USB_PORTS:-none}\nRaven Ridge reset guard: $VM_RAVEN_RESET_GUARD\n" if [[ -n "$GPU_SLOT" ]]; then - for device in /sys/bus/pci/devices/"$GPU_SLOT".*; do - summary+="\n$(lspci -D -s "${device##*/}" -nn)" - done + summary+="\n$(lspci -D -s "$GPU_SLOT" -nn)" fi vm_dialog yesno "CONFIRM DEVICE ASSIGNMENT" "$summary\n\nThese devices become exclusive to this VM. GPU output requires a connected monitor/dummy and is unavailable in noVNC. SSH and serial console remain available. Apply these assignments to the new VM?" 21 90 || exit_script fi @@ -222,13 +241,65 @@ function attach_passthrough() { validate_usb_passthrough || return 1 fi if [[ -n "$GPU_SLOT" ]]; then - qm set "$VMID" --hostpci0 "$GPU_SLOT,pcie=1" --vga none >/dev/null + qm set "$VMID" --hostpci0 "$GPU_SLOT,pcie=1" --vga serial0 >/dev/null fi for index in "${!USB_PORTS[@]}"; do qm set "$VMID" "--usb$index" "host=${USB_PORTS[$index]},usb3=1" >/dev/null done } +function validate_raven_reset_guard() { + [[ "$VM_RAVEN_RESET_GUARD" =~ ^(yes|no)$ ]] || { msg_error "VM_RAVEN_RESET_GUARD must be yes or no."; return 1; } + [[ "$VM_RAVEN_RESET_GUARD" == yes ]] || return 0 + [[ -n "$GPU_SLOT" && -r "/sys/bus/pci/devices/$GPU_SLOT/reset_method" && + "$(cat "/sys/bus/pci/devices/$GPU_SLOT/vendor")" == 0x1002 && + "$(cat "/sys/bus/pci/devices/$GPU_SLOT/device")" == 0x15dd ]] || { + msg_error "The reset guard requires an explicitly selected AMD Raven Ridge 1002:15dd display function with reset_method support." + return 1 + } +} + +function install_raven_reset_guard() { + [[ "$VM_RAVEN_RESET_GUARD" == yes ]] || return 0 + validate_raven_reset_guard || return 1 + local script="/usr/local/sbin/moonlight-raven-reset-$VMID" unit="moonlight-raven-reset-$VMID.service" + [[ ! -e "$script" && ! -e "/etc/systemd/system/$unit" ]] || { + msg_error "A Moonlight reset guard already exists for VM $VMID; inspect it before retrying." + return 1 + } + msg_info "Installing Moonlight's Raven Ridge reset guard" + cat >"$script" <"\$DEVICE/reset_method" +fi +test -z "\$(cat "\$DEVICE/reset_method")" +GUARD + chmod 0755 "$script" + cat >"/etc/systemd/system/$unit" </dev/null 2>&1 && break + sleep 2 +done -if [ -f /etc/apt/sources.list.d/debian.sources ]; then - grep -q non-free-firmware /etc/apt/sources.list.d/debian.sources || - sed -i 's/^Components:.*/& non-free-firmware/' /etc/apt/sources.list.d/debian.sources -else - grep -q non-free-firmware /etc/apt/sources.list 2>/dev/null || - sed -i '/^deb / s/\$/ non-free-firmware/' /etc/apt/sources.list -fi +for component in contrib non-free non-free-firmware; do + if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then + sed -i -E "/^Components:/ { /(^|[[:space:]])\${component}([[:space:]]|\$)/! s/\$/ \${component}/; }" /etc/apt/sources.list.d/debian.sources + fi + if [[ -f /etc/apt/sources.list ]]; then + sed -i -E "/^deb / { /(^|[[:space:]])\${component}([[:space:]]|\$)/! s/\$/ \${component}/; }" /etc/apt/sources.list + fi +done apt -o Acquire::Retries=3 update - -apt install -y \\ - qemu-guest-agent curl ca-certificates \\ - xorg xfce4 lightdm lightdm-gtk-greeter dbus-x11 polkitd \\ - pipewire wireplumber pipewire-pulse pipewire-alsa \\ - mesa-va-drivers mesa-vulkan-drivers intel-media-va-driver i965-va-driver \\ +apt install -y \ + qemu-guest-agent curl ca-certificates libfuse2t64 \ + xserver-xorg-core xserver-xorg-input-libinput xserver-xorg-video-amdgpu \ + xinit openbox x11-xserver-utils wmctrl xauth dbus-user-session unclutter-xfixes adwaita-icon-theme fonts-dejavu-core git \ + pipewire wireplumber pipewire-pulse pipewire-alsa \ + mesa-va-drivers mesa-vulkan-drivers intel-media-va-driver \ firmware-amd-graphics firmware-intel-graphics firmware-misc-nonfree firmware-sof-signed vainfo vulkan-tools systemctl enable --now qemu-guest-agent getent group render >/dev/null || groupadd -r render -usermod -aG video,render "\$GUEST_USER" +usermod -s /bin/bash -aG audio,video,render,input,plugdev "\$GUEST_USER" systemctl --global enable pipewire.socket pipewire-pulse.socket wireplumber.service -if [ ! -x /opt/moonlight/squashfs-root/AppRun ]; then - cd /opt/moonlight - ./"${VM_RELEASE_ASSET}" --appimage-extract - rm -f ./*.AppImage +USER_HOME="\$(getent passwd "\$GUEST_USER" | cut -d: -f6)" +if [[ -z "\$USER_HOME" || ! -d "\$USER_HOME" ]]; then + echo "Home directory for \$GUEST_USER is missing" >&2 + exit 1 +fi + +STAGED_APPIMAGE="/opt/moonlight/${VM_RELEASE_ASSET}" +APPIMAGE=/opt/moonlight/Moonlight.AppImage +if [[ ! -f \$APPIMAGE && -f \$STAGED_APPIMAGE && \$STAGED_APPIMAGE != "\$APPIMAGE" ]]; then + mv "\$STAGED_APPIMAGE" "\$APPIMAGE" +fi +if [[ ! -f \$APPIMAGE ]]; then + echo "Moonlight AppImage is missing from /opt/moonlight" >&2 + exit 1 fi +chmod 0755 "\$APPIMAGE" cat >/opt/moonlight/version.txt <<'VERSION' ${VM_RELEASE_VERSION} VERSION +if [[ ! -f /etc/moonlight-kiosk.conf ]]; then + cat > /etc/moonlight-kiosk.conf <<'KCONF' +# Moonlight kiosk defaults read by /usr/local/bin/moonlight. +# Edit values here, or use MOONLIGHT_KIOSK_CONF to select a different file. +PREFER_VULKAN=1 +QT_SCALE_FACTOR=2.5 +KCONF +fi + cat > /usr/local/bin/moonlight <<'LAUNCHER' #!/bin/sh -exec /opt/moonlight/squashfs-root/AppRun "\$@" +set -u +_MOONLIGHT_CONF="\${MOONLIGHT_KIOSK_CONF:-/etc/moonlight-kiosk.conf}" +if [ -r "\$_MOONLIGHT_CONF" ]; then + . "\$_MOONLIGHT_CONF" +fi +: "\${PREFER_VULKAN:=1}" +: "\${QT_SCALE_FACTOR:=2.5}" +export PREFER_VULKAN QT_SCALE_FACTOR +if [ -z "\${LIBVA_DRIVER_NAME:-}" ]; then + for _drv in /sys/bus/pci/devices/*/driver; do + _driver=\$(readlink "\$_drv" 2>/dev/null || true) + if [ "\${_driver##*/}" = amdgpu ]; then + LIBVA_DRIVER_NAME=radeonsi + export LIBVA_DRIVER_NAME + break + fi + done + unset _drv _driver +fi +exec /opt/moonlight/Moonlight.AppImage "\$@" LAUNCHER chmod 0755 /usr/local/bin/moonlight @@ -389,7 +509,7 @@ cat > /usr/share/applications/com.moonlight_stream.Moonlight.desktop <<'DESKTOP' [Desktop Entry] Name=Moonlight Comment=Stream games and other applications from another PC running Sunshine or GeForce Experience -Exec=/opt/moonlight/squashfs-root/AppRun +Exec=/usr/local/bin/moonlight Icon=moonlight Terminal=false Type=Application @@ -397,27 +517,156 @@ Categories=Game;Network;RemoteAccess; Keywords=nvidia;gamestream;stream;sunshine;remote play; DESKTOP -install -m 0644 -D /opt/moonlight/squashfs-root/moonlight.svg /usr/share/icons/hicolor/scalable/apps/moonlight.svg -gtk-update-icon-cache -f /usr/share/icons/hicolor 2>/dev/null || true +OPENBOX_DIR="\$USER_HOME/.config/openbox" +mkdir -p "\$OPENBOX_DIR" +if [[ -f \$OPENBOX_DIR/autostart && ! -e \$OPENBOX_DIR/autostart.bak ]]; then + cp -a "\$OPENBOX_DIR/autostart" "\$OPENBOX_DIR/autostart.bak" +fi +cat > "\$OPENBOX_DIR/autostart" <<'AUTOSTART' +#!/bin/sh +# Managed by moonlight-setup: Moonlight kiosk session (original backed up as autostart.bak). +if command -v xset >/dev/null 2>&1; then + xset s off + xset s noblank + xset -dpms +fi +if command -v xrandr >/dev/null 2>&1; then + _output=\$(xrandr --query 2>/dev/null | awk '\$2 == "connected" {print \$1; exit}') + if [ -n "\${_output}" ]; then + if xrandr --query 2>/dev/null | awk -v out="\${_output}" ' + \$1 == out && \$2 == "connected" {want = 1; next} + /^[A-Za-z].*(connected|disconnected)/ {want = 0} + want && \$1 == "3840x2160" && / 60\.00([*+]| |\$)/ {found = 1} + END {exit !found}'; then + xrandr --output "\${_output}" --mode 3840x2160 --rate 60 2>/dev/null || true + fi + fi + unset _output +fi +if command -v unclutter >/dev/null 2>&1; then + unclutter --timeout 1 --start-hidden --fork +fi +AUTOSTART +if [[ \$AUTOSTART == yes ]]; then + cat >> "\$OPENBOX_DIR/autostart" <<'AUTOSTART_MOONLIGHT' +mkdir -p "\$HOME/.local/state" +( while :; do + /usr/local/bin/moonlight >>"\$HOME/.local/state/moonlight-kiosk.log" 2>&1 + sleep 3 +done ) & +if command -v wmctrl >/dev/null 2>&1; then + ( while :; do + wmctrl -r Moonlight -b add,fullscreen 2>/dev/null || true + sleep 3 + done ) & +fi +AUTOSTART_MOONLIGHT +fi +chmod 0755 "\$OPENBOX_DIR/autostart" +chown -R "\$GUEST_USER:\$GUEST_USER" "\$OPENBOX_DIR" -if [ "\$AUTOSTART" = yes ]; then - install -m 0644 -D /usr/share/applications/com.moonlight_stream.Moonlight.desktop "/home/\$GUEST_USER/.config/autostart/com.moonlight_stream.Moonlight.desktop" - chown "\$GUEST_USER:\$GUEST_USER" "/home/\$GUEST_USER/.config" - chown -R "\$GUEST_USER:\$GUEST_USER" "/home/\$GUEST_USER/.config/autostart" +if [[ -f \$USER_HOME/.xinitrc && ! -e \$USER_HOME/.xinitrc.bak ]]; then + cp -a "\$USER_HOME/.xinitrc" "\$USER_HOME/.xinitrc.bak" +fi +cat > "\$USER_HOME/.xinitrc" <<'XINITRC' +#!/bin/sh +# Managed by moonlight-setup: minimal X session for the Moonlight kiosk. +export XCURSOR_THEME=Adwaita +export XCURSOR_SIZE=64 +if [ -r "\$HOME/.Xresources" ]; then + xrdb -merge "\$HOME/.Xresources" +fi +exec openbox-session +XINITRC +chmod 0755 "\$USER_HOME/.xinitrc" +chown "\$GUEST_USER:\$GUEST_USER" "\$USER_HOME/.xinitrc" + +KIOSK_MARKER="# moonlight-kiosk: start X on tty1" +if [[ -f \$USER_HOME/.bash_profile ]] && ! grep -qF "\$KIOSK_MARKER" "\$USER_HOME/.bash_profile"; then + [[ -e \$USER_HOME/.bash_profile.bak ]] || cp -a "\$USER_HOME/.bash_profile" "\$USER_HOME/.bash_profile.bak" +fi +if [[ ! -f \$USER_HOME/.bash_profile ]]; then + cat > "\$USER_HOME/.bash_profile" <<'PROFILE' +# Created by moonlight-setup: keep the default login chain, then start the kiosk on tty1. +if [ -r "\$HOME/.profile" ]; then + . "\$HOME/.profile" +fi +PROFILE +fi +if ! grep -qF "\$KIOSK_MARKER" "\$USER_HOME/.bash_profile"; then + cat >> "\$USER_HOME/.bash_profile" <<'PROFILE_KIOSK' +# moonlight-kiosk: start X on tty1 +if [ -z "\${DISPLAY:-}" ] && [ "\$(tty)" = /dev/tty1 ]; then + exec startx -- -nolisten tcp fi +PROFILE_KIOSK +fi +chown "\$GUEST_USER:\$GUEST_USER" "\$USER_HOME/.bash_profile" -if [ "\$AUTOLOGIN" = yes ]; then - mkdir -p /etc/lightdm/lightdm.conf.d - cat > /etc/lightdm/lightdm.conf.d/12-autologin.conf <<'LIGHTDM' -[Seat:*] -autologin-user=${CLOUDINIT_USER} -autologin-user-timeout=0 -LIGHTDM +BASH_IT_SRC=/opt/moonlight/bash-it.tar.gz +BASH_IT_DIR="\$USER_HOME/.bash_it" +if [[ ! -d \$BASH_IT_DIR ]]; then + if [[ ! -f \$BASH_IT_SRC ]]; then + echo "Bash-it source \$BASH_IT_SRC is missing" >&2 + exit 1 + fi + BASH_IT_STAGE=\$(mktemp -d "\$USER_HOME/.bash_it.XXXXXX") + tar -xzf "\$BASH_IT_SRC" -C "\$BASH_IT_STAGE" --strip-components=1 + chown -R "\$GUEST_USER:\$GUEST_USER" "\$BASH_IT_STAGE" + mv "\$BASH_IT_STAGE" "\$BASH_IT_DIR" +fi +if [[ ! -f \$BASH_IT_DIR/install.sh ]]; then + echo "Bash-it installer is missing from \$BASH_IT_DIR" >&2 + exit 1 +fi +runuser -u "\$GUEST_USER" -- env HOME="\$USER_HOME" BASH_IT="\$BASH_IT_DIR" bash "\$BASH_IT_DIR/install.sh" --silent --no-modify-config +BASHIT_MARKER="# moonlight-kiosk: Bash-it with the pure theme" +if [[ -f \$USER_HOME/.bashrc ]] && ! grep -qF "\$BASHIT_MARKER" "\$USER_HOME/.bashrc"; then + [[ -e \$USER_HOME/.bashrc.bak ]] || cp -a "\$USER_HOME/.bashrc" "\$USER_HOME/.bashrc.bak" +fi +[[ -f \$USER_HOME/.bashrc ]] || touch "\$USER_HOME/.bashrc" +if ! grep -qF "\$BASHIT_MARKER" "\$USER_HOME/.bashrc"; then + cat >> "\$USER_HOME/.bashrc" <<'BASHRC_SNIPPET' +# moonlight-kiosk: Bash-it with the pure theme +if [ -d "\$HOME/.bash_it" ]; then + export BASH_IT="\$HOME/.bash_it" + export BASH_IT_THEME=pure + if [ -r "\$BASH_IT/bash_it.sh" ]; then + . "\$BASH_IT/bash_it.sh" + fi +fi +BASHRC_SNIPPET +fi +chown "\$GUEST_USER:\$GUEST_USER" "\$USER_HOME/.bashrc" + +GETTY_DROPIN=/etc/systemd/system/getty@tty1.service.d/12-autologin.conf +if [[ \$AUTOLOGIN == yes ]]; then + mkdir -p /etc/systemd/system/getty@tty1.service.d + cat > "\$GETTY_DROPIN" <<'GETTY' +# Managed by moonlight-setup: opt-in console autologin for the kiosk user. +[Service] +ExecStart= +ExecStart=-/sbin/agetty --autologin ${CLOUDINIT_USER} --noclear %I \$TERM +GETTY + systemctl daemon-reload +elif [[ -f \$GETTY_DROPIN ]] && grep -q "Managed by moonlight-setup" "\$GETTY_DROPIN" 2>/dev/null; then + rm -f "\$GETTY_DROPIN" + systemctl daemon-reload +fi +systemctl enable getty@tty1.service + +mkdir -p /etc/default/grub.d +cat > /etc/default/grub.d/60-moonlight-kiosk.cfg <<'GRUB' +# Managed by moonlight-setup: keep video and serial consoles, disable console blanking. +GRUB_CMDLINE_LINUX="\${GRUB_CMDLINE_LINUX:+\$GRUB_CMDLINE_LINUX }console=tty0 console=ttyS0,115200 consoleblank=0" +GRUB +if command -v update-grub >/dev/null 2>&1; then + update-grub fi -systemctl enable --now lightdm -systemctl set-default graphical.target -echo "[\$(date)] Moonlight setup complete" +systemctl enable serial-getty@ttyS0.service +systemctl set-default multi-user.target +echo "[\$(date '+%F %T')] Moonlight setup complete" FIRSTBOOT vm_firstboot_unit "$WORK_FILE" moonlight-setup "$MOONLIGHT_SETUP_TMP" \ @@ -453,6 +702,7 @@ fi msg_ok "Created a Debian 13 Moonlight VM ${CL}${BL}(${HN})" attach_passthrough +install_raven_reset_guard vm_start_vm "Moonlight VM" vm_wait_for_ip 120 || true @@ -462,16 +712,21 @@ vm_print_summary \ "GPU=${GPU_SLOT:-not assigned}" \ "OS=Debian 13 (Trixie)" \ "Moonlight=${VM_RELEASE_VERSION} (installs on first boot)" \ - "Desktop=Xfce + LightDM (user ${CLOUDINIT_USER})" \ + "Session=tty1 -> Xorg -> Openbox -> Moonlight (user ${CLOUDINIT_USER})" \ "USB=${VM_USB_PORTS:-not assigned}" \ "Autostart=${VM_CLIENT_AUTOSTART}" \ "Autologin=${VM_DESKTOP_AUTOLOGIN}" \ + "RavenResetGuard=${VM_RAVEN_RESET_GUARD}" \ "Pairing=In the Moonlight UI (enter the PIN in Sunshine)" \ "Documentation=https://moonlight-stream.org/" vm_next_steps \ - "Creating the VM does not mean the guest setup finished: the desktop and Moonlight install on first boot; follow it with: tail -f /var/log/moonlight-setup.log (unit: systemctl status moonlight-setup)." \ + "Wait for Moonlight setup: journalctl -u moonlight-setup -f (log: /var/log/moonlight-setup.log). Success marker: /var/lib/community-scripts/moonlight-setup.done." \ "After the first boot completes, reboot the guest once before judging GPU behavior: firmware installed that late is picked up on the next boot. No host reboot is needed." \ - "Log in on the GPU-connected monitor as ${CLOUDINIT_USER} (noVNC works only without GPU assignment) with the Cloud-Init password (or straight into the desktop when autologin is on) and pair from the Moonlight UI: add the Sunshine host, then enter the PIN shown here in Sunshine." \ - "For a capable display and decoder, select 3840x2160 at 60 FPS and HEVC in Moonlight. Select the guest HDMI/DP or passed-through USB audio output and check the performance overlay." \ + "On tty1, sign in as ${CLOUDINIT_USER} to start the kiosk, or select VM_DESKTOP_AUTOLOGIN=yes for appliance use. SSH and Proxmox's serial console remain text-only management sessions. GPU output needs a connected monitor/TV." \ + "In the Moonlight GUI, add the Sunshine host and enter the displayed PIN in Sunshine. Select 3840x2160, 60 FPS and HEVC; verify hardware decoding, controller navigation in the GUI/stream and actual received FPS in the performance overlay." \ + "Kiosk configuration: /etc/moonlight-kiosk.conf. Launcher: /usr/local/bin/moonlight. Session log: ~/.local/state/moonlight-kiosk.log. The display prefers an available 4K60 mode; this does not guarantee 60 received stream frames per second." \ "A passed-through GPU is assigned through the wizard device options; NVIDIA cards additionally need the vendor driver installed in the guest as a follow-up." -vm_finish "VM created. Moonlight and the desktop install in the VM on first boot." +if [[ "$VM_RAVEN_RESET_GUARD" == yes ]]; then + vm_next_steps "Host workaround: moonlight-raven-reset-$VMID.service disables resets only for $GPU_SLOT. Check it after a host reboot; it is not a general AMD reset fix. To remove it, disable that unit and remove its unit file and /usr/local/sbin/moonlight-raven-reset-$VMID; the next host reboot restores kernel defaults." +fi +vm_finish "VM created. Moonlight kiosk installation continues in the VM on first boot." diff --git a/vm/sunshine-vm.sh b/vm/sunshine-vm.sh deleted file mode 100644 index 6a9a2eb4a3..0000000000 --- a/vm/sunshine-vm.sh +++ /dev/null @@ -1,604 +0,0 @@ -#!/usr/bin/env bash - -# Copyright (c) 2021-2026 community-scripts ORG -# Author: Tomblarom -# License: MIT | https://github.com/community-scripts/DevScripts/raw/main/LICENSE -# Source: https://app.lizardbyte.dev - -COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://raw.githubusercontent.com/community-scripts/DevScripts/main}" -source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/pve/vm-core.func") -load_functions - -APP="Sunshine" -APP_TYPE="vm" -NSAPP="sunshine-vm" -var_os="debian" -var_version="13" -GEN_MAC=02:$(openssl rand -hex 5 | awk '{print toupper($0)}' | sed 's/\(..\)/\1:/g; s/.$//') -RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)" -METHOD="" -THIN="discard=on,ssd=1," -USE_CLOUD_INIT="no" -CLOUDINIT_DEFAULT_USER="debian" -OS_LABEL="Debian 13 (Trixie)" -INSTALL_STEAM="" -INSTALL_DOLPHIN="" -INSTALL_PEGASUS="" -DESKTOP_AUTOLOGIN="" -SUNSHINE_TAG="" -SUNSHINE_FIRSTBOOT_MARKER="" - -header_info -echo -e "\n Loading..." - -set -Eeo pipefail -shopt -s inherit_errexit -trap 'error_handler $LINENO "$BASH_COMMAND"' ERR -trap cleanup EXIT -trap 'post_update_to_api "failed" "130"' SIGINT -trap 'post_update_to_api "failed" "143"' SIGTERM -trap 'post_update_to_api "failed" "129"; exit 129' SIGHUP - -vm_require_arch amd64 - -TEMP_DIR=$(mktemp -d) -pushd "$TEMP_DIR" >/dev/null - -vm_preflight -vm_require_tools virt-customize jq lspci - -function validate_gpu_passthrough() { - local address="${VM_GPU_PCI:-}" device function member class config value slot driver - GPU_SLOT="" - [[ -z "$address" || "$address" == none ]] && return 0 - [[ "$address" =~ ^([[:xdigit:]]{4}:)?[[:xdigit:]]{2}:[[:xdigit:]]{2}\.[0-7]$ ]] || { - msg_error "VM_GPU_PCI must name one GPU function, for example 0000:01:00.0." - return 1 - } - [[ "$address" == ????:??:??.? ]] || address="0000:$address" - address="${address,,}" - device="/sys/bus/pci/devices/$address" - [[ -r "$device/class" && "$(<"$device/class")" == 0x03* ]] || { - msg_error "The selected PCI function is not a display controller: $address" - return 1 - } - slot="${address%.*}" - for function in /sys/bus/pci/devices/"$slot".*; do - [[ -e "$function/iommu_group" ]] || { - msg_error "No IOMMU group for ${function##*/}. Enable VT-d/AMD-Vi in firmware and prepare PCI passthrough first: https://pve.proxmox.com/wiki/PCI_Passthrough" - return 1 - } - [[ ! -r "$function/boot_vga" || "$(<"$function/boot_vga")" != 1 ]] || { - msg_error "${function##*/} is the boot display. Configure this GPU manually to protect the host console." - return 1 - } - driver="$(readlink "$function/driver" 2>/dev/null || true)" - [[ "${driver##*/}" == vfio-pci ]] || { - msg_error "${function##*/} must already use vfio-pci. Prepare this specific device using the Proxmox PCI passthrough guide, or choose no GPU. Host drivers and boot settings are not changed here." - return 1 - } - for member in "$function"/iommu_group/devices/*; do - [[ -r "$member/class" ]] || return 1 - class="$(<"$member/class")" - [[ "${member##*/}" == "$slot".* || "$class" == 0x0604* ]] || { - msg_error "IOMMU group also contains ${member##*/}. Select an isolated GPU; ACS overrides are not configured." - return 1 - } - done - done - for config in /etc/pve/qemu-server/*.conf; do - [[ -f "$config" ]] || continue - while IFS= read -r value; do - [[ "$value" != *mapping=* ]] || { - msg_error "A local VM uses a PCI resource mapping. Check mapped-device ownership and attach the GPU manually in Proxmox." - return 1 - } - value="${value,,}" - # hostpci accepts domain-less addresses, individual functions and semicolon lists. - value="${value//0000:/}" - value="${value//host=/}" - if [[ "$value" =~ (^|[\;[:space:]])${slot#0000:}(\.[0-7])?([\;,[:space:]]|$) ]]; then - msg_error "The selected GPU is already assigned in ${config##*/}." - return 1 - fi - done < <(sed -n 's/^hostpci[0-9][0-9]*: *//p' "$config") - done - GPU_SLOT="$slot" -} - -function validate_usb_passthrough() { - local port interface class config value vendor product - USB_PORTS=() - [[ -z "${VM_USB_PORTS:-}" || "$VM_USB_PORTS" == none ]] && return 0 - [[ "$VM_USB_PORTS" != ,* && "$VM_USB_PORTS" != *, && "$VM_USB_PORTS" != *,,* ]] || return 1 - IFS=, read -r -a USB_PORTS <<<"$VM_USB_PORTS" - (( ${#USB_PORTS[@]} <= 14 )) || { msg_error "Select at most 14 USB ports."; return 1; } - local -A seen=() - for port in "${USB_PORTS[@]}"; do - [[ "$port" =~ ^[1-9][0-9]*-[1-9][0-9]*(\.[1-9][0-9]*)*$ && -r "/sys/bus/usb/devices/$port/idVendor" ]] || { - msg_error "USB port is invalid or disconnected: $port" - return 1 - } - [[ -z "${seen[$port]:-}" ]] || { msg_error "USB port selected twice: $port"; return 1; } - seen[$port]=1 - for interface in /sys/bus/usb/devices/"$port"/bDeviceClass /sys/bus/usb/devices/"$port":*/bInterfaceClass; do - [[ -r "$interface" ]] || continue - class="$(<"$interface")" - case "${class,,}" in - 08 | 09 | 02 | 0a) msg_error "USB storage, hubs and network devices are not supported by this picker: $port"; return 1 ;; - esac - done - vendor="$(<"/sys/bus/usb/devices/$port/idVendor")" - product="$(<"/sys/bus/usb/devices/$port/idProduct")" - for config in /etc/pve/qemu-server/*.conf; do - [[ -f "$config" ]] || continue - while IFS= read -r value; do - if [[ "$value" == *mapping=* || "$value" == *"host=$port,"* || "$value" == *"host=$port" || "$value" == *"host=$vendor:$product"* ]]; then - msg_error "USB port or matching USB ID may already be assigned in ${config##*/}. Check ownership manually." - return 1 - fi - done < <(sed -n 's/^usb[0-9][0-9]*: *//p' "$config") - done - done -} - -function select_passthrough() { - local device address label port summary="" vendor product - local -a choices=(none "Configure GPU later (no hardware acceleration promised)" ON) - GPU_SLOT="" - USB_PORTS=() - if [[ -z "${VM_GPU_PCI:-}" && "${VM_UNATTENDED:-0}" != 1 ]]; then - for device in /sys/bus/pci/devices/*; do - [[ -r "$device/class" && "$(<"$device/class")" == 0x03* ]] || continue - address="${device##*/}" - label="$(lspci -D -s "$address" -nn)" - choices+=("$address" "$label" OFF) - done - vm_dialog radiolist "GPU PASSTHROUGH" "Select one prepared GPU. Every function in its PCI slot will be dedicated to this VM. No GPU is selected automatically." 18 90 8 "${choices[@]}" || exit_script - VM_GPU_PCI="$VM_DIALOG_RESULT" - fi - validate_gpu_passthrough || return 1 - if [[ -n "$GPU_SLOT" && -r "/sys/bus/pci/devices/$GPU_SLOT.0/vendor" ]] && - [[ "$(<"/sys/bus/pci/devices/$GPU_SLOT.0/vendor")" == 0x10de ]]; then - msg_warn "Install a compatible NVIDIA driver inside the guest before testing hardware encoding/decoding; this installer stages AMD/Intel Mesa drivers." - fi - if [[ "$NSAPP" == moonlight-vm ]]; then - if [[ -z "${VM_USB_PORTS:-}" && "${VM_UNATTENDED:-0}" != 1 ]]; then - summary="USB devices by physical port (none are preselected):" - for device in /sys/bus/usb/devices/*/idVendor; do - port="${device%/idVendor}" - port="${port##*/}" - [[ "$port" =~ ^[1-9][0-9]*-[1-9][0-9]*(\.[1-9][0-9]*)*$ ]] || continue - vendor="$(<"$device")" - product="$(<"${device%/idVendor}/idProduct")" - label="" - [[ ! -r "${device%/idVendor}/product" ]] || label="$(<"${device%/idVendor}/product")" - summary+="\n$port $vendor:$product $label" - done - vm_dialog inputbox "USB PASSTHROUGH" "$summary\n\nEnter comma-separated ports, for example 1-2,1-3.4, or none. They become exclusive to the VM; reconnecting another device to the same port also passes it through." 20 90 none || exit_script - VM_USB_PORTS="$VM_DIALOG_RESULT" - fi - validate_usb_passthrough || return 1 - fi - if [[ -n "$GPU_SLOT" || ${#USB_PORTS[@]} -gt 0 ]]; then - if [[ "${VM_UNATTENDED:-0}" == 1 ]]; then - [[ "${VM_PASSTHROUGH_CONFIRM:-no}" == yes ]] || { - msg_error "Unattended device assignment requires VM_PASSTHROUGH_CONFIRM=yes." - return 1 - } - else - summary="Dedicated GPU slot: ${GPU_SLOT:-none}\nUSB ports: ${VM_USB_PORTS:-none}\n" - if [[ -n "$GPU_SLOT" ]]; then - for device in /sys/bus/pci/devices/"$GPU_SLOT".*; do - summary+="\n$(lspci -D -s "${device##*/}" -nn)" - done - fi - vm_dialog yesno "CONFIRM DEVICE ASSIGNMENT" "$summary\n\nThese devices become exclusive to this VM. GPU output requires a connected monitor/dummy and is unavailable in noVNC. SSH and serial console remain available. Apply these assignments to the new VM?" 21 90 || exit_script - fi - fi -} - -function attach_passthrough() { - local index - # Recheck after downloading/provisioning, before changing device ownership. - validate_gpu_passthrough || return 1 - if [[ "$NSAPP" == moonlight-vm ]]; then - validate_usb_passthrough || return 1 - fi - if [[ -n "$GPU_SLOT" ]]; then - qm set "$VMID" --hostpci0 "$GPU_SLOT,pcie=1" --vga none >/dev/null - fi - for index in "${!USB_PORTS[@]}"; do - qm set "$VMID" "--usb$index" "host=${USB_PORTS[$index]},usb3=1" >/dev/null - done -} - -function default_settings() { - VMID=$(get_valid_nextid) - vm_apply_machine_type "q35" - DISK_SIZE="64G" - DISK_CACHE="" - HN="sunshine" - CPU_TYPE=" -cpu host" - CORE_COUNT="8" - RAM_SIZE="8192" - BRG="vmbr0" - MAC="$GEN_MAC" - VLAN="" - MTU="" - START_VM="yes" - METHOD="default" - vm_echo_default_settings -} - -function advanced_settings() { - METHOD="advanced" - vm_prompt_vmid "${VMID:-$(get_valid_nextid)}" - vm_prompt_machine_type "q35" - vm_prompt_disk_size "64G" "Set Disk Size in GiB (min. 64 recommended)" - vm_prompt_disk_cache "none" - vm_prompt_hostname "sunshine" - vm_prompt_cpu_model "host" - vm_prompt_cpu_cores "8" - vm_prompt_ram "8192" - vm_prompt_bridge "vmbr0" - vm_prompt_mac "$GEN_MAC" - vm_prompt_vlan - vm_prompt_mtu - vm_prompt_keyboard - vm_prompt_verbose "no" - vm_prompt_start_vm "yes" - - if vm_confirm_advanced_settings "Ready to create a Debian 13 Sunshine VM?"; then - echo -e "${CREATING}${BOLD}${DGN}Creating a Debian 13 Sunshine VM using the above advanced settings${CL}" - else - header_info - echo -e "${ADVANCED}${BOLD}${RD}Using Advanced Settings${CL}" - advanced_settings - fi -} - -function select_components() { - if [[ "${VM_UNATTENDED:-0}" == "1" ]]; then - INSTALL_STEAM="${VM_INSTALL_STEAM:-yes}" - INSTALL_DOLPHIN="${VM_INSTALL_DOLPHIN:-yes}" - INSTALL_PEGASUS="${VM_INSTALL_PEGASUS:-no}" - DESKTOP_AUTOLOGIN="${VM_DESKTOP_AUTOLOGIN:-no}" - else - if [[ -z "${VM_INSTALL_STEAM:-}" ]]; then - if vm_dialog radiolist "STEAM" \ - "Install Steam from Debian packages? (adds contrib and i386)" 11 68 2 \ - "yes" "Install Steam" ON \ - "no" "Skip Steam" OFF; then - INSTALL_STEAM="$VM_DIALOG_RESULT" - else - exit_script - fi - else - INSTALL_STEAM="$VM_INSTALL_STEAM" - fi - if [[ -z "${VM_INSTALL_DOLPHIN:-}" ]]; then - if vm_dialog radiolist "DOLPHIN" \ - "Install Dolphin (GameCube/Wii) from Debian packages?" 11 68 2 \ - "yes" "Install Dolphin" ON \ - "no" "Skip Dolphin" OFF; then - INSTALL_DOLPHIN="$VM_DIALOG_RESULT" - else - exit_script - fi - else - INSTALL_DOLPHIN="$VM_INSTALL_DOLPHIN" - fi - if [[ -z "${VM_INSTALL_PEGASUS:-}" ]]; then - if vm_dialog radiolist "PEGASUS" \ - "Install Pegasus Frontend alpha from its continuous build?" 11 68 2 \ - "no" "Skip Pegasus" ON \ - "yes" "Install Pegasus (alpha)" OFF; then - INSTALL_PEGASUS="$VM_DIALOG_RESULT" - else - exit_script - fi - else - INSTALL_PEGASUS="$VM_INSTALL_PEGASUS" - fi - if [[ -z "${VM_DESKTOP_AUTOLOGIN:-}" ]]; then - if vm_dialog radiolist "DESKTOP AUTOLOGIN" \ - "Log the debian user into Plasma automatically?" 11 68 2 \ - "no" "Ask for credentials" ON \ - "yes" "Autologin as debian" OFF; then - DESKTOP_AUTOLOGIN="$VM_DIALOG_RESULT" - else - exit_script - fi - else - DESKTOP_AUTOLOGIN="$VM_DESKTOP_AUTOLOGIN" - fi - fi - - local var - for var in INSTALL_STEAM INSTALL_DOLPHIN INSTALL_PEGASUS DESKTOP_AUTOLOGIN; do - if [[ "${!var}" != "yes" && "${!var}" != "no" ]]; then - msg_error "Invalid ${var}=${!var} (expected yes or no)" - exit 1 - fi - done - echo -e "${INFO}${BOLD}${DGN}Steam: ${BGN}${INSTALL_STEAM}${CL}" - echo -e "${INFO}${BOLD}${DGN}Dolphin: ${BGN}${INSTALL_DOLPHIN}${CL}" - echo -e "${INFO}${BOLD}${DGN}Pegasus: ${BGN}${INSTALL_PEGASUS}${CL}" - echo -e "${INFO}${BOLD}${DGN}Desktop autologin: ${BGN}${DESKTOP_AUTOLOGIN}${CL}" -} - -vm_start_script "Use Default Settings?\n\nDefaults:\n• 8 CPU Cores\n• 8 GB RAM\n• 64 GB Disk" 13 58 -if [[ "${MACHINE_TYPE:-}" != "q35" ]]; then - msg_error "Sunshine VMs require the q35 machine type (got '${MACHINE_TYPE:-unset}')" - exit 1 -fi -CLOUDINIT_REQUIRED=1 -vm_prompt_cloud_init "debian" -if [[ "$USE_CLOUD_INIT" != "yes" ]]; then - msg_error "Sunshine cloud images require Cloud-Init credentials." - exit 1 -fi -CLOUDINIT_USER="${CLOUDINIT_USER:-debian}" -if [[ "$CLOUDINIT_USER" == "root" ]] || - [[ ! "$CLOUDINIT_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || - ((${#CLOUDINIT_USER} > 32)); then - msg_error "Invalid Cloud-Init user '${CLOUDINIT_USER}' (expected a non-root login name)" - exit 1 -fi -select_components -select_passthrough -post_to_api_vm - -vm_select_storage "$HN" -vm_define_disk_references 2 - -URL="https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2" - -msg_info "Retrieving the URL for the ${OS_LABEL} Cloud Image" -msg_ok "${CL}${BL}${URL}${CL}" - -CACHE_FILE="$(vm_image_cache_path "$URL")" -MIN_IMAGE_BYTES=$((100 * 1024 * 1024)) -vm_fetch_image "$URL" "$CACHE_FILE" --cache --min-bytes "$MIN_IMAGE_BYTES" || exit 115 - -WORK_FILE="$TEMP_DIR/sunshine.qcow2" -cp "$CACHE_FILE" "$WORK_FILE" - -SUNSHINE_PATTERN='^sunshine_.*[+]debiantrixie_amd64\.deb$' -msg_info "Resolving the Sunshine release" -if vm_release_asset github LizardByte/Sunshine "$SUNSHINE_PATTERN"; then - SUNSHINE_TAG="$VM_RELEASE_TAG" - SUNSHINE_DEB="$TEMP_DIR/$VM_RELEASE_ASSET" - if [[ -z "${VM_RELEASE_SHA256:-}" ]]; then - msg_error "Sunshine release ${VM_RELEASE_TAG} publishes no digest" - exit 1 - fi - vm_fetch_image "$VM_RELEASE_URL" "$SUNSHINE_DEB" --sha256 "$VM_RELEASE_SHA256" --min-bytes $((1024 * 1024)) || exit 115 - msg_ok "Staged ${VM_RELEASE_ASSET}" -else - exit 1 -fi - -PEGASUS_DEB="" -if [[ "$INSTALL_PEGASUS" == "yes" ]]; then - PEGASUS_PATTERN='^pegasus-frontend_.*_amd64\.deb$' - msg_info "Resolving the Pegasus Frontend release" - if vm_release_asset github mmatyas/pegasus-frontend "$PEGASUS_PATTERN" continuous; then - PEGASUS_DEB="$TEMP_DIR/$VM_RELEASE_ASSET" - if [[ -z "${VM_RELEASE_SHA256:-}" ]]; then - msg_error "Pegasus Frontend release ${VM_RELEASE_TAG} publishes no digest" - exit 1 - fi - vm_fetch_image "$VM_RELEASE_URL" "$PEGASUS_DEB" --sha256 "$VM_RELEASE_SHA256" --min-bytes $((1024 * 1024)) || exit 115 - msg_ok "Staged ${VM_RELEASE_ASSET}" - else - exit 1 - fi -fi - -STAGE_ARGS=(--mkdir /opt/community-scripts --upload "${SUNSHINE_DEB}:/opt/community-scripts/sunshine.deb") -if [[ -n "$PEGASUS_DEB" ]]; then - STAGE_ARGS+=(--upload "${PEGASUS_DEB}:/opt/community-scripts/pegasus.deb") -fi -msg_info "Staging application packages in image" -vm_customize "Application packages" "$WORK_FILE" "${STAGE_ARGS[@]}" || exit 1 -msg_ok "Staged application packages in image" - -vm_prepare_cloud_image "$WORK_FILE" "$HN" || true - -SUNSHINE_FIRSTBOOT_TMP="$TEMP_DIR/sunshine-firstboot.sh" -cat >"$SUNSHINE_FIRSTBOOT_TMP" <> /var/log/sunshine-firstboot.log 2>&1 - -echo "[\$(date '+%F %T')] Starting Sunshine first boot installation" -for _ in {1..30}; do - getent hosts deb.debian.org >/dev/null 2>&1 && break - sleep 2 -done - -GUEST_USER="${CLOUDINIT_USER}" -INSTALL_STEAM="${INSTALL_STEAM}" -INSTALL_DOLPHIN="${INSTALL_DOLPHIN}" -INSTALL_PEGASUS="${INSTALL_PEGASUS}" -DESKTOP_AUTOLOGIN="${DESKTOP_AUTOLOGIN}" - -for component in contrib non-free non-free-firmware; do - if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then - sed -i -E "/^Components:/ { /(^|[[:space:]])\${component}([[:space:]]|\$)/! s/\$/ \${component}/; }" /etc/apt/sources.list.d/debian.sources - fi - if [[ -f /etc/apt/sources.list ]]; then - sed -i -E "/^deb / { /(^|[[:space:]])\${component}([[:space:]]|\$)/! s/\$/ \${component}/; }" /etc/apt/sources.list - fi -done - -apt update -apt install -y \\ - curl jq qemu-guest-agent \\ - kde-plasma-desktop sddm xwayland \\ - pipewire wireplumber pipewire-pulse pipewire-alsa libcap2-bin \\ - mesa-va-drivers mesa-vulkan-drivers intel-media-va-driver-non-free \\ - firmware-amd-graphics firmware-intel-graphics firmware-misc-nonfree \\ - vainfo vulkan-tools -systemctl enable --now qemu-guest-agent -systemctl --global enable pipewire.socket pipewire-pulse.socket wireplumber.service -systemctl set-default graphical.target - -if [[ "\${INSTALL_STEAM}" == "yes" ]]; then - dpkg --add-architecture i386 - apt update - apt install -y mesa-vulkan-drivers libglx-mesa0:i386 mesa-vulkan-drivers:i386 libgl1-mesa-dri:i386 steam-installer -fi - -apt install -y /opt/community-scripts/sunshine.deb -modprobe -a uinput uhid -getent group render >/dev/null || groupadd -r render -usermod -aG input,video,render "\${GUEST_USER}" - -SUNSHINE_UNIT="" -for candidate in /usr/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service /usr/lib/systemd/user/sunshine.service; do - if [[ -f "\${candidate}" ]]; then - SUNSHINE_UNIT="\${candidate}" - break - fi -done -if [[ -n "\${SUNSHINE_UNIT}" ]]; then - USER_WANTS="/home/\${GUEST_USER}/.config/systemd/user/graphical-session.target.wants" - mkdir -p "\${USER_WANTS}" - ln -sf "\${SUNSHINE_UNIT}" "\${USER_WANTS}/\$(basename "\${SUNSHINE_UNIT}")" - chown -R "\${GUEST_USER}:\${GUEST_USER}" "/home/\${GUEST_USER}/.config" -else - mkdir -p "/home/\${GUEST_USER}/.config/autostart" - cat > "/home/\${GUEST_USER}/.config/autostart/sunshine.desktop" <<'DESKTOP' -[Desktop Entry] -Type=Application -Name=Sunshine -Exec=/usr/bin/sunshine -X-GNOME-Autostart-enabled=true -DESKTOP - chown -R "\${GUEST_USER}:\${GUEST_USER}" "/home/\${GUEST_USER}/.config" -fi - -if [[ "\${INSTALL_DOLPHIN}" == "yes" ]]; then - apt install -y dolphin-emu - DOLPHIN_GAMES="/home/\${GUEST_USER}/games/dolphin" - mkdir -p "\${DOLPHIN_GAMES}" - chown "\${GUEST_USER}:\${GUEST_USER}" "/home/\${GUEST_USER}/games" "\${DOLPHIN_GAMES}" - if [[ ! -e "\${DOLPHIN_GAMES}/metadata.pegasus.txt" ]]; then - cat > "\${DOLPHIN_GAMES}/metadata.pegasus.txt" <<'METADATA' -collection: Dolphin -extensions: iso, rvz, wbfs, ciso, gcm, wad, m3u -launch: /usr/games/dolphin-emu -b -e "{file.path}" -METADATA - chown "\${GUEST_USER}:\${GUEST_USER}" "\${DOLPHIN_GAMES}/metadata.pegasus.txt" - fi -fi - -if [[ "\${INSTALL_PEGASUS}" == "yes" ]]; then - apt install -y /opt/community-scripts/pegasus.deb - if [[ "\${INSTALL_DOLPHIN}" == "yes" ]]; then - PEGASUS_CONFIG="/home/\${GUEST_USER}/.config/pegasus-frontend" - mkdir -p "\${PEGASUS_CONFIG}" - if ! grep -Fxq "\${DOLPHIN_GAMES}" "\${PEGASUS_CONFIG}/game_dirs.txt" 2>/dev/null; then - cat >>"\${PEGASUS_CONFIG}/game_dirs.txt" </dev/null - APPS_TEMP="\$(mktemp "\${SUNSHINE_CONFIG}/apps.XXXXXX")" - jq 'if any(.apps[]; .name == "Pegasus") then . else .apps += [{"name":"Pegasus", "cmd":"env QT_QPA_PLATFORM=xcb /usr/bin/pegasus-fe"}] end' \ - "\${SUNSHINE_CONFIG}/apps.json" >"\${APPS_TEMP}" - chown "\${GUEST_USER}:\${GUEST_USER}" "\${SUNSHINE_CONFIG}" "\${APPS_TEMP}" - chmod 600 "\${APPS_TEMP}" - mv "\${APPS_TEMP}" "\${SUNSHINE_CONFIG}/apps.json" -fi - -if [[ "\${DESKTOP_AUTOLOGIN}" == "yes" ]]; then - mkdir -p /etc/sddm.conf.d - for SESSION_FILE in /usr/share/wayland-sessions/plasma*.desktop; do - [[ -f "\${SESSION_FILE}" ]] || { echo "Plasma Wayland session is missing"; exit 1; } - cat > /etc/sddm.conf.d/autologin.conf </dev/null -vm_resize_disk "scsi0" "$DISK_SIZE" -set_description - -vm_provision "$VMID" -if [[ -n "${CLOUDINIT_SSH_KEYS:-}" ]]; then - $STD qm set "$VMID" --sshkeys "$CLOUDINIT_SSH_KEYS" -fi - -msg_ok "Created a Debian 13 Sunshine VM ${CL}${BL}(${HN})" -attach_passthrough -vm_start_vm "Sunshine VM" -vm_wait_for_ip 120 || true - -display_cloud_init_info "$VMID" "$HN" - -vm_print_summary \ - "GPU=${GPU_SLOT:-not assigned}" \ - "OS=${OS_LABEL}" \ - "Sunshine=${SUNSHINE_TAG}" \ - "Steam=${INSTALL_STEAM}" \ - "Dolphin=${INSTALL_DOLPHIN}" \ - "Pegasus=${INSTALL_PEGASUS}" \ - "DesktopAutologin=${DESKTOP_AUTOLOGIN}" \ - "WebUI=https://${VM_IP:-pending}:47990" -NEXT_STEPS=( - "First boot installs the desktop and Sunshine in the VM; follow it with: journalctl -u sunshine-firstboot -f (log: /var/log/sunshine-firstboot.log)" - "Reboot the guest once first boot completes, before testing anything that needs firmware or acceleration." - "Log in on the GPU-connected monitor as ${CLOUDINIT_USER} (noVNC works only without GPU assignment). Dummy-only operation needs VM_DESKTOP_AUTOLOGIN=yes; Sunshine starts at graphical login." - "Open https://:47990, set Sunshine credentials, then enter the PIN displayed by Moonlight in Sunshine's PIN page." - "For 4K60, select the GPU-connected output at 3840x2160 and 60 Hz in Plasma Display Settings if the monitor/dummy supports it; check hardware encoding in the Sunshine log." -) -if [[ "$INSTALL_PEGASUS" == "yes" ]]; then - NEXT_STEPS+=("Pegasus Frontend is an alpha install; its game collections are user-owned.") -fi -vm_next_steps "${NEXT_STEPS[@]}" - -vm_finish "VM created. Sunshine installation continues in the VM on first boot."