1. What the pieces are, and the problem they solve
None of sections 2 to 11 makes sense without four ideas underneath it: a tunnel, the TLS handshake a filter reads, the protocol carried inside that handshake, and the way a server can prove who it is without owning a certificate. This section covers those four ideas, then explains why the parts are arranged this way.
1.1 The problem
Every network you join decides what its users may reach. Hotel and airport wifi, mobile carriers and office firewalls usually allow one thing: HTTPS on TCP 443 to ordinary web servers, because blocking it breaks every website. Other ports are fair game for a block.
A VPN is the usual answer. Run a server somewhere else, connect to it, let it make your connections for you. That works until the network inspects the connection instead of only its port. WireGuard has a fixed handshake in its first packet. OpenVPN has its own. IPsec has IKE. Deep packet inspection matches those in a handful of bytes and drops the address, whichever port it was moved to.
The requirement, then, is a tunnel whose wire traffic cannot be told apart from a browser opening a website. That is what Xray is for.
One constraint applies here. The server is the router, and 443 on that router already serves an HTTPS site which has to keep its name, its certificate and its saved URLs. The tunnel shares the port instead of taking it.
1.2 What a tunnel is
A client program opens one connection to the server and holds it. Every connection an application makes is carried inside that one, in sequence. The server opens the real connection to the destination and copies bytes both ways. The destination sees the server’s address. The network in between sees one encrypted stream between two addresses.
Two capture points, one mechanism underneath:
| Shape | What it captures | Used here |
|---|---|---|
| SOCKS or HTTP proxy | One application, or one application’s traffic | Yes, the client profile in section 8 |
| TUN device (a full VPN) | Every packet the operating system sends | No |
1.3 What a filter sees in a TLS handshake
TLS 1.3 runs in this order. The client sends a ClientHello naming the site it wants, and that name travels in the clear. The server answers with a ServerHello, then its certificate and a signature. The client checks that the certificate chains to an authority it trusts and is valid for the name it asked for. Both sides derive keys, and encrypted records follow.
That gives a censor two cheap tests.
- Connect to the address and look at the answer. A real site presents a valid certificate for its own name and serves HTTP. A homemade proxy presents a self-signed certificate or a reset.
- Compare the stream with what a browser produces. Unusual record sizes, timings or a non-browser TLS fingerprint mark the address as worth blocking.
A tunnel that terminates TLS with a certificate of its own fails the first test unless it owns that name, and owning a name means DNS, a certificate authority and a record pointing at the tunnel. A certificate for a name that resolves nowhere is a signal by itself.
1.4 VLESS
VLESS is the protocol that runs inside the TLS session. Its job is to say, once per connection, who is connecting and where they want to go. It is deliberately thin.
- It authenticates with a UUID, inside the encrypted channel.
- It does no encryption of its own:
decryption: noneon the server,encryption: noneon the client. The TLS layer outside it does that work. - It adds a header of a few dozen bytes per connection. There is no obfuscation layer and no second cipher, so a filter has nothing of Xray’s own to match.
The thinness is the point. The only thing a network can inspect is the genuine TLS session around VLESS.
1.5 REALITY
REALITY answers the question of which certificate to present. It presents none of its own. The server borrows the handshake of a real site, the one named in dest: it forwards the handshake there and relays that site’s own certificate back. A stranger who connects gets a certificate for a real name, signed by a real authority, and a real HTTP answer when it asks.
Authentication does not involve the certificate at all.
- The client is configured with the server’s X25519 public key and a short ID.
- It puts a value derived from that key, and the short ID, into ClientHello fields a browser fills with random values, the session ID above all.
- The server holds the matching private half. It recognises the tag, derives the same shared secret, and answers with proof of it in the ServerHello’s corresponding field.
- The client checks that proof against the public key it was given. Only a holder of the private half could have produced it.
What an arriving connection gets:
| First bytes | Server action | What the peer sees |
|---|---|---|
| Tag matches and the short ID is listed | A VLESS session | The tunnel |
| Anything else | Spliced to dest | The real site’s certificate and HTTP answers |
What this buys, and what it costs:
- No domain, no certificate and no ACME for the tunnel. The only name in play belongs to the site behind the router.
- A prober gets the site, not a proxy. There is no reset and no self-signed certificate.
- The client’s trust does not rest on a certificate authority, so a compromised or coerced authority cannot impersonate this server to its clients.
- The site in
desthas to speak TLS 1.3 and answer HTTP normally, and the router has to reach it on 443. Section 9 keeps the three names equal, and section 10 checks what the port presents. - The endpoint address stays visible. REALITY changes what the port looks like, not where the packets go.
1.6 The flow string, xtls-rprx-vision
A browser reaching an HTTPS site already has TLS inside your TLS. Encrypting that twice costs the router CPU and gives the outer stream record sizes a browser would not produce. xtls-rprx-vision passes the inner TLS records through and pads only at the start of a connection, so the outer stream keeps browser-like boundaries.
The same string goes on both ends. A client that omits it, or sends a different flow, authenticates as a stranger and is spliced to the fallback with no error on either side. It is a TCP mechanism, so UDP and QUIC are not carried over it (section 11).
1.7 Xray
Xray-core is the program that implements the above. One static binary, one JSON configuration, and it handles the TLS work, REALITY, VLESS, routing, sniffing and the outbounds. OpenWrt has no package for it, so section 4 installs the binary and a procd unit by hand.
The parts, as they sit on this box:
| Part | Runs on | Job |
|---|---|---|
xray | Router | Listens on 443, terminates REALITY, carries VLESS, exits by the WAN address |
| nginx | Router | Serves the LAN side on the LAN address, and must not hold 443 on the WAN |
dest origin | Media box | A real HTTPS site with a real certificate, where strangers are spliced |
| Client | Phone or laptop | Performs the REALITY handshake and offers SOCKS and HTTP to applications |
1.8 Why not a simpler tunnel
| Alternative | What it needs | Why it is not used here |
|---|---|---|
| WireGuard | A UDP port, a key pair | Fixed handshake, matched and blocked by inspection, and it needs a port that is open |
| OpenVPN | A port and a certificate | Its handshake is a signature a filter matches |
| Shadowsocks | A port and a password | No TLS around it, so the pattern itself is what gets matched |
| Plain TLS inbound with your own certificate | A domain pointed at the router and a certificate | Needs the name and the certificate, which is the thing being defended |
| VLESS with REALITY on 443 | A real HTTPS site the router can reach | This procedure |
2. Why choose this
2.1 When it fits
- One public IPv4, and 443 already serves a site that has to keep its name, its certificate and its saved URLs.
- The router can reach that site, or another TLS origin, on 443.
- Clients have to work on networks where only 443 outbound is reliable.
- No domain is available for the tunnel, or pointing one at it is not wanted.
- If a spare port is reachable and a domain is available, a plain TLS inbound or WireGuard is less machinery than this.
2.2 What it buys
- One port for two services. The site keeps its name, its certificate and every saved URL.
- No certificate, DNS record or domain for the tunnel. REALITY authenticates on a key pair instead of on a name it has to own.
- A prober gets the real site. Anything that fails the handshake is spliced to
dest, which answers with its own certificate, so the port does not advertise a proxy. - One key pair serves several profiles.
shortIdslabels which entry a client claims, so one profile can be revoked without reissuing the rest. - The tunnel needs nothing new to listen on a port that is already open, so there is no second hole in the firewall to maintain.
2.3 What it costs
- The endpoint address stays visible in every packet. REALITY changes what the port looks like, not where the packets go, so an address block, a route change or a block on the fallback name ends the tunnel.
- The UUID is the credential in full. Anyone holding the URI has a proxy into the LAN: no second factor, no per-user limits, no expiry.
- One port is one point of failure. A network that filters or intercepts 443 leaves no fallback port.
- The fallback origin sees the router’s address on every spliced connection instead of the client’s, unless
xveris set and the origin reads the PROXY header. - Three places have to agree on one hostname:
serverNames, the clientserverName, and the certificate atdest. Nothing checks that for you, and a mismatch takes the public site down along with the tunnel. - A dormant 443 forward in the firmware’s own store can take the port back after an upgrade.
- Nothing rotates the Xray log, so
debuggrows without limit.
3. Prerequisites
Use this procedure when TCP 443 already serves an HTTPS site that has to keep serving. The inbound reads the first bytes of each connection: a client that proves it holds the server’s public key gets a VLESS session and exits by the WAN address, and every other connection is spliced to the TLS origin behind the router. Nothing is issued for the tunnel, no name is pointed at it, and no certificate is bought for it.
| Requirement | Why the procedure fails without it |
|---|---|
| One public IPv4 arriving on the router, with no DNAT for 443 | A port forward in front of Xray takes the connection before the listener sees it |
| A TLS origin the router can reach, with a certificate valid for the hostname you will use | REALITY splices every unauthenticated connection there, and a prober compares the name in that certificate |
| Xray 26.x built for the router’s architecture | x25519 output labels changed in 26.10, and older builds print a different middle line |
| An init system: procd on OpenWrt, systemd or OpenRC elsewhere | Something has to bind 443 after the network comes up and restart the process after a crash |
A client that speaks VLESS, REALITY and xtls-rprx-vision | The flow string is part of the handshake, and a client without it authenticates as a stranger |
Topology for the rest of this document:
Cloudflare (proxied A → 203.0.113.10)
outside clients ──────────────┬──────────────► 203.0.113.10:443 (router WAN)
│ │
│ xray: reality-in
│ ├─ handshake verifies ──► VLESS ──► freedom
│ │ exit = WAN address
│ └─ anything else ──────► dest 192.0.2.46:443
│ (media box, own cert)
LAN clients ── split DNS ──► 192.0.2.46:443 (never touches 203.0.113.10)
Each piece of that path, and the failure it prevents:
| Piece | Job | What fails without it |
|---|---|---|
| WAN interface address | One public IPv4 with nothing rewriting it, so 443 arrives at the listener intact | A DNAT in front sends 443 to the media box and Xray never sees a packet |
xray under procd | Binds 443, supervises, restarts after a crash, starts after the network | With START below the network it binds nothing and exits at boot |
reality-in inbound | Declares the protocol, the credentials, the fallback and sniffing for that one socket | Nothing listens on 443 |
| REALITY layer | Checks the authentication tag in the ClientHello and picks session or splice | The tunnel needs a certificate for a name pointed at it, which means a DNS record and an SNI to defend |
| VLESS | Carries the destination and the user identity inside the TLS session | There is no way to say who is connecting |
xtls-rprx-vision | Splices the inner TLS stream to the outer connection | Extra copies of every packet through the proxy |
dest fallback | Receives anything that fails authentication and serves its own certificate | A prober gets a reset, and the port looks like a proxy |
shortIds | Labels which server entry a client claims, so one key pair serves several profiles | Revoking one profile means reissuing all of them |
| nginx on the LAN address | Keeps the LAN-side vhost off the WAN address | A wildcard IPv6 bind takes the port first and Xray exits with a bind error |
| Vendor port-forward store | Re-applies DNAT rules on firmware save or upgrade | A dormant 443 forward returns and takes the port from Xray |
| Client REALITY outbound | Performs the handshake and cannot proceed without the server’s proof | Nothing authenticates and every connection falls back |
4. Install the binary and the service
OpenWrt carries no Xray package in the standard feeds, so the file goes in by hand and every path below is absolute. On a systemd host the project’s install script does the same work and leaves a unit behind.
4.1 Copy the binary and set its mode
mkdir -p /etc/xray
# copy the release binary for your architecture to /etc/xray/xray
chmod 0755 /etc/xray/xray
/etc/xray/xray version
Expected:
Xray 26.10.29 (go1.27.3 linux/arm64)
Mode 0755 because procd runs it as root and nothing else needs to read it.
4.2 Write the init script
The whole file is:
#!/bin/sh /etc/rc.common
START=90
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command /etc/xray/xray -c /etc/xray/config.json
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
| Setting | Value | Effect |
|---|---|---|
USE_PROCD | 1 | Hands supervision to procd, which is what gives START and respawn meaning |
START | 90 | Runs after the network is up; a lower value binds nothing at boot |
respawn | 3600 5 5 | Tolerates five crashes in an hour before procd gives up |
stdout, stderr | 1 | Sends the binary’s own messages to the system log, the only place a bind failure appears |
4.3 Enable the service
chmod 0755 /etc/init.d/xray
/etc/init.d/xray enable
ls /etc/rc.d/ | grep -i xray
Expected:
S90xray
That file is the proof the tunnel starts at boot. Do not start the service yet. If nginx still holds the IPv6 wildcard on 443, the start exits with a bind error and section 7 is the fix.
5. Generate the credentials
5.1 Generate the key pair
$ xray x25519
PrivateKey: 0N_QKPg0OCvNM2ES8RbBevLY6pI4Dcd8M7tuEkcbTU8
Password (PublicKey): YjaSGE6Qi3Kt_6Gby04vDMcpqMD73oAE_wUR1PK24kM
Hash32: Z3i9EBRMgeCivNpb6jDpxajrzhp4_wuaYjYy4e6u0
Take the Password (PublicKey) line as the public key; older builds print it as Password alone. Hash32 is a third value and not a substitute. A client handed Hash32 fails authentication and is spliced to the fallback without an error on either side.
5.2 Check the derivation
$ xray x25519 -i 0N_QKPg0OCvNM2ES8RbBevLY6pI4Dcd8M7tuEkcbTU8
PrivateKey: 0N_QKPg0OCvNM2ES8RbBevLY6pI4Dcd8M7tuEkcbTU8
Password (PublicKey): YjaSGE6Qi3Kt_6Gby04vDMcpqMD73oAE_wUR1PK24kM
The public key has to match the one from step 4.1. A mismatched pair passes every check until traffic falls back.
5.3 Generate one UUID per device
$ xray uuid
7a826016-8e5e-428c-92dd-3b720420413b
The UUID is not derived from the key pair and has no expiry. Retiring one means deleting its line and restarting. One per device buys exactly one thing: revoking a lost phone without reissuing everything else.
5.4 Generate the short ID
$ openssl rand -hex 8
9f2c1a7d4b8e6350
Sixteen hex characters. It is not a password. It labels which server entry a client claims, so several profiles share one key pair, and deleting the matching entry from shortIds invalidates every profile carrying it.
5.5 Place each value
| Value | Server field | Client field |
|---|---|---|
| Private key | realitySettings.privateKey | never |
| Public key | not used | publicKey / pbk |
| UUID | clients[].id | vnext[].users[].id |
| Short ID | shortIds[] | shortId / sid |
| Server name | serverNames[] | serverName / sni |
The private key stays in the server config at mode 0600 and nowhere else. Publishing the public key is safe: the client builds a tag from it that the server checks, so a client proves the server is genuine without holding a secret, and a leaked profile identifies one user without letting anyone impersonate the server to other clients.
6. Write the server config
6.1 Write /etc/xray/config.json
{
"log": { "loglevel": "warning" },
"inbounds": [
{
"tag": "reality-in",
"listen": "203.0.113.10",
"port": 443,
"protocol": "vless",
"settings": {
"clients": [
{
"id": "7a826016-8e5e-428c-92dd-3b720420413b",
"flow": "xtls-rprx-vision",
"email": "laptop"
}
],
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"dest": "192.0.2.46:443",
"xver": 0,
"serverNames": ["media.example.net"],
"privateKey": "0N_QKPg0OCvNM2ES8RbBevLY6pI4Dcd8M7tuEkcbTU8",
"shortIds": ["9f2c1a7d4b8e6350"],
"show": false
}
},
"sniffing": { "enabled": true, "destOverride": ["http", "tls", "quic"] }
}
],
"outbounds": [
{ "tag": "direct", "protocol": "freedom" },
{ "tag": "block", "protocol": "blackhole" }
],
"routing": {
"rules": [
{ "type": "field", "ip": ["192.0.2.0/24"], "outboundTag": "direct" }
]
}
}
chmod 0600 /etc/xray/config.json
Mode 0600 because the file holds the REALITY private key.
6.2 Check each field against what follows
| Field | Value in this config | What it decides |
|---|---|---|
listen, port | 203.0.113.10, 443 | The WAN address only. Naming the address keeps this listener clear of nginx on the LAN address, and a wildcard here collides with it (section 7). |
protocol | vless | The transport inside the TLS session. It carries addressing and identity, and does no encryption of its own. |
clients[].id | a UUID | Who is allowed in. Not derived from the key pair, so losing it means a new UUID and a new URI for that device. |
clients[].flow | xtls-rprx-vision | Flow control that pairs with REALITY over TCP. The client sends the same string, and a mismatch fails the handshake like a wrong key would. |
clients[].email | a label | A name in logs and stats. Nothing authenticates against it. |
decryption | none | Says that VLESS declines to encrypt, because TLS outside it does. |
dest | 192.0.2.46:443 | Where anything that fails authentication goes. A real service with a real certificate, not a decoy page. |
xver | 0 | Do not prepend a PROXY protocol header to the spliced connection. Set it when the fallback origin expects one and can report the real client address. |
serverNames | ["media.example.net"] | The hostnames the inbound accepts, and with dest the name the fallback certificate has to be valid for. |
privateKey | x25519 private half | Half the credential, mode 0600, never in a client profile. |
shortIds | one 16-hex value | The other half. Must include whatever the client sends, and several may be listed. |
show | false | Keeps the accepted server name out of the verbose log. |
sniffing | http, tls, quic | Recovers the destination name for routing rules. Without it, rules keyed on a domain never fire. |
routing.rules | 192.0.2.0/24 → direct | Answers anything addressed on the LAN on the LAN interface instead of treating it as tunnel traffic. |
outbounds | freedom, blackhole | Two exits: one delivers, one drops. The direct tag is what the routing rule and the default path point at. |
log.loglevel | warning | Debug writes several KB per connection burst into error.log and nothing rotates that file. warning keeps it small enough to leave alone. |
6.3 Validate before every restart
$ /etc/xray/xray -test -c /etc/xray/config.json
Configuration OK.
That line proves the file parses and the fields are typed. It does not prove the server agrees with its clients. A typo in dest passes and shows up later as a fallback answering with the wrong certificate, and serverNames naming something no client sends passes in the same way.
7. Free port 443, then start the service
7.1 Take nginx off the wildcard
nginx serves the LAN side on the same box, and its vhost commonly carries listen [::]:443 ssl;. An IPv6 wildcard bind also accepts IPv4 through the v4-mapped address, so it holds 443 before Xray starts. Bind the vhost to the LAN address:
listen 80;
listen [::]:80;
listen 192.0.2.1:443 ssl; # LAN only
# disabled for xray: listen [::]:443 ssl;
7.2 Reload nginx, start Xray, read the sockets
/etc/init.d/nginx reload
/etc/init.d/xray start
netstat -lnt | grep ':443 '
Expected, two listeners on two addresses:
tcp 0 0 203.0.113.10:443 0.0.0.0:* LISTEN
tcp 0 0 192.0.2.1:443 0.0.0.0:* LISTEN
The first line is Xray, the second is nginx. A line on 0.0.0.0:443 or [::]:443 means something owns the port outright, and Xray loses that fight without an error anyone will see.
7.3 Audit the vendor port-forward store
This step is vendor-specific and generalises to any firmware that keeps its own forward list. GL’s firmware stores forwards in /etc/config/port_forward, and uci show firewall cannot see it. A dormant 443 → 192.0.2.46:443 entry left in that file is applied whenever the admin page is saved or the firmware upgrades, and once live it takes 443 from Xray and the public media host down with it.
$ uci show port_forward | grep src_dport
port_forward.@redirect[0].src_dport='80'
port_forward.@redirect[1].src_dport='8089'
Neither line may name 443. Delete the entry if one does, and save the backup before editing:
cp -a /etc/config/port_forward /etc/config/port_forward.bak.$(date +%s)
7.4 Audit the live ruleset
# iptables -t nat -L zone_wan_prerouting -n -v | grep -E 'dpt:(443|80|8089)'
Run both audits: the ruleset says what is happening now, the store says what will happen after the next firmware upgrade.
A packet capture showing the WAN address sending to 192.0.2.46:443 is Xray’s own fallback dial, not a NAT rule misfiring. Read the DNAT counters in the nat table instead of reading packets; the counters say which rule, if any, matched.
8. Build the client profile
8.1 Write the client config
One inbound per capture style, so both work:
{
"log": { "loglevel": "warning" },
"inbounds": [
{ "listen": "127.0.0.1", "port": 10808, "protocol": "socks", "settings": { "udp": true } },
{ "listen": "127.0.0.1", "port": 10809, "protocol": "http", "settings": {} }
],
"outbounds": [
{
"tag": "reality-out",
"protocol": "vless",
"settings": {
"vnext": [
{
"address": "203.0.113.10",
"port": 443,
"users": [
{ "id": "7a826016-8e5e-428c-92dd-3b720420413b", "flow": "xtls-rprx-vision", "encryption": "none" }
]
}
]
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"serverName": "media.example.net",
"fingerprint": "chrome",
"publicKey": "YjaSGE6Qi3Kt_6Gby04vDMcpqMD73oAE_wUR1PK24kM",
"shortId": "9f2c1a7d4b8e6350",
"spiderX": "/"
}
}
}
]
}
8.2 Check each field
| Field | Value in this config | What it decides |
|---|---|---|
SOCKS inbound, 10808 | udp: true | A capture point for apps that speak SOCKS5. The UDP flag lets an app ask; the vision flow is TCP-only, so those datagrams have nowhere to go and the app falls back to TCP. |
HTTP inbound, 10809 | no settings | A capture point for anything that only speaks HTTP CONNECT, which includes curl -x and a browser configured by hand. |
vnext[].address, port | 203.0.113.10, 443 | The endpoint is an IP literal by design. Nothing resolves a name to reach the tunnel. |
id | the UUID | Who you are. It has to match a clients[].id, or the server splices you to the fallback. |
flow | xtls-rprx-vision | Must match clients[].flow. A different string authenticates as a stranger. |
encryption | none | The client-side statement of what the server says with decryption. |
serverName | media.example.net | The name the client claims in the ClientHello, and the name it expects the server to prove. One entry from serverNames. |
fingerprint | chrome | Which browser fingerprint the client imitates. Client-side only; nothing on the server checks it. |
publicKey | the x25519 public half | What the client uses to build its tag. Exactly one value is correct. |
shortId | 9f2c1a7d4b8e6350 | One entry from shortIds. |
spiderX | / | The path the client requests if it makes a probing request of its own. / suits a site whose root is an ordinary page. |
8.3 Write the URI form for a phone
vless://[email protected]:443?encryption=none&flow=xtls-rprx-vision&security=reality&type=tcp&sni=media.example.net&fp=chrome&pbk=YjaSGE6Qi3Kt_6Gby04vDMcpqMD73oAE_wUR1PK24kM&sid=9f2c1a7d4b8e6350&spx=%2F#home
The URI compresses the table above and adds nothing: pbk is publicKey, sid is shortId, sni is serverName, spx is spiderX.
8.4 Import, then stop editing by hand
A client that imports the URI and then has a field edited by hand is the usual source of a profile that works on the phone and not on the laptop. A QR code is a picture of that string, so it goes stale the moment the string changes, and every client carrying an older SNI fails exactly like a wrong key.
9. Check the hostname in three places
9.1 The three places
| Place | Field | Must equal |
|---|---|---|
| Server config | serverNames[] | the hostname |
| Client profile | serverName / sni | the hostname |
Certificate at dest | subject or SAN | the hostname |
The server splices under the same name, so the fallback origin has to answer for it. Nothing in the toolchain cross-checks the three.
9.2 The check
# grep -o '"dest": *"[^"]*"' /etc/xray/config.json
# grep -A1 '"serverNames"' /etc/xray/config.json
$ grep '"serverName"' ~/vpn/home-xray-client.json
Three lines of output naming the same host. Run it before every restart, because -test does not compare them.
9.3 What a mismatch does
If serverNames names a host the clients do not send, REALITY authentication fails for all of them and they are spliced to dest. If dest then serves a certificate for a different name than the public hostname points at, the public site fails at the same time, with the client-side symptom pointing at the wrong end of the connection.
10. Verify
The LAN cannot test the public path. Split-horizon overrides aimed media.example.net at 192.0.2.46 long before this tunnel existed, so a curl from a laptop inside the house reaches the media server directly and reports success either way.
10.1 Test A: the handshake, on the router
A second Xray instance in client mode on loopback:
rm -f /tmp/xc.log
/etc/xray/xray -c /etc/xray/client-prod.json >/tmp/xc.log 2>&1 &
sleep 3
curl --socks5-hostname 127.0.0.1:10809 -s https://ifconfig.me; echo
curl --socks5-hostname 127.0.0.1:10809 -o /dev/null -w '%{http_code}\n' http://192.0.2.46:32400/web/index.html
Pass condition: the first command prints 203.0.113.10. A connection that fell back never became a SOCKS session and produces no answer at all.
10.2 Kill the test client
Leaving it running holds 10809 and muddies the next test:
kill %1; rm -f /tmp/xc.log
pidof xray
One PID when the box is idle.
10.3 Test B: the fallback, from off the LAN
The only vantage point that sees what an outside client sees:
$ openssl s_client -connect 203.0.113.10:443 -servername media.example.net </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
$ curl -s -o /dev/null -w 'media %{http_code} %{time_total}s\n' https://media.example.net/identity
Expected:
subject=CN = *.example.net
issuer=C = US, O = Let's Encrypt, CN = YE1
notAfter=Jan 5 02:03:05 2027 GMT
media 200 0.21s
Pass condition: a 200 carrying the media origin’s own certificate. The connection reached 443 as a stranger, was spliced to the site behind, and got that site’s answer. Nothing on the port announced a proxy.
10.4 Acceptance
| Phase | Check | Passing looks like |
|---|---|---|
| Service came up | pidof xray; netstat -lnt | grep ':443 ' | one PID, the WAN address bound, nginx on the LAN address |
| Port not stolen | uci show port_forward | grep 443 | no output |
| Handshake works | test A, ifconfig.me | the WAN address |
| Fallback works | test B | the media origin’s own certificate and a 200 |
| Survives reboot | /etc/rc.d/S90xray exists, then reboot and re-run the first row | the same three results |
Tests A and B answer different questions. Test A exercises REALITY over loopback, which is authentication without the public path. Test B connects as a stranger on purpose. Neither dials the tunnel from an outside host as a configured client, so that last leg is checked from the first device you provision.
11. Limits, rollback and the health check
11.1 Limits
- The endpoint address stays visible. Every packet carries it, and an address block or a route change ends the tunnel. What the shared port buys is that a probe of 443 comes back looking like the site behind it.
- The UUID is the credential in full. Anyone holding the URI has a proxy into this LAN, with no second factor and no per-user limits in this config. One UUID per device, and a URI pasted into a group chat is a leaked password.
- Nothing rotates logs.
loglevel: debugwrites several KB per connection burst into/etc/xray/error.logand no rotation exists for it. Debug is for the build,warningfor afterwards. - Clocks will disagree. Routers commonly keep local time while Xray logs UTC, so a log line and a packet capture of the same second sit a timezone apart.
- A firmware upgrade can undo the port work without touching the tunnel’s config. The nginx vhost and the vendor port-forward store are both files the admin UI rewrites. Section 7 audits both, and both get re-run after every upgrade.
- One port is one point of failure. A network that intercepts TLS on 443 or blocks the address has no fallback port, and a second inbound on 8443 costs one config block and stays dark everywhere else.
11.2 Rollback
Everything is a file, so rollback is a copy:
cp -a /etc/xray/config.json.bak.1793399903 /etc/xray/config.json
/etc/xray/xray -test -c /etc/xray/config.json && /etc/init.d/xray restart
cp -a /etc/config/port_forward.bak.1793399966 /etc/config/port_forward
cp -a ~/vpn/home-xray.vless.txt.bak.1 ~/vpn/home-xray.vless.txt
The first two restore a working server. The third brings back the dormant 443 forward that section 7 removed; it stays inert until something applies it, which is the reason to restore it deliberately instead of leaving the backup as the only copy.
11.3 Health check
pidof xray; netstat -lnt | grep 203.0.113.10:443; \
iptables -t nat -L zone_wan_prerouting -n -v | grep -E 'dpt:(443|80|8089)'; \
ls /etc/rc.d/ | grep -i xray; uci show port_forward | grep src_dport
Expect one PID, one listener on the WAN address, no DNAT for 443, a start script in /etc/rc.d, and two forwards. Each failure in section 3 shows up here before it shows up in a client.
