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."