[GH-ISSUE #3132] [macOS][XRay] Domain endpoint: utun is configured but nothing traverses the tunnel until teardown; literal IP works; iOS works #1454

Open
opened 2026-10-13 20:42:46 +00:00 by bsakaguchi · 3 comments
Owner

Originally created by @konst643 on GitHub (Sep 6, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3132

Describe the bug

On macOS, an XRay (VLESS + Reality) profile whose server address is a domain name never establishes: the app reaches "Connected", the utun interface is created but never receives an IP address, no traffic flows, and networking stays broken until disconnect. On disconnect the log shows Failed to stop xray.

Replacing only the server address with the server's literal IPv4 (same key, same Reality parameters, same SNI) makes the very same profile work: utun gets its address and traffic flows.

To Reproduce

  1. Self-host xray-core (26.3.x), VLESS + Reality on TCP 443, with a DNS A record pointing at the server.
  2. On macOS, import a vpn:// key (or vless:// link) where the outbound vnext.address is the domain name.
  3. Connect. The app shows "Connected", but ifconfig shows the new utun interface without an inet address; nothing loads, the machine effectively loses networking until disconnect.
  4. Re-import the same profile with vnext.address set to the server's literal IPv4 — it works: the utun gets its address (e.g. 10.x.x.x) and traffic flows through the server.

Expected behavior

A domain endpoint should work: resolve the name before the tunnel takes over the default route (or add a bypass route for the resolved IP once known). Otherwise the resolver query is routed into the tunnel that is still being established — a chicken-and-egg loop that can never complete.

Evidence that it is the resolution order

  • The same domain-based profile works in AmneziaVPN on iOS: the Network Extension excludes the provider's own traffic, so the name resolves outside the tunnel.
  • The same domain-based vless:// link works on the same Mac in a third-party client (V2Box), which resolves the name before route setup and adds a host bypass route for the resolved address.
  • The "Use AmneziaDNS" toggle has no effect on this (tested both ways).
  • Not a duplicate of #2722: there the tunnel comes up and passes IP traffic while in-tunnel DNS fails; here the tunnel never comes up at all when the address is a domain.

Environment information

  • OS: macOS 26 (Darwin 25.6)
  • AmneziaVPN Client Version: 4.8.15.4 and 5.0.2.0 (both reproduce)
  • Protocol: XRay, VLESS + Reality over TCP 443, self-hosted xray-core 26.3.x
  • Import method: vpn:// key with XRay JSON config (also reproduces with vless:// link import)
Originally created by @konst643 on GitHub (Sep 6, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3132 ### Describe the bug On macOS, an XRay (VLESS + Reality) profile whose server address is a **domain name** never establishes: the app reaches "Connected", the `utun` interface is created but **never receives an IP address**, no traffic flows, and networking stays broken until disconnect. On disconnect the log shows `Failed to stop xray`. Replacing only the server address with the server's **literal IPv4** (same key, same Reality parameters, same SNI) makes the very same profile work: `utun` gets its address and traffic flows. ### To Reproduce 1. Self-host xray-core (26.3.x), VLESS + Reality on TCP 443, with a DNS A record pointing at the server. 2. On macOS, import a `vpn://` key (or `vless://` link) where the outbound `vnext.address` is the **domain name**. 3. Connect. The app shows "Connected", but `ifconfig` shows the new `utun` interface without an inet address; nothing loads, the machine effectively loses networking until disconnect. 4. Re-import the same profile with `vnext.address` set to the server's literal IPv4 — it works: the `utun` gets its address (e.g. `10.x.x.x`) and traffic flows through the server. ### Expected behavior A domain endpoint should work: resolve the name **before** the tunnel takes over the default route (or add a bypass route for the resolved IP once known). Otherwise the resolver query is routed into the tunnel that is still being established — a chicken-and-egg loop that can never complete. ### Evidence that it is the resolution order - The same domain-based profile works in AmneziaVPN on **iOS**: the Network Extension excludes the provider's own traffic, so the name resolves outside the tunnel. - The same domain-based `vless://` link works on the same Mac in a third-party client (V2Box), which resolves the name before route setup and adds a host bypass route for the resolved address. - The "Use AmneziaDNS" toggle has no effect on this (tested both ways). - Not a duplicate of #2722: there the tunnel comes up and passes IP traffic while in-tunnel DNS fails; here the tunnel never comes up at all when the address is a domain. ### Environment information - **OS:** macOS 26 (Darwin 25.6) - **AmneziaVPN Client Version:** 4.8.15.4 and 5.0.2.0 (both reproduce) - **Protocol:** XRay, VLESS + Reality over TCP 443, self-hosted xray-core 26.3.x - **Import method:** `vpn://` key with XRay JSON config (also reproduces with `vless://` link import)
Author
Owner

@ManguoLabsTech commented on GitHub (Sep 6, 2026):

Root cause

This is a route-before-resolve deadlock. The macOS client installs the tunnel interface as the default route before resolving the server domain name, so the DNS query itself is routed into the tunnel that cannot complete without knowing the server IP. The utun interface never receives an address because the xray-core process cannot reach the server to complete the handshake.

Why literal IP works

When you specify a literal IPv4, no DNS lookup is needed. The client (or xray-core) can immediately install a host route for that IP via the physical interface, then safely switch the default route to the tunnel.

Why iOS works

The Network Extension framework on iOS automatically excludes the VPN provider's own traffic from the tunnel. DNS queries and the initial connection to the server go through the original network interface, avoiding the deadlock.

What needs to change

The macOS client must resolve the server domain before changing the default route, then:

  1. Cache the resolved IP
  2. Install a host route for that IP via the physical gateway (e.g., route add <server-ip> <physical-gateway>)
  3. Start xray-core and bring up the tunnel
  4. Switch the default route to utun

Alternatively, adopt the macOS Network Extension API (like iOS) so the provider's traffic bypasses the tunnel automatically.

Workarounds

  • Use the server's IP address in profiles

  • Add a static /etc/hosts entry (if the IP is stable)

  • Use a client that resolves before route changes

Questions for the maintainers

  • Does the app call getaddrinfo / resolve domains before starting xray-core, or does it pass the domain string directly to xray-core's config?

  • Is the route installation done by the app or by xray-core?

  • Are you using a system extension, or utun via ioctl / NEPacketTunnelProvider?

Knowing the sequence (DNS → xray start → route change → IP assignment) will clarify where to inject the pre-resolution step.

<!-- gh-comment-id:5561467023 --> @ManguoLabsTech commented on GitHub (Sep 6, 2026): ## Root cause This is a **route-before-resolve deadlock**. The macOS client installs the tunnel interface as the default route before resolving the server domain name, so the DNS query itself is routed into the tunnel that cannot complete without knowing the server IP. The `utun` interface never receives an address because the xray-core process cannot reach the server to complete the handshake. ## Why literal IP works When you specify a literal IPv4, no DNS lookup is needed. The client (or xray-core) can immediately install a host route for that IP via the physical interface, then safely switch the default route to the tunnel. ## Why iOS works The Network Extension framework on iOS automatically excludes the VPN provider's own traffic from the tunnel. DNS queries and the initial connection to the server go through the original network interface, avoiding the deadlock. ## What needs to change The macOS client must resolve the server domain **before** changing the default route, then: 1. Cache the resolved IP 2. Install a host route for that IP via the physical gateway (e.g., `route add <server-ip> <physical-gateway>`) 3. Start xray-core and bring up the tunnel 4. Switch the default route to `utun` Alternatively, adopt the macOS Network Extension API (like iOS) so the provider's traffic bypasses the tunnel automatically. ## Workarounds - Use the server's IP address in profiles - Add a static `/etc/hosts` entry (if the IP is stable) - Use a client that resolves before route changes ## Questions for the maintainers - Does the app call `getaddrinfo` / resolve domains before starting xray-core, or does it pass the domain string directly to xray-core's config? - Is the route installation done by the app or by xray-core? - Are you using a system extension, or `utun` via `ioctl` / `NEPacketTunnelProvider`? Knowing the sequence (DNS → xray start → route change → IP assignment) will clarify where to inject the pre-resolution step.
Author
Owner

@HeisLuka commented on GitHub (Sep 8, 2026):

I checked current dev, and I think it is worth separating two things here.

The existing “route-before-resolve deadlock” explanation may be directionally useful, but current code already contains some pre-resolution/underlay protection:

m_remoteAddress =
NetworkUtilities::getIPAddress(
m_rawConfig.value(configKey::hostName).toString());

so the client resolves hostName before XRay startup.

On macOS, Xray::startXray() also installs physical-interface routes before amnezia_xray_start(), and XRay sockets are bound using:

IP_BOUND_IF
IPV6_BOUND_IF

Therefore I would not yet treat “the DNS query is definitely sent into the unfinished tunnel” as proven without observing the actual XRay DNS socket/path.

There is, however, a deterministic false-success path in the macOS TUN setup that seems directly relevant to:

UI says Connected
but utun has no inet address

RouterMac::createTun() currently runs:

ifconfig inet up

and checks only whether QProcess started and finished.

It does not check:

process.exitStatus()
process.exitCode()
stderr

so a failed ifconfig command can still return true.

XrayProtocol::setupRouting() then treats that as:

TUN address successfully assigned

and after setupRouting() returns NoError, the connection is promoted to:

Vpn::ConnectionState::Connected

I would add a regression test for this independently of the DNS root cause:

make macOS ifconfig return non-zero

expected:
createTun() == false
setupRouting() fails
connection never reaches Connected

forbidden:
command finished != address configured successfully

Minimal correctness fix:

if (!process.waitForFinished(...))
return false;

if (process.exitStatus() != QProcess::NormalExit ||
process.exitCode() != 0) {
qCritical() << process.readAllStandardError();
return false;
}

Even stronger would be a postcondition/read-back:

expected inet address is actually present on utun

before reporting routing setup success.

For the domain-vs-IP difference, I would then run one controlled A/B rather than infer DNS from the symptom:

A. current runtime config:
vnext.address = vpn.example.com

B. resolve vpn.example.com before startup,
use the resolved IP only as runtime vnext.address,
keep REALITY serverName/SNI unchanged

If B works while A consistently fails, that specifically isolates the second name-resolution inside XRay.

This matters because current client-side pre-resolution stores the result in m_remoteAddress, but the runtime XRay JSON is still passed to the service as xrayConfigStr; on macOS that pre-resolved address is not currently used the same way it is in the Windows routing path.

Useful logging for the same run would be:

original hostname
client pre-resolved A/AAAA
physical uplink iface/gateway
routeAddXray result
XRay actual dial target / DNS result
ifconfig exit code + stderr
utun expected address read-back
connection state transition

That should distinguish:

PRE_FLOW_DNS
vs
XRay transport failure
vs
TUN configuration false-success

instead of grouping all three under “domain endpoint fails”.

<!-- gh-comment-id:5586318877 --> @HeisLuka commented on GitHub (Sep 8, 2026): I checked current dev, and I think it is worth separating two things here. The existing “route-before-resolve deadlock” explanation may be directionally useful, but current code already contains some pre-resolution/underlay protection: m_remoteAddress = NetworkUtilities::getIPAddress( m_rawConfig.value(configKey::hostName).toString()); so the client resolves hostName before XRay startup. On macOS, Xray::startXray() also installs physical-interface routes before amnezia_xray_start(), and XRay sockets are bound using: IP_BOUND_IF IPV6_BOUND_IF Therefore I would not yet treat “the DNS query is definitely sent into the unfinished tunnel” as proven without observing the actual XRay DNS socket/path. There is, however, a deterministic false-success path in the macOS TUN setup that seems directly relevant to: UI says Connected but utun has no inet address RouterMac::createTun() currently runs: ifconfig <dev> inet <addr> <addr> up and checks only whether QProcess started and finished. It does not check: process.exitStatus() process.exitCode() stderr so a failed ifconfig command can still return true. XrayProtocol::setupRouting() then treats that as: TUN address successfully assigned and after setupRouting() returns NoError, the connection is promoted to: Vpn::ConnectionState::Connected I would add a regression test for this independently of the DNS root cause: make macOS ifconfig return non-zero expected: createTun() == false setupRouting() fails connection never reaches Connected forbidden: command finished != address configured successfully Minimal correctness fix: if (!process.waitForFinished(...)) return false; if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { qCritical() << process.readAllStandardError(); return false; } Even stronger would be a postcondition/read-back: expected inet address is actually present on utun before reporting routing setup success. For the domain-vs-IP difference, I would then run one controlled A/B rather than infer DNS from the symptom: A. current runtime config: vnext.address = vpn.example.com B. resolve vpn.example.com before startup, use the resolved IP only as runtime vnext.address, keep REALITY serverName/SNI unchanged If B works while A consistently fails, that specifically isolates the second name-resolution inside XRay. This matters because current client-side pre-resolution stores the result in m_remoteAddress, but the runtime XRay JSON is still passed to the service as xrayConfigStr; on macOS that pre-resolved address is not currently used the same way it is in the Windows routing path. Useful logging for the same run would be: original hostname client pre-resolved A/AAAA physical uplink iface/gateway routeAddXray result XRay actual dial target / DNS result ifconfig exit code + stderr utun expected address read-back connection state transition That should distinguish: PRE_FLOW_DNS vs XRay transport failure vs TUN configuration false-success instead of grouping all three under “domain endpoint fails”.
Author
Owner

@konst643 commented on GitHub (Sep 13, 2026):

Ran the controlled A/B you asked for, with capture on the server side as well. B works, A fails — and the tunnel interface gets its address in both arms, so the title of this issue is wrong: the utun is configured correctly, it just carries nothing.

Setup: macOS, AmneziaVPN 5.0.2.0, XRay VLESS + REALITY on my own server. Same profile in both arms; in B only vnext.address is replaced by the resolved IPv4, REALITY serverName left untouched.

A — vnext.address = edge.example.one

  • app reports Connected ~300 ms after XrayProtocol::start()
  • utun22 receives 10.33.0.2, MTU 1500 — the read-back you suggested would pass
  • the server receives nothing at all for as long as the tunnel is kept up; tested three times, holding 40 s, 80 s and 5 min 20 s
  • dig @1.1.1.1 example.com times out over UDP and over TCP; scutil --dns shows 1.1.1.1 / 1.0.0.1 scoped to the tunnel
  • at teardown the queued attempts flush at once: 256 accepted sessions within one second, 13 in the next, with the interface disappearing 6 s later

B — vnext.address = <resolved IPv4>, same key, same SNI

  • accepted sessions on the server within a second of the interface appearing, and continuously afterwards
  • resolution of a name that cannot be in the system cache succeeds
  • https://example.org returns 200 in 0.5 s; 3 MB download in 1.4 s

Routing is identical in both arms, so the data path is not what differs:

route to server: gateway 10.33.0.2, interface utun22
host routes for the server address: none
default via en0
0/1 via en0
128.0/1 via utun22
128.0/1 via en0

Two things there look wrong independently of this issue: there is no /32 exclusion for the server address in either arm, and the split halves are inconsistent — 0/1 points at the physical interface while 128.0/1 is installed twice, once per interface. Traffic in B flows regardless because the service's own sockets are bound to the uplink: one attempt shows 184 outbound connections from AmneziaVPN-service to port 443 over en0. So IP_BOUND_IF is doing the work the route table is not.

That lines up with what you predicted. Client-side pre-resolution into m_remoteAddress does happen, but the runtime JSON still carries the name, and XRay's own resolution of it is not bound to the uplink the way its dialer is. With the tunnel's scoped resolvers already installed and nothing traversing the tunnel, that query cannot complete — and it completes the instant the routes are removed, which is exactly when the burst arrives. By your taxonomy this is the second case, name resolution inside XRay, not PRE_FLOW_DNS and not TUN false-success.

Happy to re-run with any additional logging you want to add, or to retitle this issue to describe the actual symptom.

<!-- gh-comment-id:5654479889 --> @konst643 commented on GitHub (Sep 13, 2026): Ran the controlled A/B you asked for, with capture on the server side as well. B works, A fails — and the tunnel interface gets its address in both arms, so the title of this issue is wrong: the `utun` is configured correctly, it just carries nothing. Setup: macOS, AmneziaVPN 5.0.2.0, XRay VLESS + REALITY on my own server. Same profile in both arms; in B only `vnext.address` is replaced by the resolved IPv4, REALITY `serverName` left untouched. **A — `vnext.address = edge.example.one`** * app reports `Connected` ~300 ms after `XrayProtocol::start()` * `utun22` receives `10.33.0.2`, MTU 1500 — the read-back you suggested would pass * the server receives **nothing at all** for as long as the tunnel is kept up; tested three times, holding 40 s, 80 s and 5 min 20 s * `dig @1.1.1.1 example.com` times out over UDP *and* over TCP; `scutil --dns` shows 1.1.1.1 / 1.0.0.1 scoped to the tunnel * at teardown the queued attempts flush at once: **256 accepted sessions within one second**, 13 in the next, with the interface disappearing 6 s later **B — `vnext.address = <resolved IPv4>`, same key, same SNI** * accepted sessions on the server within a second of the interface appearing, and continuously afterwards * resolution of a name that cannot be in the system cache succeeds * `https://example.org` returns 200 in 0.5 s; 3 MB download in 1.4 s Routing is identical in both arms, so the data path is not what differs: ``` route to server: gateway 10.33.0.2, interface utun22 host routes for the server address: none default via en0 0/1 via en0 128.0/1 via utun22 128.0/1 via en0 ``` Two things there look wrong independently of this issue: there is no `/32` exclusion for the server address in either arm, and the split halves are inconsistent — `0/1` points at the physical interface while `128.0/1` is installed twice, once per interface. Traffic in B flows regardless because the service's own sockets are bound to the uplink: one attempt shows 184 outbound connections from `AmneziaVPN-service` to port 443 over `en0`. So `IP_BOUND_IF` is doing the work the route table is not. That lines up with what you predicted. Client-side pre-resolution into `m_remoteAddress` does happen, but the runtime JSON still carries the name, and XRay's own resolution of it is not bound to the uplink the way its dialer is. With the tunnel's scoped resolvers already installed and nothing traversing the tunnel, that query cannot complete — and it completes the instant the routes are removed, which is exactly when the burst arrives. By your taxonomy this is the second case, name resolution inside XRay, not `PRE_FLOW_DNS` and not TUN false-success. Happy to re-run with any additional logging you want to add, or to retitle this issue to describe the actual symptom.
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amnezia-client#1454
No description provided.