fangorn/sunshine-qemu
public
Phase 1d: multi-VM deployment (ports, mDNS, systemd, libvirt) #6
Pull requests
Links
Phase 1d: multi-VM deployment (ports, mDNS, systemd, libvirt)
Parent: #1. Depends on #3, #4, #5 (a working single-VM stream).
Requirements
- REQ-DEP-001: multiple VMs can be streamed from one host
- REQ-DEP-002: systemd template unit and libvirt integration
Why
Sunshine assumes one host, one desktop, one process. We run one Sunshine process per VM. Each instance needs its own:
- QEMU D-Bus address and console
- base
port; all other ports are offsets vianet::map_port, so instances need non-overlapping ranges (use a step of 100) sunshine_name, so Moonlight’s mDNS discovery shows each VM separately (verifysrc/platform/linux/publish.cppadvertises that name and that two instances don’t collide on the service instance name)file_state,credentials_file,pkey,cert,file_apps, and log path, so pairing is per VM
Tasks
A. Verify multi-instance works with only config (no code), then fix what doesn’t
- Run two test VMs and two Sunshine instances with different config files. Pair and stream both at the same time from two test clients.
- Known suspects to check:
- UPnP port mapping collisions (set
upnp = disabledin docs for per-VM instances) - mDNS instance-name collision
- web UI ports (they also come from
map_port) - any hard-coded paths under
~/.config/sunshine - PulseAudio virtual sinks (irrelevant when
capture=qemu; confirm they aren’t created)
- UPnP port mapping collisions (set
B. systemd
-
packaging/linux/qemu/sunshine-qemu@.service:ExecStart=/usr/bin/sunshine /etc/sunshine-qemu/%i.conf,Restart=on-failure, runs as a dedicated user that has access to the VM’s D-Bus socket,StateDirectory=sunshine-qemu/%i. Also a user-unit variant for rootless setups. - Example
/etc/sunshine-qemu/example.confwithcapture = qemu,qemu_dbus_address,port,sunshine_name, state paths,encoder. - Helper
scripts/sunshine-qemu-port.sh <vm>, or document a deterministic port-allocation convention.
C. libvirt (investigate first; unverified)
- Find out exactly what libvirt does for
<graphics type='dbus'/>and<audio type='dbus'/>, from libvirt docs (formatdomain.html, “Graphical framebuffers” / “Audio backends”) and libvirtsrc/qemu/qemu_dbus.c. Answer these in a comment on this issue before coding:- Does libvirt start a private
dbus-daemonper domain? Where is its socket, and what are the owner and permissions? p2p='yes'/address=/gl=/rendernodeattributes: which libvirt version added each?- How can a third-party process get access? Maybe the socket plus group membership, maybe
virDomainOpenGraphicsFDfor p2p, maybe neither. Check SELinux/AppArmor (svirt) labeling. - Does libvirt itself register as a client, and do multiple listeners coexist? QEMU allows multiple Console listeners.
- Does libvirt start a private
- If p2p is the only practical route, implement p2p mode in QemuSession: QMP
add_client protocol=@dbus-display fdname=...aftergetfd, or the libvirt API equivalent. -
packaging/linux/qemu/libvirt-hook-qemu: on<vm> startedrunsystemctl start sunshine-qemu@<vm>; onstoppedrun stop. It only acts on domains opted in (e.g. a<metadata>element or a list in/etc/sunshine-qemu/). - Docs:
docs/qemu.mdcovering the raw QEMU command line, libvirt XML, virt-manager steps, and troubleshooting (permissions, render node mismatch, IsAbsolute).
Tests
- Integration: two VMs and two instances streaming at the same time; assert distinct mDNS names, no port conflict, and pairing one doesn’t pair the other (REQ-DEP-001).
- systemd-analyze verify on the unit file; a shellcheck-clean hook script with unit tests using a fake
systemctl(REQ-DEP-002). - Manual: a libvirt domain started from virt-manager streams with no manual Sunshine launch.
Done when
Both requirements are linked to passing tests, the libvirt investigation is answered in this issue, and docs are written. PR Closes #6.
Section C: libvirt investigation (read-only, answered before coding)
Sources read: libvirt git at tag v12.7.0 (plus git show v10.0.0: for Ubuntu 24.04’s version), QEMU 11.1.1 ui/dbus*.c and monitor/qmp-cmds.c, dbus 1.14.10, glib 2.80 gdbusobjectmanagerserver.c, and the Ubuntu noble debs libvirt-daemon-driver-qemu / libvirt-daemon-system 10.0.0-2ubuntu8.16 (unpacked, not installed).
Tags: [src] = read in the source. [inf] = inference, not tested.
Nothing here was run against a real libvirt. The dev host (WSL2) has no libvirt installed; no daemon was started and no VM was run through libvirt. Everything is static source reading.
1. Does libvirt start a private dbus-daemon per domain? Yes, unless p2p='yes' or address= is used
- When [src]:
qemuExtDevicesStart()(src/qemu/qemu_extdevice.c:238-243) callsqemuDBusStart()for every<graphics type='dbus'>with p2p off and no user-givenaddress=(fromConfig,domain_conf.c:12481). The same daemon also serves slirp helper / dbus-vmstate.<audio type='dbus'>never starts a daemon itself; it becomes-audiodev dbusbound to the display with-display dbus,...,audiodev=audioN(qemu_command.c:8765,8334), and validation requires the linked audio to be dbus (qemu_validate.c:5005). - Command line [src] (
qemu_command.c:8742-8778): bus mode-display dbus,addr=unix:path=<sock>[,gl=on|off][,rendernode=..][,audiodev=..]; p2p mode-display dbus,p2p=on,.... An emptyaddressis filled byqemuDBusGetAddress()(qemu_process.c:5462) and appears in the live XML. - Socket path [src]:
<stateDir>/dbus/<shortName>-dbus.sock(qemu_dbus.c:56,qemu_conf.c:248).shortNameis"%d-%.20s"from the domain id and name (domain_conf.c:32027), so the path changes on every domain start.- system:
/run/libvirt/qemu/dbus/<id>-<name20>-dbus.sock - session (
qemu:///session):$XDG_RUNTIME_DIR/libvirt/qemu/run/dbus/...(qemu_conf.c:209) - discoverable via live
virsh dumpxml(address='unix:path=...'),virsh domdisplay --type dbus(printsdbus+unix://<path>, since 8.4.0,tools/virsh-domain.c:12221), or the live XML a qemu hook gets on stdin.
- system:
- Owner / permissions [src]:
- directory
0770cfg->user:cfg->group(qemu_driver.c:651, spec%attr(0770, qemu_user, qemu_group)); Debian/Ubuntulibvirt-qemu:kvm, Fedoraqemu:qemu. - config
0600(qemu_dbus.c:154), chowned/labelled for the domain. - daemon runs via
qemuSecurityCommandRun()with the domain’s labels: DAC uid/gid (security_dac.c:2392-2411), SELinuxsvirt_t:MCS(security_selinux.c:3110), AppArmorlibvirt-<uuid>(security_apparmor.c:548); capabilities cleared. - socket: dbus-daemon
chmod 0777(dbus-sysdeps-unix.c:1254), then libvirt chowns and labels it to the domain (qemu_dbus.c:311). Net:srwxrwxrwx libvirt-qemu:kvminside a0770dir [inf: combination of the two code paths]. - bus policy (
qemuDBusWriteConfig,qemu_dbus.c:119-155):EXTERNALauth, allow-all send/receive/own, and<allow user='root'/>only when privileged and only since 11.2.0 (commit 37429530c). No other<allow user|group>. - dbus-daemon’s connect rule (
bus/policy.c:460): only the bus owner’s uid, then<allow user|group>rules. So only the domain’s DAC user and (11.2.0+) root can connect.kvmgroup membership passes the directory but dbus-daemon still rejects the connection [src]. On 10.0.0 (Ubuntu 24.04) even root is refused.
- directory
- Lifetime [src]: started in
qemuProcessLaunch(qemu_process.c:8368), before thestarthook (8384) and before QEMU exec; stopped inqemuProcessStopviaqemuDBusStop()(9404), before thestoppedhook (9473). Survives a virtqemud restart (<dbusDaemon/>in status XML,qemu_domain.c:2731,3387; reconnectqemu_process.c:9900). Binary from qemu.confdbus_daemonsince 9.4.0.
2. Versions [src: git describe --contains on introducing commits, NEWS.rst, formatdomain.rst]
| Feature | libvirt | Evidence |
|---|---|---|
<graphics type='dbus'> with private bus |
8.4.0 | 88ba34f5a, 5c1e203a8, NEWS v8.4.0 (needs QEMU 7.0) |
p2p='yes' |
8.4.0 | 88ba34f5a (address and p2p mutually exclusive in rng) |
address= |
8.4.0 | 88ba34f5a |
<gl enable= rendernode=> for dbus |
8.4.0 | 88ba34f5a. gl='yes' without rendernode auto-picks virHostGetDRMRenderNode() (qemu_process.c:5584) [inf: that 8.4.0 already did]; fails on hosts without /dev/dri |
<audio type='dbus'>, <graphics type='dbus'><audio id=/> |
8.4.0 | a062f5f77, 1ce258a57 |
virsh domdisplay dbus URI, -chardev dbus, usbredir dbus |
8.4.0 | bf213aa96, 53905292f, 3fa987cc4 |
OpenGraphics/OpenGraphicsFD for dbus (@dbus-display) |
8.4.0 [inf: NEWS “private bus or p2p mode”] | qemu_driver.c:15199, 15301 (v12.7.0) |
dbus_daemon in qemu.conf |
9.4.0 | 769de39f5 |
| libvirt’s own bus connection; root allowed; daemon log | 11.2.0 | dbfb96d18, 37429530c, 507be3425 |
| qemu-rdp helper | 11.2.0 | 469e16194 |
| qemu-vnc helper | 12.5.0 | 209e40a34 |
3. How can a third-party process (Sunshine as its own user) get access?
- a) socket + group membership: does not work on system libvirt [src] (see §1). The
.confis rewritten on every start.- Remaining bus-mode options: run Sunshine as the domain’s DAC user, or give the domain
<seclabel type='static' model='dac'><label>+UID:+GID</label>[inf]; run Sunshine as root (11.2.0+ only, not recommended) [src]; wrapdbus_daemonto add<allow group>(host-wide hack) [inf]; session libvirt with Sunshine as the same user [inf: uid matches].
- Remaining bus-mode options: run Sunshine as the domain’s DAC user, or give the domain
- b)
address=to a bus Sunshine owns: libvirt starts no daemon (qemu_extdevice.c:239) and does not label/authorise that path for QEMU [src] (virt-aa-helper only adds rendernode and listen sockets,virt-aa-helper.c:1053-1082). Under AppArmor/SELinux the confined QEMU is likely denied [inf]. - c) p2p via
virDomainOpenGraphicsFD(dom, idx, 0): works by design, including svirt [src]:- libvirt makes the socketpair with the domain’s socket label (
qemuSocketPair,qemu_driver.c:15237), passes one end to QEMU with QMPgetfd fdname=graphicsfd+add_client protocol=@dbus-display fdname=graphicsfd skipauth=false(qemu_monitor.c:3149,qemu_monitor_json.c:3840,4822), returns the other end over RPC. - Ubuntu’s AppArmor abstraction allows
unix (send, receive) type=stream addr=none peer=(label=virtqemud|libvirtd)(abstractions/libvirt-qemu:247-250, 10.0.0 deb) - exactly this case. - Access control: needs a
qemu:///systemconnection withdomain:open_graphics. Default ACL = full RW access =libvirtgroup on Ubuntu (root-equivalent). Withaccess_drivers = ["polkit"]it can be narrowed toorg.libvirt.api.domain.open-graphics[inf: polkit rules not read]. - Hook caveat:
docs/hooks.rst- a hook must not call back into libvirt (deadlock). The hook should onlysystemctl start --no-block; Sunshine calls OpenGraphicsFD itself. - Unsupported alternative:
virsh qemu-monitor-command --pass-fds(taints the domain).
- libvirt makes the socketpair with the domain’s socket label (
- d) SELinux/AppArmor for bus mode [inf, untested]: the per-VM dbus-daemon runs confined; an unconfined client is generally allowed by SELinux; AppArmor pathname unix-socket mediation between confined daemon and unconfined peer is not covered by an explicit rule I could find - needs testing on a real Ubuntu host.
4. Does libvirt register as a display client? Do listeners coexist?
- libvirt itself registers no Console listener [src] (no
RegisterListenerinsrc/). Since 11.2.0 it holds a plain bus connection (qemu_dbus.c:88) for helper control and dbus-vmstate. - But libvirt spawns display clients on the private bus:
qemu-rdp(11.2.0+, needs non-p2p dbus,qemu_validate.c:5050) andqemu-vnc(12.5.0+,qemu_process.c:5611). QEMU 11.1.1tools/qemu-vnc/display.c:307callsRegisterListener[src]; qemu-rdp doing so is [inf]. - Multiple listeners coexist [src]:
ui/dbus-console.c:45keeps an unboundedGPtrArray *listeners; eachRegisterListenerappends a peer connection (line 332); removed when its connection closes. Any client’sSetUIInfoaffects the shared console [inf]. - One D-Bus display per VM [src]: QEMU refuses a second
dbus-display(ui/dbus.c:260), libvirt allows one dbus graphics (qemu_command.c:10504). Bus mode and p2p cannot be combined on one VM. - p2p has a single control client [src]:
dbus_display_add_client()(ui/dbus.c:376) callsg_dbus_object_manager_server_set_connection(), which (glibgdbusobjectmanagerserver.c:294-303) unexports everything from the previous connection. A second p2p client (e.g. virt-viewer) takes VM/Console/Mouse/Keyboard/Audio away from Sunshine’s control connection; already-registered listeners survive [inf from the code].
5. p2p sequence and reuse of QemuSession
- Sequence [src]: QEMU with
-display dbus,p2p=on[,audiodev=..]. QMPgetfd fdname=X(fd via SCM_RIGHTS), thenadd_client protocol=@dbus-display fdname=X(monitor/qmp-cmds.c:138-160). In bus modeadd_clientfails (“p2p connections not accepted in bus mode”). QEMU is the AUTHENTICATION_SERVER withDELAY_MESSAGE_PROCESSING(ui/dbus.c:416-429); client must be auth client. Via libvirt:virDomainOpenGraphicsFD(dom, idx, 0)does the same. - Reuse: everything above the connection is transport independent (Console proxies, ConsoleIDs, RegisterListener socketpair, listener thread, frame store; QEMU names p2p listeners “p2p”,
dbus-listener.c:1000; audio handles p2p,audio/dbusaudio.c:486). Required changes [inf: glib API rules + session.cpp reading]: connection from fd withAUTHENTICATION_CLIENTand noMESSAGE_BUS_CONNECTION; proxies with NULL name; noname_ownercheck or name watch (onlyclosed); a new fd per reconnect.
Conclusion
- p2p is required for the common case (system libvirt, Sunshine as its own user). Socket + group cannot work.
- Plan for this issue: (A) raw QEMU / session libvirt as the same user → bus mode as today; (B) system libvirt →
<graphics type='dbus' p2p='yes'>+ a p2p connect path in QemuSession, with the fd obtained through libvirtvirDomainOpenGraphicsFDor QMPgetfd+add_client. The hook only doessystemctl start/stop --no-block. - To verify on real hosts (not possible here): Ubuntu with AppArmor enforcing and Fedora with SELinux enforcing, both routes; virt-manager manual check.
Progress on phase-1 (not closing)
Commits: 50eb6e47, 1c97e202, 4ffcec4b, 4fc4de64, e6a28789, e431a566, cfee508e.
A. Multi-instance. Two VMs, two Sunshine instances and two clients streamed at the same time with only config files. The one collision was the mDNS instance name, which came from the host name (the second instance became “ #2”). It now follows sunshine_name, and falls back to the host name when that is empty.
tests/e2e/qemu/multi_instance.shchecks:- distinct mDNS names on each instance’s port, through a private rootless Avahi sandbox;
- disjoint sockets inside
port-5..port+21; - per-instance pairing;
- each instance streams its own VM;
- no PulseAudio sinks;
- no shared
~/.config/sunshinestate.
- It passes on QEMU 11.1.1 (Debug and Release NVENC at 1080p60) and on QEMU 8.2.2.
C. p2p (required, see the investigation comment). qemu_dbus_address now also accepts:
qmp:<socket>: QMPgetfd+add_client protocol=@dbus-display.libvirt:<domain>[?uri=]:libvirt.so.0is loaded at run time. It usesvirDomainOpenGraphicsFDforp2p='yes', otherwise the bus address from the live XML.
Tests:
- Unit tests use a fake p2p QEMU, a fake QMP server and a fake libvirt module.
- The E2E runs over QMP p2p (pattern, audio, input, reboot, reconnect) pass on real QEMU. The pattern run also passes on 8.2.2.
B. systemd + hook. In packaging/linux/qemu:
sunshine-qemu@.serviceand a user unit- sysusers file
sunshine-qemu-wait-vm(ExecStartPre; Sunshine checks the display only once at startup and doesn’t recover when the VM appears later)sunshine-qemu-portlibvirt-hook-qemu(opt-in =/etc/sunshine-qemu/<domain>.conf; usessystemctl --no-block)example.conf
tests/packaging/linux/qemu/test_sunshine_qemu.sh covers systemd-analyze verify for both units, the hook with a fake systemctl, the wait helper with fake gdbus/virsh, the port helper and shellcheck.
Docs. docs/qemu.md covers raw QEMU, several VMs, systemd, libvirt XML and permissions, the hook, virt-manager and troubleshooting. configuration.md is updated too.
Not verified here: there is no libvirt on this host. Still open:
- a real libvirt domain started from virt-manager (manual check);
virDomainOpenGraphicsFDagainst a real libvirt;- the polkit rule;
- SELinux and AppArmor;
- units under a real systemd instance;
- a GUI Moonlight client discovering both hosts.