VLESS + REALITY on Xray-core, no panel or root: openssl disguise check
Contents
VLESS + REALITY stands up with almost nothing — one Xray-core binary, two config files, no panel, no root. I set it up on a spare Linux machine in under ten minutes, then went a step further than most guides and used openssl s_client to confirm the disguise actually holds.
I’d previously written an overview of VLESS + REALITY, but that was a resource roundup — I hadn’t actually run anything. This post is the hands-on follow-up, with no 3X-UI panel: just the Xray-core binary and config files, to see how little it takes.
No root, no domain, no certificate of your own: download one binary, write two config files, and VLESS + REALITY is up. From key generation to a working SOCKS connection took under ten minutes. The friction wasn’t the steps themselves but version drift — newer Xray changed an output label and throws a port warning, so copy-pasting old instructions trips up in a few spots.
And a working tunnel only means traffic flows. The point of REALITY is that, from the outside, the server looks like an ordinary HTTPS site and nothing more. I reproduced an outside prober’s view with openssl s_client and verified the disguise holds.
This is purely a local ease-of-setup test — server and client run on the same machine. Actually changing the geographic exit (getting outside China) is the production test, and that’s next, on Alibaba Cloud.
Test environment
Both the server and client run on a junk Dell Latitude 5310 with Mageia Linux installed.
| Item | Detail |
|---|---|
| Model | Dell Latitude 5310 |
| CPU | Intel Core i5-10310U (Comet Lake, 4C/8T, 1.7-4.4GHz) |
| Memory | ~16GB |
| Storage | NVMe SSD 238GB |
| OS | Mageia 9 (Xfce) / x86_64 |
| Privileges | regular user (no root) |
| Xray-core | v26.3.27 (latest at the time of writing) |
| Setup | no panel, no systemd unit (just run in the foreground to check) |
REALITY is a protocol that borrows a real site’s TLS certificate to disguise traffic, so you don’t have to provide your own domain or certificate. I covered the mechanism in the overview article, so I’ll skip it here.
The overall flow
This is all it takes to stand up.
flowchart TD
A[Get the Xray-core binary] --> B[Generate keypair / UUID / shortId]
B --> C[Write the server config<br/>VLESS + REALITY inbound]
C --> D[Test the config<br/>xray run -test]
D --> E[Write the client config<br/>SOCKS inbound + REALITY outbound]
E --> F[Start both and check connectivity]
F --> G[Verify the disguise with openssl<br/>does the real cert come back]
Getting the binary
Just download the Linux 64-bit build from the GitHub releases and extract it. No installer, no package manager.
mkdir -p ~/vless-test && cd ~/vless-test
curl -sL -o xray.zip https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip
unzip -o xray.zip
chmod +x xray
./xray version
Xray 26.3.27 (Xray, Penetrates Everything.) d2758a0 (go1.26.1 linux/amd64)
The zip contains the xray binary itself plus geoip.dat / geosite.dat (regional data for routing). This minimal setup doesn’t use the geo data, but since it’s bundled I leave it in place.
Generating keys, UUID, and shortId
REALITY needs an x25519 keypair, a UUID to identify the client, and a shortId for authentication. The xray command produces all of them.
./xray x25519 # REALITY keypair
./xray uuid # client UUID
od -An -tx1 -N8 /dev/urandom | tr -d ' \n' # shortId (8-byte hex)
Here’s the output of xray x25519.
PrivateKey: IILkxOpNRR72F9h9k6yVWB537l6Df4fYxcunbnzLNUE
Password (PublicKey): p8MIhWYnijydf3ofqPlnf3p7OquIyXn99yU5WB5y0zo
Hash32: Kuy1X9sdUGaHU225_JLxNgc_OmKcOzAyYi9rIRYFfsM
This is the first gotcha. In older articles or panel screenshots the second line reads Public key:, but in the v26 series it’s changed to Password (PublicKey):. What you put on the client side is this Password (PublicKey) value (the public key), not Hash32. Pasting Hash32 and then puzzling over why it won’t connect is an easy trap. Remember: the server gets the PrivateKey, the client gets the Password (PublicKey).
PrivateKey is, as the name says, a private key — never expose it in production. The values in this article were generated for a throwaway local test and have already been discarded.
Server config
Write a single VLESS + REALITY inbound (inbounds). Since I’m not using root, it listens on 127.0.0.1:8443. For the disguise target (dest / serverNames) I picked www.cloudflare.com, which reliably supports TLS 1.3 and HTTP/2.
{
"log": { "loglevel": "warning" },
"inbounds": [
{
"listen": "127.0.0.1",
"port": 8443,
"protocol": "vless",
"settings": {
"clients": [
{ "id": "97e1a708-...", "flow": "xtls-rprx-vision" }
],
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"show": false,
"dest": "www.cloudflare.com:443",
"xver": 0,
"serverNames": ["www.cloudflare.com"],
"privateKey": "IILkxOpNRR72F9h9k6yVWB537l6Df4fYxcunbnzLNUE",
"shortIds": ["2c26a4c08d1cce62"]
}
}
}
],
"outbounds": [{ "protocol": "freedom" }]
}
Key points:
| Item | Setting |
|---|---|
privateKey | goes on the server side (not the public key) |
dest / serverNames | must match the client’s serverName |
flow | xtls-rprx-vision (keep it the same on server and client) |
outbounds | freedom (sends received traffic straight out) |
Once written, validate the config before starting.
./xray run -test -c server.json
[Warning] infra/conf: REALITY: Listening on non-443 ports may get your IP blocked by the GFW
Configuration OK.
If Configuration OK appears, the config syntax is valid. Here’s the second gotcha. Listening on a non-443 port triggers a warning that the GFW may block your IP. The essence of REALITY is to look like an ordinary HTTPS site on 443, so in production you’ll listen on 443. That design assumption surfaces as a pre-start warning. Since this is just a test, I kept 8443.
Client config
On the client side you create a SOCKS inbound and route the outbound through VLESS + REALITY. Point curl (or anything) at this SOCKS proxy and the traffic flows to the server over REALITY.
{
"log": { "loglevel": "warning" },
"inbounds": [
{ "listen": "127.0.0.1", "port": 10808, "protocol": "socks", "settings": { "udp": true } }
],
"outbounds": [
{
"protocol": "vless",
"settings": {
"vnext": [
{
"address": "127.0.0.1",
"port": 8443,
"users": [
{ "id": "97e1a708-...", "encryption": "none", "flow": "xtls-rprx-vision" }
]
}
]
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"serverName": "www.cloudflare.com",
"fingerprint": "chrome",
"publicKey": "p8MIhWYnijydf3ofqPlnf3p7OquIyXn99yU5WB5y0zo",
"shortId": "2c26a4c08d1cce62"
}
}
}
]
}
The easy-to-mismatch correspondences with the server:
| Client side | Value to set |
|---|---|
publicKey | the server’s Password (PublicKey) value |
serverName | matches the server’s serverNames |
shortId | one of the server’s shortIds |
fingerprint | chrome (mimics the TLS ClientHello as Chrome) |
If the public key, shortId, or disguise target is off by even one, it can silently fail to connect with no error log. When it won’t connect, suspect a mismatch in these three first.
Starting up and checking connectivity
Start both the server and client in the background.
./xray run -c server.json > server.log 2>&1 &
./xray run -c client.json > client.log 2>&1 &
Check that both ports are LISTENing with ss.
LISTEN 127.0.0.1:8443 users:(("xray",...))
LISTEN 127.0.0.1:10808 users:(("xray",...))
Check whether you can reach the outside through the SOCKS proxy (10808) with curl.
curl -s -x socks5h://127.0.0.1:10808 https://api.ipify.org?format=json
{"ip":"180.4.30.248"}
A response came back. The relay shows up in both the server and client logs too.
# client.log
from tcp:127.0.0.1:52466 accepted tcp:api.ipify.org:443
# server.log
from 127.0.0.1:38450 accepted tcp:api.ipify.org:443
The connection the client accepted passed through REALITY and was accepted on the server side too. The exit IP being the same as a direct connection is expected here — the server is on the same machine — so this only confirms that the path was established.
Checking that the disguise works
At this point we know traffic flows. But that’s not why you’d use REALITY. What matters is that from the outside, the server looks like nothing more than a plain HTTPS site. Stop at connectivity and you finish without ever confirming the disguise works.
Confirming it needs the viewpoint of a third party that doesn’t hold the auth keys. China’s GFW sends “active probes” at suspicious servers — connecting to them itself to figure out what they really are. I do the same thing with openssl s_client. Since openssl doesn’t know REALITY’s keys, it’s treated exactly like a probe without keys.
A REALITY server passes connections that fail authentication straight through to the disguise target (dest). So a party without keys gets a response from the real server behind the disguise target. If it works as intended, connecting to my own server should return the genuine www.cloudflare.com certificate.
First, for comparison, look at the certificate of the real www.cloudflare.com:443.
echo "Q" | openssl s_client -connect www.cloudflare.com:443 -servername www.cloudflare.com 2>/dev/null \
| grep -E "^(subject=|issuer=)"
subject=CN = www.cloudflare.com
issuer=C = US, O = Google Trust Services, CN = WE1
Next, connect to the server I just stood up (127.0.0.1:8443) with the same SNI.
echo "Q" | openssl s_client -connect 127.0.0.1:8443 -servername www.cloudflare.com 2>/dev/null \
| grep -E "^(subject=|issuer=|Verify return code)"
subject=CN = www.cloudflare.com
issuer=C = US, O = Google Trust Services, CN = WE1
Verify return code: 0 (ok)
The subject and issuer matched the real one exactly. The issuer is a genuine CA (Google Trust Services), and Verify return code: 0 (ok) means the chain verifies too. Checking the validity dates and SAN, it was the real thing through and through.
echo "Q" | openssl s_client -connect 127.0.0.1:8443 -servername www.cloudflare.com 2>/dev/null \
| openssl x509 -noout -dates -ext subjectAltName
notBefore=May 7 16:54:23 2026 GMT
notAfter=Aug 5 17:54:15 2026 GMT
X509v3 Subject Alternative Name:
DNS:www.cloudflare.com
Connecting to nothing but 127.0.0.1:8443 on my own machine, it’s indistinguishable from a real Cloudflare endpoint. Even under a probe, it looks like “a server that just happens to use Cloudflare” — that’s what it means for REALITY’s disguise to be working.
Cross-checking the behavior in the server log
Bump the server’s loglevel up to debug and send the same probe, and you can see what REALITY does internally.
REALITY remoteAddr: 127.0.0.1:35068 forwarded SNI: www.cloudflare.com
[Info] transport/internet/tcp: REALITY: processed invalid connection from 127.0.0.1:35068: authentication failed or validation criteria not met
It treats it as authentication failed (no keys, so auth fails) and forwards the SNI straight to the disguise target (forwarded SNI). Exactly the intended behavior: show the real thing to probes that don’t hold keys.
Try sending an SNI that differs from the disguise target (example.com) and it’s rejected for a different reason.
REALITY remoteAddr: 127.0.0.1:35076 forwarded SNI: example.com
[Info] transport/internet/tcp: REALITY: processed invalid connection from 127.0.0.1:35076: server name mismatch: example.com
It’s logged as server name mismatch. In both cases it passes through to the disguise target, so the response visible from outside is always a genuine TLS endpoint. Only a legitimate client (the one holding the keys) gets past authentication into the actual proxied traffic.
When you’re done checking, stop the processes.
pkill -f 'xray run'
How it went
In terms of ease of setup, it was almost anticlimactically simple. No root, no domain, no certificate — one binary and two config files, and the VLESS + REALITY path is established. A panel like 3X-UI is handy for multi-user management and traffic monitoring, but for standing up a single path solo just to check it, the bare core actually makes the structure easier to see.
What tripped me up wasn’t the procedure itself but version differences. The x25519 output label changed, the non-443 warning appears — there are quietly a few spots where old copy-paste no longer applies. Once you’ve got the key correspondence (private = server, public = client) and the matching disguise target down, the rest is smooth.
Even including the disguise check, the effort is small. One openssl s_client call to see whether the same certificate as the real site comes back makes it clear how the server looks from the outside. Skip this and you won’t notice when the tunnel works but the disguise is broken.
Next is the real thing. I’ll stand up the same setup on an Alibaba Cloud instance and test whether I can get out from inside China. This time was only “does it stand up”; next is “can it escape the Chinese internet.”