现充|junyu33

Making Official Moonlight iOS Work over Public IPv4 with Split DNS

The official Moonlight iOS client is not very friendly to adding hosts over public IPv4. You can compile your own client, or pay $1.49 / 9 CNY for the community fork VoidLink to bypass this restriction, but spending some time approaching the problem from the Sunshine server side is also a reasonable option.

The final workaround is simple: make the same hostname resolve to a VPN/LAN IP during pairing, and to a public IP during public use. Repository: https://github.com/junyu33/moonlight-ios-publicIP

Note that this method only works for non-TLS-ified hostnames.

Background

In February 2025, I wrote an article about connecting to my lab workstation through a public FRP jump server. Following the method from this article in September 2024, I also set up a VNC service on the lab machine for remote desktop access.

However, as time went on, the desktop environment on my workstation changed from Xfce to KDE Plasma, so the old setup was no longer suitable. The previous VNC-based connection was replaced by a Sunshine/Moonlight streaming setup, still using a hostname as the connection entry point.

Moonlight on desktop and Android had no obvious issues. The trouble was on iOS. Due to Apple's previous policy, streaming over the public Internet was not available there. Although this policy was changed in 2024, the official client had not caught up as of the time of writing. To work around this public-Internet restriction, I started exploring.

Existing Solutions

There are several ready-made solutions that can be found online:

  1. If you are physically near the workstation, you can directly enable a hotspot on the workstation, connect your phone to it, and enter the hotspot-side host IP in Moonlight.
  2. You can use VPN solutions such as Tailscale/WireGuard to make the host look like an internal address.
  3. Since Moonlight is open source, you can remove that restriction and compile your own fork, or use a third-party solution such as VoidLink.
  4. You can configure DNS hijacking on the router, making the same hostname resolve to the public IP outside the LAN and to the internal IP inside the LAN. See this Tieba post for the concrete idea.

However, these four approaches have their own drawbacks:

  1. If you can physically access the workstation, there is little point in streaming from it; you might as well use the machine directly.
  2. Tailscale depends on third-party servers, which in some sense makes it closer to ordinary remote desktop solutions and may introduce latency issues. I did test it, and the latency actually seemed fine, so it can still be used as a fallback. WireGuard also needs a public endpoint or an FRP setup to establish the tunnel. Of course, both of these may conflict with the proxy/VPN setup already used on the phone, which can be quite annoying for users in China.
  3. Compiling the client yourself is difficult if you do not have a Mac. As for VoidLink, it costs $1.49 / 9 CNY to amortize the developer's Apple-related costs.
  4. The router-based DNS hijacking solution requires access to the router, and you also need to be near that router once to complete LAN pairing.

So is there a way to use a public hostname, avoid relying on a long-lived VPN tunnel, keep using the official app, and still avoid physically going to the workstation? Yes.

Overview of My Approach

Simply put, my approach is mainly a fork of the fourth solution: I move the router-side DNS hijacking step onto the Linux server running Sunshine. Meanwhile, the initial LAN pairing step is also moved into a VPN. In other words, as long as you have SSH access and root privileges on the server, you can complete all the steps below.

Establishing the Connection

First, if you are using FRP for NAT traversal with bindPort = 8000, remember to expose UDP port 51820 in frps/frpc. The client configuration is as follows, using frp 0.61:

serverAddr = "<public IP of frps>"
serverPort = 8000

auth.method = "token"
auth.token = "PUT_YOUR_FRP_TOKEN_HERE"

[[proxies]]
name = "moonlight-wireguard-udp-51820"
type = "udp"
localIP = "127.0.0.1"
localPort = 51820
remotePort = 51820

The server configuration is:

bindPort = 8000

auth.method = "token"
auth.token = "PUT_YOUR_FRP_TOKEN_HERE"

Then come the connections required by Sunshine/Moonlight itself. First, the client side:

serverAddr = "<public IP of frps>"
serverPort = 8001

auth.method = "token"
auth.token = "PUT_YOUR_FRP_TOKEN_HERE"


{{- range $_, $v := parseNumberRangePair "47984,47989,48010" "47984,47989,48010" }}
[[proxies]]
name       = "sunshine-tcp-{{ $v.First }}"
type       = "tcp"
localIP    = "127.0.0.1"
localPort  = {{ $v.First }}
remotePort = {{ $v.Second }}
{{- end }}

{{- range $_, $v := parseNumberRangePair "47998-48000,48002,48010" "47998-48000,48002,48010" }}
[[proxies]]
name       = "sunshine-udp-{{ $v.First }}"
type       = "udp"
localIP    = "127.0.0.1"
localPort  = {{ $v.First }}
remotePort = {{ $v.Second }}
{{- end }}

And the server side:

bindPort = 8001

auth.method = "token"
auth.token = "PUT_YOUR_FRP_TOKEN_HERE"

The corresponding firewall ports to open are:

TCP: 8000, 8001, 47984, 47989, 48010
UDP: 51820, 47998-48000, 48002, 48010

The next step is to establish a WireGuard connection between the iOS device and the Sunshine server. There should be plenty of tutorials online for this part. You can read those, ATFAI, or refer to Step 4 in the repository.

Note that my script uses 10.0.42.1 as the server's virtual IP and 10.0.42.2 as the client IP by default. You can also modify the repository's .env file yourself, but the rest of this article will use these two IPs.

After scanning the QR code on your phone, importing the tunnel, and starting the WireGuard connection, enter 10.0.42.1 in Moonlight's Add PC page. If nothing unexpected happens, it should connect successfully, and a locked computer icon should appear on the main screen. Then tap the computer, and a pairing screen will appear. If you are not physically near the machine, you can use SSH port forwarding:

ssh -L 47990:127.0.0.1:47990 <user>@<server IP>

Then open https://localhost:47990/pin in your browser. Note that this is HTTPS, not HTTP. After completing the pairing process, the phone should be able to stream normally.

Software-Based DNS Hijacking

When your phone connects through VPN, it naturally looks for a DNS server. In general, the phone will automatically use the VPN server, namely 10.0.42.1, as its DNS server. Since you now have permission to modify the server, you can use dnsmasq or similar tools to rewrite the hostname you want to connect to into 10.0.42.1.

This way, when you enter the hostname in Moonlight, the phone will query dnsmasq, and dnsmasq will return this address. The result is equivalent to the previous step: it is still an internal address, but Moonlight now stores the hostname internally.

Concretely, suppose your goal is to use moon.example.test as the final access hostname, and it is currently mapped by the VPN to the virtual IP 10.0.42.1. This is equivalent to the following configuration:

MOON_HOST=moon.example.test
LAN_IP=10.0.42.1
WG_IFACE=moonwg0

First, clear any existing runtime process:

sudo pkill -f '/tmp/moon-dnsmasq.conf' 2>/dev/null || true
sudo rm -f /tmp/moon-dnsmasq.conf /tmp/moon-dnsmasq.pid /tmp/moon-dnsmasq.log

Write the dnsmasq configuration:

cat > /tmp/moon-dnsmasq.conf <<'EOF'
no-resolv
server=8.8.8.8
interface=moonwg0
listen-address=10.0.42.1
bind-dynamic
local=/moon.example.test/
address=/moon.example.test/10.0.42.1
log-queries
log-facility=/tmp/moon-dnsmasq.log
EOF

Then start dnsmasq:

sudo dnsmasq \
  --conf-file=/tmp/moon-dnsmasq.conf \
  --pid-file=/tmp/moon-dnsmasq.pid

Remember to allow UDP port 53 through the firewall.

On the phone side, if you have a terminal emulator, run:

dig @10.0.42.1 A moon.example.test +short

Make sure the result is 10.0.42.1, which verifies that the DNS hijacking has taken effect. Then open Moonlight on the phone, add a new host, and enter moon.example.test. If nothing unexpected happens, it should show host updated, and the second step is complete.

Modifying Public DNS

According to the experience from the Tieba post:

On the client side, because iOS does not allow adding public addresses, but hosts that have already been added are not restricted.

So the next step is to add a DNS A record in your domain provider, Vercel, Netlify, Cloudflare, or similar DNS management panel:

key: moon.example.test
value: <server IP>

Wait about a minute for DNS propagation, then turn off WireGuard on the phone. Open Moonlight again and wait for the exclamation mark in front of the computer to disappear, if there is one. After that, entering the host should give you real public-hostname streaming.

For future use, you no longer need to enable WireGuard. You can even uninstall it, as long as Sunshine is online and the following ports are reachable: TCP: 8000, 8001, 47984, 47989, 48010 and UDP: 47998-48000, 48002, 48010.

Of course, if you accidentally delete this Sunshine host in Moonlight, you will need to redo from Step 2;

if you even delete WireGuard, then you need to start over from the beginning.

But please note that this method currently seems to work only for non-TLS-ified hostnames. See the next section for details.

HTTPS Support

Let me state the conclusion first: after several days of hacking, I did not manage to make iOS public streaming work with HSTS-enabled HTTPS hostnames, such as this site's junyu33.me. This conclusion holds for both the official Moonlight client and the third-party VoidLink client. Although I failed, I will still record some observations that may be useful in the future.

  1. This phenomenon only appears on iOS devices. Moonlight on Android and Windows works perfectly fine.
  2. In the GitHub code of Moonlight iOS, the URL constructed for fresh manual add is http://<host>:47989/serverinfo. In other words, the app itself wants to use HTTP, but the internal implementation of iOS CFNetwork upgrades it to HTTPS and sends something like a TLS ClientHello. This is also consistent with the fact that both Moonlight and VoidLink fail in the same way.
  3. I once used stunnel/HAProxy, together with my own domain certificate, to decrypt the TLS-encrypted GET /serverinfo HTTP/1.1 request from the client and forward it to the Sunshine server. But the following /pair, cert, challenge, session, RTSP/UDP, and other steps would all need this MITM-like middle layer. The engineering complexity was not low, and I did not succeed after spending a day or two on it.
  4. The HSTS policy of my main site is strict-transport-security: max-age=31536000, without includeSubDomains, so it may not be the fault of junyu33.me's HSTS policy itself. One possible explanation is that because I had previously visited my own domain, the iOS device cached the HTTPS state of that domain, and CFNetwork formed some kind of recursive-HSTS-like state for my domain, causing the device to send a TLS handshake packet right from the start.

There is also a vague issue: after repeated HTTPS experiments, normal Moonlight Windows access to Sunshine would get stuck at the RTSP Handshake stage. Restarting NetworkManager did not help; I had to reboot the server machine to recover. I have not found a solution to this issue yet.