[GH-ISSUE #3145] Xray/REALITY: dest is always default and never follows the SNI set in the UI #1461

Open
opened 2026-10-13 20:42:53 +00:00 by bsakaguchi · 1 comment
Owner

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

Describe the bug

For an Xray (VLESS + REALITY) container, the generated server config always contains

"dest": "www.googletagmanager.com:443"

regardless of what the user configures. The Server Name (SNI) field on the Xray security settings page writes only to serverNames. There is no UI control for the masking site that dest is derived from, so dest permanently keeps its built-in default while serverNames follows the user's input.

The result is a dest and serverNames mismatch that the user cannot fix from the app and is never warned about.

Why it's a problem

REALITY relays the TLS handshake of every incoming connection to the configured target site, and an unauthenticated visitor is served that site (dest is the legacy name of that field, current Xray-core documents it as target and treats the two as aliases).

The official REALITY documentation states that serverNames has to be consistent with it:

Usually this should stay consistent with target. In practice, valid values are any SNI accepted by the server according to the behavior of target, and typically refer to the SAN values on the returned certificate.

The config this app generates cannot satisfy that, because the two fields are driven by different inputs and only one of them is reachable from the UI.

With the mismatch in place, an active prober that connects to the server's IP with the configured SNI is forwarded to www.googletagmanager.com, which does not serve that name. It answers with an unrelated Google certificate or fails the handshake — exactly the anomaly REALITY exists to avoid. The whole point of choosing a masking site is defeated, and the user believes they have chosen one because the app offered them a field.

The mismatch does not break authorized clients, since the REALITY client authenticates via the REALITY signature rather than by validating the certificate chain against the SNI. That is why the problem is silent.

To Reproduce

Steps to reproduce the behavior:

  1. Add a self-hosted server, install the Xray container.
  2. Open the protocol settings, set Server Name (SNI) to any domain other than the default, e.g. example.com.
  3. Save.
  4. On the server, read the generated config from inside the container:
    sudo docker exec amnezia-xray cat /opt/amnezia/xray/server.json
    

Actual behavior

"realitySettings": {
    "dest": "www.googletagmanager.com:443",
    "fingerprint": "chrome",
    "privateKey": "...",
    "serverNames": ["example.com"],
    "shortIds": ["..."]
}

dest and serverNames refer to two unrelated sites.

Expected behavior

Either of:

  • dest follows the configured name, so the generated config is self-consistent; or
  • the masking site is exposed as its own UI field next to SNI, with validation that the entered SNI is served by the chosen dest.

At minimum the app should not silently produce a config whose dest and serverNames disagree.

Notes from the code

Not a diagnosis, just what turned up while looking for a workaround. Line references point at the 5.0.1.5 tag; the same code is present in current dev.

  • dest is built from XrayServerConfig::site, serverNames from XrayServerConfig::sni - two independent fields (xrayConfigurator.cpp#L418-L425). Both are seeded with the same default at container creation (installerBase.cpp#L87).
  • SiteRole exists in the model, but no QML page binds it and grep -rn "site" client/ui/qml/Pages2/PageProtocolXray*.qml returns nothing. The only exposed field is Server Name (SNI) → SniRole (PageProtocolXraySecuritySettings.qml#L275), so site can never diverge from its default and dest is effectively hardcoded.
  • Worth accounting for when fixing: the read-back paths overwrite site with serverNames[0] (xrayInstaller.cpp#L135, xrayProtocolConfig.cpp#L548). A dest set by hand through config export/import is therefore reverted by the next save from the UI, so a UI-only fix may be silently undone by this path.

Desktop (please complete the following information):

  • OS: macOS 26.5.2
  • Version 5.0.1.5

Server (please complete the following information):

  • OS: Debian GNU/Linux 12 (bookworm)
Originally created by @koryukov on GitHub (Sep 7, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3145 **Describe the bug** For an Xray (VLESS + REALITY) container, the generated server config always contains ```json "dest": "www.googletagmanager.com:443" ``` regardless of what the user configures. The **Server Name (SNI)** field on the Xray security settings page writes only to `serverNames`. There is no UI control for the masking site that `dest` is derived from, so `dest` permanently keeps its built-in default while `serverNames` follows the user's input. The result is a `dest` and `serverNames` mismatch that the user cannot fix from the app and is never warned about. **Why it's a problem** REALITY relays the TLS handshake of every incoming connection to the configured target site, and an unauthenticated visitor is served that site (`dest` is the legacy name of that field, current Xray-core documents it as `target` and treats the two as aliases). The [official REALITY documentation](https://xtls.github.io/en/config/transports/reality.html) states that `serverNames` has to be consistent with it: > Usually this should stay consistent with `target`. In practice, valid values are any SNI accepted by the server according to the behavior of `target`, and typically refer to the SAN values on the returned certificate. The config this app generates cannot satisfy that, because the two fields are driven by different inputs and only one of them is reachable from the UI. With the mismatch in place, an active prober that connects to the server's IP with the configured SNI is forwarded to `www.googletagmanager.com`, which does not serve that name. It answers with an unrelated Google certificate or fails the handshake — exactly the anomaly REALITY exists to avoid. The whole point of choosing a masking site is defeated, and the user believes they have chosen one because the app offered them a field. The mismatch does not break authorized clients, since the REALITY client authenticates via the REALITY signature rather than by validating the certificate chain against the SNI. That is why the problem is silent. **To Reproduce** Steps to reproduce the behavior: 1. Add a self-hosted server, install the **Xray** container. 2. Open the protocol settings, set **Server Name (SNI)** to any domain other than the default, e.g. `example.com`. 3. Save. 4. On the server, read the generated config from inside the container: ``` sudo docker exec amnezia-xray cat /opt/amnezia/xray/server.json ``` **Actual behavior** ```json "realitySettings": { "dest": "www.googletagmanager.com:443", "fingerprint": "chrome", "privateKey": "...", "serverNames": ["example.com"], "shortIds": ["..."] } ``` `dest` and `serverNames` refer to two unrelated sites. **Expected behavior** Either of: - `dest` follows the configured name, so the generated config is self-consistent; or - the masking site is exposed as its own UI field next to SNI, with validation that the entered SNI is served by the chosen `dest`. At minimum the app should not silently produce a config whose `dest` and `serverNames` disagree. **Notes from the code** Not a diagnosis, just what turned up while looking for a workaround. Line references point at the `5.0.1.5` tag; the same code is present in current `dev`. - `dest` is built from `XrayServerConfig::site`, `serverNames` from `XrayServerConfig::sni` - two independent fields ([`xrayConfigurator.cpp#L418-L425`](https://github.com/amnezia-vpn/amnezia-client/blob/5.0.1.5/client/core/configurators/xrayConfigurator.cpp#L418-L425)). Both are seeded with the same default at container creation ([`installerBase.cpp#L87`](https://github.com/amnezia-vpn/amnezia-client/blob/5.0.1.5/client/core/installers/installerBase.cpp#L87)). - `SiteRole` exists in the model, but no QML page binds it and `grep -rn "site" client/ui/qml/Pages2/PageProtocolXray*.qml` returns nothing. The only exposed field is `Server Name (SNI)` → `SniRole` ([`PageProtocolXraySecuritySettings.qml#L275`](https://github.com/amnezia-vpn/amnezia-client/blob/5.0.1.5/client/ui/qml/Pages2/PageProtocolXraySecuritySettings.qml#L275)), so `site` can never diverge from its default and `dest` is effectively hardcoded. - Worth accounting for when fixing: the read-back paths overwrite `site` with `serverNames[0]` ([`xrayInstaller.cpp#L135`](https://github.com/amnezia-vpn/amnezia-client/blob/5.0.1.5/client/core/installers/xrayInstaller.cpp#L135), [`xrayProtocolConfig.cpp#L548`](https://github.com/amnezia-vpn/amnezia-client/blob/5.0.1.5/client/core/models/protocols/xrayProtocolConfig.cpp#L548)). A `dest` set by hand through config export/import is therefore reverted by the next save from the UI, so a UI-only fix may be silently undone by this path. **Desktop (please complete the following information):** - OS: macOS 26.5.2 - Version 5.0.1.5 **Server (please complete the following information):** - OS: Debian GNU/Linux 12 (bookworm)
Author
Owner

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

I checked this against current dev, and I think the strongest way to describe the bug is as a serialization/read-back asymmetry rather than only a missing UI field.

XrayServerConfig currently treats site and sni as two independent pieces of state:

site -> server realitySettings.dest
sni -> server realitySettings.serverNames[0]

They are also stored separately in XrayServerConfig::toJson()/fromJson() and compared separately by hasEqualServerSettings().

But the reverse server path does not preserve that model.

XrayInstaller::extractConfigFromContainer() currently ignores realitySettings.dest and does effectively:

serverNames[0] -> srv.sni
serverNames[0] -> srv.site

This gives a deterministic round-trip failure.

For example:

initial model:
site = cover.example
sni = www.cover.example

generated server config:
dest = cover.example:443
serverNames = ["www.cover.example"]

extractConfigFromContainer():
site = www.cover.example
sni = www.cover.example

generated again:
dest = www.cover.example:443

So:

decode_server(encode_server(config)) != config

and even:

encode_server(decode_server(encode_server(config)))
!= encode_server(config)

There is a second, slightly different case in hydrateServerConfigFromClientNative().

A REALITY client config contains serverName, but it does not contain the server-side dest/target. Therefore that representation simply does not have enough information to reconstruct site.

Currently hydration still does essentially:

client serverName -> srv.sni
client serverName -> srv.site

I would avoid doing that. A lossy representation should not overwrite a canonical field it cannot actually observe.

I think there are two internally consistent fixes, depending on the intended product model:

A. site and sni are conceptually one setting

Then make one field authoritative and derive both dest and serverNames from it everywhere. Do not keep a second independently persisted value that the UI cannot edit.

B. site/target and sni are independent settings

Then:

UI:
expose Target / Masking site
expose SNI

server serialization:
site -> dest/target
sni -> serverNames

server read-back:
dest/target -> site
serverNames[0] -> sni

client hydration:
serverName -> sni
site -> preserve existing value / unknown

I would add regression tests at the representation boundaries, not only a UI test:

  1. site == sni
    -> current/default behaviour stays unchanged

  2. site != sni
    -> server generate -> read-back preserves both values

  3. existing site + client native config containing only serverName
    -> hydration changes sni if needed
    -> site is NOT overwritten

  4. server config with explicit dest + serverNames
    -> extract -> regenerate is semantically identical

That should also prevent a UI-only fix from being silently undone later by container read-back or client hydration.

<!-- gh-comment-id:5585844543 --> @HeisLuka commented on GitHub (Sep 8, 2026): I checked this against current dev, and I think the strongest way to describe the bug is as a serialization/read-back asymmetry rather than only a missing UI field. XrayServerConfig currently treats site and sni as two independent pieces of state: site -> server realitySettings.dest sni -> server realitySettings.serverNames[0] They are also stored separately in XrayServerConfig::toJson()/fromJson() and compared separately by hasEqualServerSettings(). But the reverse server path does not preserve that model. XrayInstaller::extractConfigFromContainer() currently ignores realitySettings.dest and does effectively: serverNames[0] -> srv.sni serverNames[0] -> srv.site This gives a deterministic round-trip failure. For example: initial model: site = cover.example sni = www.cover.example generated server config: dest = cover.example:443 serverNames = ["www.cover.example"] extractConfigFromContainer(): site = www.cover.example sni = www.cover.example generated again: dest = www.cover.example:443 So: decode_server(encode_server(config)) != config and even: encode_server(decode_server(encode_server(config))) != encode_server(config) There is a second, slightly different case in hydrateServerConfigFromClientNative(). A REALITY client config contains serverName, but it does not contain the server-side dest/target. Therefore that representation simply does not have enough information to reconstruct site. Currently hydration still does essentially: client serverName -> srv.sni client serverName -> srv.site I would avoid doing that. A lossy representation should not overwrite a canonical field it cannot actually observe. I think there are two internally consistent fixes, depending on the intended product model: A. site and sni are conceptually one setting Then make one field authoritative and derive both dest and serverNames from it everywhere. Do not keep a second independently persisted value that the UI cannot edit. B. site/target and sni are independent settings Then: UI: expose Target / Masking site expose SNI server serialization: site -> dest/target sni -> serverNames server read-back: dest/target -> site serverNames[0] -> sni client hydration: serverName -> sni site -> preserve existing value / unknown I would add regression tests at the representation boundaries, not only a UI test: 1. site == sni -> current/default behaviour stays unchanged 2. site != sni -> server generate -> read-back preserves both values 3. existing site + client native config containing only serverName -> hydration changes sni if needed -> site is NOT overwritten 4. server config with explicit dest + serverNames -> extract -> regenerate is semantically identical That should also prevent a UI-only fix from being silently undone later by container read-back or client hydration.
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#1461
No description provided.