[GH-ISSUE #3091] [Android 16][XRay/VLESS WS+TLS] IPv6 endpoint causes TLS handshake failure with no IPv4 fallback; config edits are discarded #1434

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

Originally created by @MaratGaZa on GitHub (Aug 29, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3091

Describe the bug

AmneziaVPN reports Connected for an imported VLESS WebSocket + TLS profile,
but no traffic passes.

The endpoint domain is proxied by Cloudflare and has both A and AAAA records.
AmneziaVPN selects an IPv6 address. The bundled Xray core repeatedly reports:

transport/internet/websocket: failed to dial to [<CLOUDFLARE_IPV6>]:443
> remote error: tls: handshake failure

The connection does not reach the server-side VLESS/Xray service. The same
original VLESS profile works in Hiddify and Happ on the same Android device and
network. It also works from Hiddify on macOS.

Steps to reproduce

  1. Import a VLESS URI whose endpoint is a Cloudflare-proxied domain with A and
    AAAA records.
  2. Use type=ws, security=tls, a valid sni, WebSocket host, path, UUID and
    port 443.
  3. Disable both application and site split tunneling.
  4. Connect on Android 16.
  5. Observe that the UI changes to Connected, but Internet access is absent.
  6. Export the AmneziaVPN log.

Actual behavior

  • Android VPNService, TUN, tun2socks and Xray start.
  • The UI reports Connected before the outbound TLS session succeeds.
  • The endpoint is resolved to IPv6.
  • Xray repeatedly receives remote error: tls: handshake failure.
  • No IPv4 fallback occurs.
  • The server-side Xray journal receives no matching connection.

Confirmed workaround

A new VLESS URI was imported with a numeric Cloudflare IPv4 address in the
endpoint position. The original domain was retained in TLS SNI/serverName and
the WebSocket Host header. Traffic started working immediately.

Conceptual difference:

Failing:  vless://<UUID>@<DOMAIN>:443?...&sni=<DOMAIN>&host=<DOMAIN>...
Working:  vless://<UUID>@<CLOUDFLARE_IPV4>:443?...&sni=<DOMAIN>&host=<DOMAIN>...

No server-side change was required.

Configuration persistence problem

As a diagnostic attempt, the following was added to streamSettings:

"sockopt": {
  "domainStrategy": "UseIPv4"
}

The editor initially displayed the modified JSON, but after the first
connection attempt the settings returned to their defaults. The runtime log
still showed the IPv6 endpoint. There is no warning that the field is not
persisted or applied.

This appears related to, but is not identical to, #2958. That report concerns
regeneration of a managed server configuration. This case concerns an imported
client VLESS profile and its native Xray configuration on Android.

Expected behavior

  • Use IPv4 fallback when the selected IPv6 endpoint cannot complete TLS.
  • Do not report Connected solely because the Android VPN interface started.
  • Preserve supported manual native-Xray edits, or clearly mark unsupported
    fields as transient/read-only.
  • Provide a persistent IPv4/IPv6 endpoint strategy for imported VLESS profiles.

Environment

  • AmneziaVPN: 5.0.1.5, commit 7d4f3e0
  • Android: 16, SDK 36
  • Device: Redmi 23129RN51X
  • ABI: armeabi-v7a, armeabi
  • Bundled Xray core: 26.6.27
  • Transport: VLESS + WebSocket + TLS, port 443
  • Endpoint: Cloudflare-proxied domain with A and AAAA records
  • Split tunneling: disabled for applications and sites
  • Android Always-on VPN: disabled
  • Android block connections without VPN: disabled

Log files

Attach AmneziaVPN-public-sanitized.log from this draft package. It contains
only the minimal connection sequence and has no VPN UUID, domain, VPS address,
profile identifier, authentication keys or user public IP.

  • #2509: request for client-side domain strategy
  • #2704: Android Xray/uTLS failure with a different transport/security case
  • #2958: Xray configuration regeneration/state mismatch

AmneziaVPN-public-sanitized.log

Originally created by @MaratGaZa on GitHub (Aug 29, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3091 ## Describe the bug AmneziaVPN reports `Connected` for an imported VLESS WebSocket + TLS profile, but no traffic passes. The endpoint domain is proxied by Cloudflare and has both A and AAAA records. AmneziaVPN selects an IPv6 address. The bundled Xray core repeatedly reports: ```text transport/internet/websocket: failed to dial to [<CLOUDFLARE_IPV6>]:443 > remote error: tls: handshake failure ``` The connection does not reach the server-side VLESS/Xray service. The same original VLESS profile works in Hiddify and Happ on the same Android device and network. It also works from Hiddify on macOS. ## Steps to reproduce 1. Import a VLESS URI whose endpoint is a Cloudflare-proxied domain with A and AAAA records. 2. Use `type=ws`, `security=tls`, a valid `sni`, WebSocket `host`, path, UUID and port 443. 3. Disable both application and site split tunneling. 4. Connect on Android 16. 5. Observe that the UI changes to `Connected`, but Internet access is absent. 6. Export the AmneziaVPN log. ## Actual behavior - Android VPNService, TUN, tun2socks and Xray start. - The UI reports `Connected` before the outbound TLS session succeeds. - The endpoint is resolved to IPv6. - Xray repeatedly receives `remote error: tls: handshake failure`. - No IPv4 fallback occurs. - The server-side Xray journal receives no matching connection. ## Confirmed workaround A new VLESS URI was imported with a numeric Cloudflare IPv4 address in the endpoint position. The original domain was retained in TLS SNI/serverName and the WebSocket Host header. Traffic started working immediately. Conceptual difference: ```text Failing: vless://<UUID>@<DOMAIN>:443?...&sni=<DOMAIN>&host=<DOMAIN>... Working: vless://<UUID>@<CLOUDFLARE_IPV4>:443?...&sni=<DOMAIN>&host=<DOMAIN>... ``` No server-side change was required. ## Configuration persistence problem As a diagnostic attempt, the following was added to `streamSettings`: ```json "sockopt": { "domainStrategy": "UseIPv4" } ``` The editor initially displayed the modified JSON, but after the first connection attempt the settings returned to their defaults. The runtime log still showed the IPv6 endpoint. There is no warning that the field is not persisted or applied. This appears related to, but is not identical to, #2958. That report concerns regeneration of a managed server configuration. This case concerns an imported client VLESS profile and its native Xray configuration on Android. ## Expected behavior - Use IPv4 fallback when the selected IPv6 endpoint cannot complete TLS. - Do not report `Connected` solely because the Android VPN interface started. - Preserve supported manual native-Xray edits, or clearly mark unsupported fields as transient/read-only. - Provide a persistent IPv4/IPv6 endpoint strategy for imported VLESS profiles. ## Environment - AmneziaVPN: 5.0.1.5, commit `7d4f3e0` - Android: 16, SDK 36 - Device: Redmi 23129RN51X - ABI: armeabi-v7a, armeabi - Bundled Xray core: 26.6.27 - Transport: VLESS + WebSocket + TLS, port 443 - Endpoint: Cloudflare-proxied domain with A and AAAA records - Split tunneling: disabled for applications and sites - Android Always-on VPN: disabled - Android block connections without VPN: disabled ## Log files Attach `AmneziaVPN-public-sanitized.log` from this draft package. It contains only the minimal connection sequence and has no VPN UUID, domain, VPS address, profile identifier, authentication keys or user public IP. ## Related issues - #2509: request for client-side domain strategy - #2704: Android Xray/uTLS failure with a different transport/security case - #2958: Xray configuration regeneration/state mismatch [AmneziaVPN-public-sanitized.log](https://github.com/user-attachments/files/31597112/AmneziaVPN-public-sanitized.log)
Author
Owner

@Vitalik93 commented on GitHub (Aug 29, 2026):

Может с этим связано? https://habr.com/ru/articles/1075272/

<!-- gh-comment-id:5462961351 --> @Vitalik93 commented on GitHub (Aug 29, 2026): Может с этим связано? https://habr.com/ru/articles/1075272/
Author
Owner

@MaratGaZa commented on GitHub (Aug 31, 2026):

Может с этим связано? https://habr.com/ru/articles/1075272/

Возможно, но такое поведение с AmneziaVPN у меня уже давно наблюдается и внутрение ошибки все равно остаются. Не сохраняется конфигурация в програме при ее изменении, не переключается на IPv4, не сообщать Connected если нет реальной связи, а только запустился интейфес VPN

<!-- gh-comment-id:5477088654 --> @MaratGaZa commented on GitHub (Aug 31, 2026): > Может с этим связано? https://habr.com/ru/articles/1075272/ Возможно, но такое поведение с AmneziaVPN у меня уже давно наблюдается и внутрение ошибки все равно остаются. Не сохраняется конфигурация в програме при ее изменении, не переключается на IPv4, не сообщать `Connected` если нет реальной связи, а только запустился интейфес VPN
Author
Owner

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

I checked this against both the exact 5.0.1.5 source from the report and current dev.

I think this report is source-confirmed, and there are actually several independent bugs here.

The most important one also explains the reported numeric-IPv4 workaround very closely.

1. Android pre-resolution can rewrite the TLS SNI and WebSocket Host, not only the endpoint

The VLESS importer correctly treats these as separate semantic fields.

For a typical Cloudflare WS + TLS URI it can produce approximately:

{
  "settings": {
    "vnext": [{
      "address": "vpn.example.com"
    }]
  },
  "streamSettings": {
    "security": "tls",
    "tlsSettings": {
      "serverName": "vpn.example.com"
    },
    "wsSettings": {
      "headers": {
        "Host": "vpn.example.com"
      }
    }
  }
}

ImportController::extractXrayConfig() then also extracts the outbound address and promotes it to the profile-level hostName.

On Android, Xray.startVpn() currently does:

var xrayJsonConfigString = xrayJsonConfig.toString()

config.getString("hostName").let { hostName ->
    val ipAddress = parseInetAddress(hostName).ip

    if (hostName != ipAddress) {
        xrayJsonConfigString =
            xrayJsonConfigString.replace(hostName, ipAddress)
    }
}

The important part is:

xrayJsonConfigString.replace(hostName, ipAddress)

This is not a structured replacement of:

outbounds[].settings.vnext[].address

It replaces every textual occurrence of the hostname in the entire XRay JSON.

So if the endpoint domain, TLS SNI and WebSocket Host are the same, which is a normal Cloudflare configuration, this can effectively turn:

vnext.address               = vpn.example.com
tlsSettings.serverName      = vpn.example.com
wsSettings.headers.Host     = vpn.example.com

into:

vnext.address               = <resolved IPv6>
tlsSettings.serverName      = <resolved IPv6>
wsSettings.headers.Host     = <resolved IPv6>

That destroys the separation between:

network destination

and:

TLS / HTTP virtual-host identity

For a CDN/proxy endpoint that distinction is critical.

This provides a direct explanation for the reported:

remote error: tls: handshake failure

It also explains the workaround unusually well

The reporter changed only the transport endpoint to a numeric Cloudflare IPv4 while preserving the original domain in SNI and WebSocket Host:

endpoint = <Cloudflare IPv4>
SNI      = original.domain
WS Host  = original.domain

In that case:

hostName == ipAddress

so this block does not execute:

xrayJsonConfigString.replace(hostName, ipAddress)

The SNI and WebSocket Host therefore remain domains.

That is exactly the configuration which the reporter says works.

So I think the first invariant should be:

transport endpoint selection
!=
TLS / HTTP application identity

Pre-resolving a connection endpoint must never rewrite SNI, HTTP Host, XHTTP host, or unrelated occurrences of the same string.

2. The same code also removes normal address fallback

Current Android utility code defines:

fun parseInetAddress(address: String): InetAddress =
    InetAddress.getByName(address)

So Amnezia asks the system resolver for one address.

If that happens to be an IPv6 address:

vpn.example.com
    ↓
InetAddress.getByName()
    ↓
2606:...

the client then hardcodes that address into the XRay JSON.

At that point XRay no longer receives:

vpn.example.com

as the transport endpoint.

It receives:

2606:...

Therefore XRay cannot subsequently resolve the hostname and try an IPv4 candidate.

The effective behavior is:

DNS has A + AAAA
        ↓
Android selects one result
        ↓
selected result is IPv6
        ↓
domain replaced with IPv6
        ↓
IPv6 attempt fails
        ↓
no domain remains in runtime config
        ↓
no IPv4 fallback

I would avoid fixing this with a hardcoded UseIPv4 unless IPv4-only behavior is explicitly selected by the user.

A stronger design is:

resolve endpoint candidates
        ↓
preserve endpoint identity separately
        ↓
apply explicit address-family policy / fallback

For example:

Auto
IPv4 preferred
IPv6 preferred
IPv4 only
IPv6 only

with Auto able to try another candidate after a failed connection.

If client-side pre-resolution is necessary, it should modify only the actual dial target structurally.

3. Connected currently means local XRay stack started, not remote XRay connection succeeded

After constructing the VPN and starting the local components, Android does:

start(xrayConfig, xrayJsonConfigString, vpnBuilder, protect)

state.value = CONNECTED
isRunning = true

Inside start() the code verifies that it can:

create Android VPN interface
start tun2socks
initialize XRay
start the XRay process/core

but there is no successful remote VLESS/TLS connection requirement before:

CONNECTED

is emitted.

So this sequence is currently valid:

VPNService established
tun2socks started
XRay started
        ↓
CONNECTED
        ↓
XRay repeatedly fails outbound TLS
        ↓
zero usable VPN traffic

which is exactly the symptom in this report.

The important state invariant should be:

LOCAL_STACK_READY
!=
REMOTE_DATA_PLANE_HEALTHY

Even if the UI ultimately keeps a single user-facing state, remote health should be observable separately so a completely unreachable VLESS endpoint is not indistinguishable from a working tunnel.

4. The raw configuration UI currently looks editable but does not save edits

The reported:

"sockopt": {
    "domainStrategy": "UseIPv4"
}

disappearing is independently explainable by current UI code.

PageProtocolRaw.qml displays the native configuration using a normal editable:

TextArea {
    text: rawConfig
}

but I cannot find any:

readOnly: true

or:

onTextChanged
Apply
Save
setNativeConfig(...)
repository update

attached to that editor.

So the current behavior is effectively:

stored nativeConfig
        ↓
TextArea.text
        ↓
user edits TextArea
        ↓
only local QML text changes
        ↓
model remains unchanged
        ↓
page/model recreated
        ↓
stored nativeConfig displayed again

This explains why an edit can appear to have been accepted and then disappear after a connection attempt/navigation.

Importantly, the underlying XRay model itself can preserve arbitrary native JSON.

XrayClientConfig stores the whole native configuration string in:

nativeConfig

and its toJson() / fromJson() paths preserve that string.

So this does not appear to require converting every XRay option into a structured Amnezia setting just to persist it.

There are two valid UI contracts:

A. viewer only
   -> make the TextArea explicitly read-only

B. editor
   -> explicit Apply/Save
   -> validate JSON
   -> write nativeConfig back
   -> persist repository state

The current third state:

looks editable
but silently discards edits

should be avoided.

5. There is also an adjacent IPv6 exclusion bug

Android XRay currently adds the endpoint exclusion with:

config.getString("hostName").let {
    excludeRoute(InetNetwork(it, 32))
}

But InetNetwork itself knows that an IPv6 host route should use /128:

private val InetAddress.maxPrefixLength: Int
    get() = if (this is Inet4Address) 32 else 128

When hostName resolves to IPv6, the XRay path still explicitly forces:

/32

rather than:

/128

So instead of excluding one IPv6 endpoint from the Android VPN, it can exclude a very large IPv6 network.

That does not appear necessary to explain the TLS failure above, so I would treat it as an adjacent routing/privacy bug.

But the invariant should clearly be:

IPv4 endpoint -> /32 host exclusion
IPv6 endpoint -> /128 host exclusion

Suggested fix boundary

I would make endpoint handling explicit rather than performing string transformation on serialized configuration:

original XRay JSON
        ↓
read vnext.address
        ↓
resolve according to explicit family policy
        ↓
modify only vnext.address for runtime
        ↓
leave unchanged:
    TLS serverName
    Reality serverName
    WebSocket Host
    XHTTP host
    HTTP headers
    other JSON strings

Even better, keep:

logical endpoint
resolved candidate
TLS identity
HTTP identity

as distinct values instead of deriving one from another by text replacement.

Regression cases

  1. VLESS WS + TLS:
vnext.address = vpn.example.com
SNI           = vpn.example.com
WS Host       = vpn.example.com

DNS returns IPv6 first.

After endpoint resolution:

vnext.address may become resolved IP
SNI must remain vpn.example.com
WS Host must remain vpn.example.com
  1. Numeric IPv4 endpoint:
vnext.address = 203.0.113.1
SNI           = vpn.example.com
WS Host       = vpn.example.com

SNI/Host must remain unchanged.

  1. A + AAAA endpoint:
first candidate fails
second candidate works

Expected:

connection can fall back

rather than pinning the session permanently to the first getByName() result.

  1. Same hostname appears in an unrelated field in XRay JSON.

Expected:

only transport address changes
  1. XRay process and tun2socks start successfully, but outbound TLS continuously fails.

Expected:

must not be treated as healthy remote connectivity
  1. Native JSON UI:
add sockopt.domainStrategy
Apply
close page
reopen
restart app
connect

If editing is supported, the field must survive all those transitions and appear in the runtime config.

If editing is not supported, the viewer must be read-only.

  1. IPv6 endpoint exclusion:
2001:db8::1234

Expected:

2001:db8::1234/128

not /32.

So I would classify this report as confirmed on current dev.

The strongest root cause is not simply “XRay prefers IPv6”:

Android pre-resolves the endpoint to one address and performs a global textual hostname→IP replacement over the entire XRay JSON. That can simultaneously pin the connection to one IPv6 candidate and overwrite the TLS SNI / WebSocket Host that the CDN requires.

The false Connected state and non-persistent raw-config editor are separate, source-confirmed bugs exposed by the same report.

<!-- gh-comment-id:5608793551 --> @HeisLuka commented on GitHub (Sep 9, 2026): I checked this against both the exact `5.0.1.5` source from the report and current `dev`. I think this report is source-confirmed, and there are actually several independent bugs here. The most important one also explains the reported numeric-IPv4 workaround very closely. ### 1. Android pre-resolution can rewrite the TLS SNI and WebSocket Host, not only the endpoint The VLESS importer correctly treats these as separate semantic fields. For a typical Cloudflare WS + TLS URI it can produce approximately: ```json { "settings": { "vnext": [{ "address": "vpn.example.com" }] }, "streamSettings": { "security": "tls", "tlsSettings": { "serverName": "vpn.example.com" }, "wsSettings": { "headers": { "Host": "vpn.example.com" } } } } ``` `ImportController::extractXrayConfig()` then also extracts the outbound `address` and promotes it to the profile-level `hostName`. On Android, `Xray.startVpn()` currently does: ```kotlin var xrayJsonConfigString = xrayJsonConfig.toString() config.getString("hostName").let { hostName -> val ipAddress = parseInetAddress(hostName).ip if (hostName != ipAddress) { xrayJsonConfigString = xrayJsonConfigString.replace(hostName, ipAddress) } } ``` The important part is: ```kotlin xrayJsonConfigString.replace(hostName, ipAddress) ``` This is not a structured replacement of: ```text outbounds[].settings.vnext[].address ``` It replaces **every textual occurrence** of the hostname in the entire XRay JSON. So if the endpoint domain, TLS SNI and WebSocket Host are the same, which is a normal Cloudflare configuration, this can effectively turn: ```text vnext.address = vpn.example.com tlsSettings.serverName = vpn.example.com wsSettings.headers.Host = vpn.example.com ``` into: ```text vnext.address = <resolved IPv6> tlsSettings.serverName = <resolved IPv6> wsSettings.headers.Host = <resolved IPv6> ``` That destroys the separation between: ```text network destination ``` and: ```text TLS / HTTP virtual-host identity ``` For a CDN/proxy endpoint that distinction is critical. This provides a direct explanation for the reported: ```text remote error: tls: handshake failure ``` ### It also explains the workaround unusually well The reporter changed only the transport endpoint to a numeric Cloudflare IPv4 while preserving the original domain in SNI and WebSocket Host: ```text endpoint = <Cloudflare IPv4> SNI = original.domain WS Host = original.domain ``` In that case: ```kotlin hostName == ipAddress ``` so this block does not execute: ```kotlin xrayJsonConfigString.replace(hostName, ipAddress) ``` The SNI and WebSocket Host therefore remain domains. That is exactly the configuration which the reporter says works. So I think the first invariant should be: ```text transport endpoint selection != TLS / HTTP application identity ``` Pre-resolving a connection endpoint must never rewrite SNI, HTTP Host, XHTTP host, or unrelated occurrences of the same string. ### 2. The same code also removes normal address fallback Current Android utility code defines: ```kotlin fun parseInetAddress(address: String): InetAddress = InetAddress.getByName(address) ``` So Amnezia asks the system resolver for **one address**. If that happens to be an IPv6 address: ```text vpn.example.com ↓ InetAddress.getByName() ↓ 2606:... ``` the client then hardcodes that address into the XRay JSON. At that point XRay no longer receives: ```text vpn.example.com ``` as the transport endpoint. It receives: ```text 2606:... ``` Therefore XRay cannot subsequently resolve the hostname and try an IPv4 candidate. The effective behavior is: ```text DNS has A + AAAA ↓ Android selects one result ↓ selected result is IPv6 ↓ domain replaced with IPv6 ↓ IPv6 attempt fails ↓ no domain remains in runtime config ↓ no IPv4 fallback ``` I would avoid fixing this with a hardcoded `UseIPv4` unless IPv4-only behavior is explicitly selected by the user. A stronger design is: ```text resolve endpoint candidates ↓ preserve endpoint identity separately ↓ apply explicit address-family policy / fallback ``` For example: ```text Auto IPv4 preferred IPv6 preferred IPv4 only IPv6 only ``` with `Auto` able to try another candidate after a failed connection. If client-side pre-resolution is necessary, it should modify only the actual dial target structurally. ### 3. `Connected` currently means local XRay stack started, not remote XRay connection succeeded After constructing the VPN and starting the local components, Android does: ```kotlin start(xrayConfig, xrayJsonConfigString, vpnBuilder, protect) state.value = CONNECTED isRunning = true ``` Inside `start()` the code verifies that it can: ```text create Android VPN interface start tun2socks initialize XRay start the XRay process/core ``` but there is no successful remote VLESS/TLS connection requirement before: ```text CONNECTED ``` is emitted. So this sequence is currently valid: ```text VPNService established tun2socks started XRay started ↓ CONNECTED ↓ XRay repeatedly fails outbound TLS ↓ zero usable VPN traffic ``` which is exactly the symptom in this report. The important state invariant should be: ```text LOCAL_STACK_READY != REMOTE_DATA_PLANE_HEALTHY ``` Even if the UI ultimately keeps a single user-facing state, remote health should be observable separately so a completely unreachable VLESS endpoint is not indistinguishable from a working tunnel. ### 4. The raw configuration UI currently looks editable but does not save edits The reported: ```json "sockopt": { "domainStrategy": "UseIPv4" } ``` disappearing is independently explainable by current UI code. `PageProtocolRaw.qml` displays the native configuration using a normal editable: ```qml TextArea { text: rawConfig } ``` but I cannot find any: ```text readOnly: true ``` or: ```text onTextChanged Apply Save setNativeConfig(...) repository update ``` attached to that editor. So the current behavior is effectively: ```text stored nativeConfig ↓ TextArea.text ↓ user edits TextArea ↓ only local QML text changes ↓ model remains unchanged ↓ page/model recreated ↓ stored nativeConfig displayed again ``` This explains why an edit can appear to have been accepted and then disappear after a connection attempt/navigation. Importantly, the underlying XRay model itself **can preserve arbitrary native JSON**. `XrayClientConfig` stores the whole native configuration string in: ```text nativeConfig ``` and its `toJson()` / `fromJson()` paths preserve that string. So this does not appear to require converting every XRay option into a structured Amnezia setting just to persist it. There are two valid UI contracts: ```text A. viewer only -> make the TextArea explicitly read-only B. editor -> explicit Apply/Save -> validate JSON -> write nativeConfig back -> persist repository state ``` The current third state: ```text looks editable but silently discards edits ``` should be avoided. ### 5. There is also an adjacent IPv6 exclusion bug Android XRay currently adds the endpoint exclusion with: ```kotlin config.getString("hostName").let { excludeRoute(InetNetwork(it, 32)) } ``` But `InetNetwork` itself knows that an IPv6 host route should use `/128`: ```kotlin private val InetAddress.maxPrefixLength: Int get() = if (this is Inet4Address) 32 else 128 ``` When `hostName` resolves to IPv6, the XRay path still explicitly forces: ```text /32 ``` rather than: ```text /128 ``` So instead of excluding one IPv6 endpoint from the Android VPN, it can exclude a very large IPv6 network. That does not appear necessary to explain the TLS failure above, so I would treat it as an adjacent routing/privacy bug. But the invariant should clearly be: ```text IPv4 endpoint -> /32 host exclusion IPv6 endpoint -> /128 host exclusion ``` ### Suggested fix boundary I would make endpoint handling explicit rather than performing string transformation on serialized configuration: ```text original XRay JSON ↓ read vnext.address ↓ resolve according to explicit family policy ↓ modify only vnext.address for runtime ↓ leave unchanged: TLS serverName Reality serverName WebSocket Host XHTTP host HTTP headers other JSON strings ``` Even better, keep: ```text logical endpoint resolved candidate TLS identity HTTP identity ``` as distinct values instead of deriving one from another by text replacement. ### Regression cases 1. VLESS WS + TLS: ```text vnext.address = vpn.example.com SNI = vpn.example.com WS Host = vpn.example.com ``` DNS returns IPv6 first. After endpoint resolution: ```text vnext.address may become resolved IP SNI must remain vpn.example.com WS Host must remain vpn.example.com ``` 2. Numeric IPv4 endpoint: ```text vnext.address = 203.0.113.1 SNI = vpn.example.com WS Host = vpn.example.com ``` SNI/Host must remain unchanged. 3. A + AAAA endpoint: ```text first candidate fails second candidate works ``` Expected: ```text connection can fall back ``` rather than pinning the session permanently to the first `getByName()` result. 4. Same hostname appears in an unrelated field in XRay JSON. Expected: ```text only transport address changes ``` 5. XRay process and tun2socks start successfully, but outbound TLS continuously fails. Expected: ```text must not be treated as healthy remote connectivity ``` 6. Native JSON UI: ```text add sockopt.domainStrategy Apply close page reopen restart app connect ``` If editing is supported, the field must survive all those transitions and appear in the runtime config. If editing is not supported, the viewer must be read-only. 7. IPv6 endpoint exclusion: ```text 2001:db8::1234 ``` Expected: ```text 2001:db8::1234/128 ``` not `/32`. So I would classify this report as **confirmed on current dev**. The strongest root cause is not simply “XRay prefers IPv6”: **Android pre-resolves the endpoint to one address and performs a global textual hostname→IP replacement over the entire XRay JSON. That can simultaneously pin the connection to one IPv6 candidate and overwrite the TLS SNI / WebSocket Host that the CDN requires.** The false `Connected` state and non-persistent raw-config editor are separate, source-confirmed bugs exposed by the same report.
Author
Owner

@MaratGaZa commented on GitHub (Sep 14, 2026):

@HeisLuka, thank you very much for this exceptionally detailed source-level analysis and for checking both version 5.0.1.5 and the current dev branch.

Your explanation of the global hostname-to-IP replacement matches my observed workaround exactly: using a numeric IPv4 endpoint while preserving the original domain in TLS SNI and the WebSocket Host immediately restored traffic.

It also clearly explains why the UseIPv4 edit disappeared and why AmneziaVPN reported Connected despite the repeated outbound TLS failures.

I can retest this on the same Redmi device with Android 16 and provide fresh sanitized logs when a fix or test build becomes available.

<!-- gh-comment-id:5663053280 --> @MaratGaZa commented on GitHub (Sep 14, 2026): **@HeisLuka**, thank you very much for this exceptionally detailed source-level analysis and for checking both version 5.0.1.5 and the current dev branch. Your explanation of the global hostname-to-IP replacement matches my observed workaround exactly: using a numeric IPv4 endpoint while preserving the original domain in TLS SNI and the WebSocket Host immediately restored traffic. It also clearly explains why the `UseIPv4` edit disappeared and why AmneziaVPN reported `Connected` despite the repeated outbound TLS failures. I can retest this on the same Redmi device with Android 16 and provide fresh sanitized logs when a fix or test build becomes available.
Author
Owner

@MaratGaZa commented on GitHub (Sep 14, 2026):


I would like to try contributing a focused fix for the Android hostname-to-IP
replacement described by @HeisLuka, with regression tests that preserve TLS SNI,
WebSocket Host, and unrelated JSON values.

Is anyone already working on this? Would you welcome a PR targeting dev for
this part, leaving address fallback, route exclusions, and the raw-config UI
as separate changes?

I am new to the Qt/Android codebase and plan to use AI assistance. I will review
the patch and report exactly which build and device checks I can perform.
Do you have any contribution or AI-disclosure requirements I should follow?
I can also test your own fix on the same Redmi device running Android 16.


<!-- gh-comment-id:5663735184 --> @MaratGaZa commented on GitHub (Sep 14, 2026): --- I would like to try contributing a focused fix for the Android hostname-to-IP replacement described by @HeisLuka, with regression tests that preserve TLS SNI, WebSocket Host, and unrelated JSON values. Is anyone already working on this? Would you welcome a PR targeting dev for this part, leaving address fallback, route exclusions, and the raw-config UI as separate changes? I am new to the Qt/Android codebase and plan to use AI assistance. I will review the patch and report exactly which build and device checks I can perform. Do you have any contribution or AI-disclosure requirements I should follow? I can also test your own fix on the same Redmi device running Android 16. ---
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#1434
No description provided.