Sunshine and Moonlight had been streaming my headless Alienware R8 (Ubuntu Server 24.04, RTX 2080) to my Mac over Tailscale flawlessly for a week. Then the box rebooted. What followed was a 6-hour debugging session that touched every layer of the stack before landing on two lines of config.
The uncomfortable truth up front: I'm not certain the reboot itself broke anything. Before I ever tested Moonlight after the reboot, I made several unrelated changes to the server (removed XFCE from the streaming display, edited Sunshine's systemd dependencies, restarted Xorg, installed libinput). Any of those could have been the trigger. The original pairing and crypto keys were still valid on disk. If I had simply restarted Sunshine and tested Moonlight before touching anything else, it might have just worked. I'll never know — and the changes I made to debug the problem made it progressively worse, turning what might have been a one-restart fix into a 6-hour saga.
The setup
- Server: Alienware R8 running Ubuntu Server 24.04, headless (no monitor). NVIDIA RTX 2080 driving a virtual display via
nvidia-dummy.confon Xorg:2. Sunshine streams the desktop to Moonlight clients. - Client: MacBook running Moonlight 6.1.0.
- Network: Tailscale (WireGuard) over the internet, ~88ms RTT. No direct LAN — the machines are in different locations.
- What worked before the reboot: Moonlight connected, streamed at 1080p/60fps with GPU encoding (NVENC), mouse and keyboard input worked, latency was excellent.
What broke
After the reboot, Moonlight showed:
Starting RTSP handshake failed: Error 60
Check your firewall and port forwarding rules for ports:
TCP 48010, UDP 48000, UDP 48010
The error was consistent. Every connection attempt failed at the exact same step.
Everything we tried (the scenic route)
In order of attempt, with the actual diagnostic that ruled each one out:
1. Firewall (UFW)
Added rules for every Sunshine port (TCP 47984/47989/47990/48010, UDP 47998-48000/48002/48010). Then added a blanket ALLOW from 100.64.0.0/10 (entire Tailscale range). Made no difference.
Ruled out by: nc -zv 100.64.0.11 48010 succeeded. All ports reachable.
2. Pairing state
Deleted sunshine_state.json, regenerated TLS certificates, re-paired multiple times via the web UI PIN exchange. Each time: Sunshine said "Success! Please check Moonlight to continue." Moonlight said "Pairing refused (error: 1)."
Ruled out by: ...actually, this was a clue we misread. More on this below.
3. Certificate injection
Since interactive pairing kept failing, I extracted Moonlight's client certificate from its macOS plist (~/Library/Preferences/com.moonlight-stream.Moonlight.plist) and injected it directly into Sunshine's sunshine_state.json. Also injected Sunshine's server cert into Moonlight's plist.
Ruled out by: Pairing appeared to work (PairStatus=1 in serverinfo, apps list retrieved), but streaming still failed with Error 60.
4. UUID mismatch
Every time Sunshine's state was recreated, it generated a new uniqueid. Moonlight cached the old one and showed "PC Status: Offline." Fixed by syncing hosts.1.uuid in the plist + killall cfprefsd.
Ruled out by: Got past "offline" to the actual streaming attempt — still Error 60.
5. LAN encryption mode
Sunshine classifies Tailscale IPs (100.64.0.x/32) as WAN, not LAN. Tried wan_encryption_mode = 0 to disable WAN encryption. Tried lan_encryption_mode = 1 to force LAN encryption. Tried removing both.
Ruled out by: The RTSP URL always returned rtspenc:// regardless of encryption settings. Sunshine's RTSP channel uses its own encryption (derived from the pairing key exchange), not the config-controlled stream encryption.
6. Sunshine reinstall
Purged and reinstalled the same version (v2025.924.154138) from the LizardByte GitHub .deb. Fresh binary, fresh config, fresh state.
Ruled out by: Same Error 60.
7. Sunshine version upgrade
Upgraded to v2026.428.130031 (7 months of development newer). New service name (app-dev.lizardbyte.app.Sunshine.service instead of sunshine.service), required override reconfiguration.
Ruled out by: Same Error 60. (But this upgrade was actually necessary — it introduced the CSRF protection that was part of the real fix.)
8. Compositor
The working setup had XFCE running on Xorg :2 (providing xfwm4 as a compositing window manager). I had removed XFCE during an earlier optimization session. Installed picom as a lightweight GLX compositor.
Ruled out by: Same Error 60.
9. TCP MTU probing
Tailscale's MTU is 1280. Enabled net.ipv4.tcp_mtu_probing=1 and added iptables MSS clamping on the tailscale0 interface.
Ruled out by: Same Error 60. (Though this is probably good to leave enabled.)
10. SSH tunneling
Forwarded all Sunshine TCP ports through an SSH tunnel to bypass Tailscale's WireGuard path entirely.
Ruled out by: Moonlight ignored the tunnel — it used Sunshine's advertised LocalIP (100.64.0.11) for the RTSP connection, bypassing the localhost tunnels.
11. NVIDIA driver state
Checked nvidia-drm modeset, driver version, module loading. All fine.
Ruled out by: e2e tests (which use GPU-backed rendering) passed perfectly. The GPU was fine.
The actual diagnosis
While all this was happening, I was capturing logs on both sides. The Moonlight CLI (moonlight stream 100.64.0.11 "Desktop") gave the clearest picture:
00:00:17 - Executing request: "https://100.64.0.11:47984/launch?..."
00:00:18 - Launch response: status_code=200, sessionUrl=rtspenc://100.64.0.11:48010
00:00:18 - Starting RTSP handshake...
00:00:18 - Audio port: 48000
00:00:18 - Video port: 47998
00:00:18 - Control port: 47999
00:00:33 - RTSP request timed out
00:00:33 - RTSP ANNOUNCE request failed: 60
And on Sunshine's side:
Session: DEADBEEFCAFE
command :: ANNOUNCE
New streaming session started [active sessions: 1]
RTSP/1.0 200 OK
Sunshine received the ANNOUNCE, created the session, and sent 200 OK. But Moonlight never got the response. The response was encrypted with the RTSP session key — which is derived from the Diffie-Hellman key exchange during PIN-based pairing.
And therein lay the problem.
What actually fixed it (the 2-line version)
Fix 1: CSRF protection (new in Sunshine v2026.428)
The upgraded Sunshine added CSRF protection to its web UI. When I accessed the PIN entry page via https://alienware-r8:47990 (Tailscale hostname), the PIN submission was silently rejected because the origin didn't match localhost. The web UI showed "Success" but the server-side exchange never completed.
Every single pairing attempt through the web UI had been failing silently. Sunshine's log showed it clearly once I knew what to look for:
CSRF Protection Error
The request was blocked by CSRF protection.
Fix: One line in sunshine.conf:
csrf_allowed_origins = https://alienware-r8:47990
Fix 2: Proper PIN pairing (not cert injection)
With CSRF fixed, the real PIN-based pairing exchange could complete. The full Diffie-Hellman key exchange ran:
getservercert → clientchallenge → serverchallengeresp → clientpairingsecret → pairchallenge
This established the shared secret that both sides use to encrypt/decrypt RTSP messages. My cert injection hack had put the right certificates in place (PairStatus=1, apps list worked) but never established the RTSP encryption keys. Every ANNOUNCE response from Sunshine was encrypted with a key Moonlight didn't have, so it looked like random bytes — hence the 15-second timeout.
After proper pairing: streaming worked immediately. Mouse, keyboard, click-and-drag, all perfect.
Bonus fix 3: Device naming
Moonlight's device name (sent during pairing) contained spaces and numbers. Switching to a short, lowercase-only name resolved intermittent pairing failures. Likely a URL-encoding edge case in the pairing HTTP parameters.
What I should have done
The minimal fix, if I'd known the root causes from the start:
- Upgrade Sunshine to v2026.428 (for latest fixes, though any version would work with step 2)
- Add
csrf_allowed_origins = https://alienware-r8:47990tosunshine.conf - Remove the host in Moonlight, re-add it with a simple device name
- Pair via PIN through the web UI
- Stream
Total time: ~10 minutes. Instead: 6 hours.
Postscript: it happened again (same day)
Six hours after the fix, Moonlight showed "PC Status: Offline." A different root cause this time, but the same zombie session mechanism:
- An app update triggered a rebuild/restart of the Trading Workstation on Xorg
:2 - Moonlight attempted to connect during the transition
- The connection failed, leaving a zombie RTSP session
- Sunshine detected the hang:
Fatal: Hang detected! Session failed to terminate in 10 seconds. - Sunshine crashed (core dump, SIGTRAP)
- systemd auto-restarted it — but the zombie persisted, causing another crash
- After 4 rapid crashes, systemd hit the restart rate limit (
start-limit-hit) and gave up - Sunshine stayed dead for 7 hours until I noticed
The fix was two commands:
systemctl --user reset-failed app-dev.lizardbyte.app.Sunshine.service
systemctl --user restart app-dev.lizardbyte.app.Sunshine.service
The reset-failed clears the rate-limit counter; the restart comes up clean. Moonlight connected immediately after.
The real lesson: zombie RTSP sessions aren't just a debugging nuisance — they're a stability hazard. A single failed connection attempt can cascade into hours of downtime. Sunshine's hang detection is aggressive (10-second timeout then abort), and systemd's rate limiting does the rest.
The automation fix (2026-05-04 ~10:00 AM)
After the second incident, I automated the recovery with two layers:
Layer 1: Resilient restart policy. In Sunshine's systemd override:
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Restart=on-failure
RestartSec=10
This allows up to 10 crashes in a 5-minute window with 10-second cooldown between attempts. The default was ~4 rapid restarts before giving up. Zombie sessions usually clear after 1-2 restarts, so 10 attempts with spacing is more than enough.
Layer 2: Watchdog timer. A 2-minute health check (sunshine-watchdog.timer) that runs:
if ! systemctl --user is-active --quiet app-dev.lizardbyte.app.Sunshine.service; then
systemctl --user reset-failed app-dev.lizardbyte.app.Sunshine.service
systemctl --user restart app-dev.lizardbyte.app.Sunshine.service
fi
If Sunshine somehow exhausts the restart budget and ends up in start-limit-hit, the watchdog catches it within 2 minutes, clears the rate-limit counter, and restarts cleanly. Recoveries are logged to /tmp/sunshine-watchdog.log.
The 7-hour silent outage can't happen anymore. Worst case is 2 minutes of downtime before the watchdog picks it up.
Lessons
Silent failures are the worst failures. CSRF rejection returned "Success" in the UI while blocking the actual exchange server-side. The pairing appeared to work. The cert injection appeared to fix the pairing. The RTSP error appeared to be a network issue. Every visible symptom pointed away from the root cause.
Don't bypass security protocols. My cert injection hack seemed clever — skip the flaky PIN exchange, just put the certs where they belong. But pairing isn't just about certificates. It's a key exchange. Bypassing it left the encryption channel broken in a way that manifested as a completely unrelated timeout error.
Read the error messages you dismiss. The CSRF error appeared once in the Sunshine log and I didn't see it because I was grepping for RTSP/pairing keywords. A broader grep -i error would have caught it immediately.
Restart before you debug. Sunshine accumulates zombie RTSP sessions from failed connection attempts. Each zombie blocks the next attempt. I lost significant time debugging state that was contaminated by previous failed attempts. A clean restart before each test would have given clearer signals.
Version upgrades aren't free. Upgrading Sunshine from v2025.924 to v2026.428 changed the service name, added CSRF protection, and may have changed other behaviors. The upgrade was necessary (7 months of fixes) but it introduced the CSRF issue that made pairing impossible until configured.
Enjoyed this post?
Get notified when I publish something new. No spam, unsubscribe anytime.