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:

ShapeWhat it capturesUsed here
SOCKS or HTTP proxyOne application, or one application’s trafficYes, the client profile in section 8
TUN device (a full VPN)Every packet the operating system sendsNo

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: none on the server, encryption: none on 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.

  1. The client is configured with the server’s X25519 public key and a short ID.
  2. 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.
  3. 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.
  4. 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 bytesServer actionWhat the peer sees
Tag matches and the short ID is listedA VLESS sessionThe tunnel
Anything elseSpliced to destThe 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 dest has 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:

PartRuns onJob
xrayRouterListens on 443, terminates REALITY, carries VLESS, exits by the WAN address
nginxRouterServes the LAN side on the LAN address, and must not hold 443 on the WAN
dest originMedia boxA real HTTPS site with a real certificate, where strangers are spliced
ClientPhone or laptopPerforms the REALITY handshake and offers SOCKS and HTTP to applications

1.8 Why not a simpler tunnel

AlternativeWhat it needsWhy it is not used here
WireGuardA UDP port, a key pairFixed handshake, matched and blocked by inspection, and it needs a port that is open
OpenVPNA port and a certificateIts handshake is a signature a filter matches
ShadowsocksA port and a passwordNo TLS around it, so the pattern itself is what gets matched
Plain TLS inbound with your own certificateA domain pointed at the router and a certificateNeeds the name and the certificate, which is the thing being defended
VLESS with REALITY on 443A real HTTPS site the router can reachThis 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. shortIds labels 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 xver is set and the origin reads the PROXY header.
  • Three places have to agree on one hostname: serverNames, the client serverName, and the certificate at dest. 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 debug grows 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.

RequirementWhy the procedure fails without it
One public IPv4 arriving on the router, with no DNAT for 443A 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 useREALITY splices every unauthenticated connection there, and a prober compares the name in that certificate
Xray 26.x built for the router’s architecturex25519 output labels changed in 26.10, and older builds print a different middle line
An init system: procd on OpenWrt, systemd or OpenRC elsewhereSomething 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-visionThe 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:

PieceJobWhat fails without it
WAN interface addressOne public IPv4 with nothing rewriting it, so 443 arrives at the listener intactA DNAT in front sends 443 to the media box and Xray never sees a packet
xray under procdBinds 443, supervises, restarts after a crash, starts after the networkWith START below the network it binds nothing and exits at boot
reality-in inboundDeclares the protocol, the credentials, the fallback and sniffing for that one socketNothing listens on 443
REALITY layerChecks the authentication tag in the ClientHello and picks session or spliceThe tunnel needs a certificate for a name pointed at it, which means a DNS record and an SNI to defend
VLESSCarries the destination and the user identity inside the TLS sessionThere is no way to say who is connecting
xtls-rprx-visionSplices the inner TLS stream to the outer connectionExtra copies of every packet through the proxy
dest fallbackReceives anything that fails authentication and serves its own certificateA prober gets a reset, and the port looks like a proxy
shortIdsLabels which server entry a client claims, so one key pair serves several profilesRevoking one profile means reissuing all of them
nginx on the LAN addressKeeps the LAN-side vhost off the WAN addressA wildcard IPv6 bind takes the port first and Xray exits with a bind error
Vendor port-forward storeRe-applies DNAT rules on firmware save or upgradeA dormant 443 forward returns and takes the port from Xray
Client REALITY outboundPerforms the handshake and cannot proceed without the server’s proofNothing 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
}
SettingValueEffect
USE_PROCD1Hands supervision to procd, which is what gives START and respawn meaning
START90Runs after the network is up; a lower value binds nothing at boot
respawn3600 5 5Tolerates five crashes in an hour before procd gives up
stdout, stderr1Sends 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

ValueServer fieldClient field
Private keyrealitySettings.privateKeynever
Public keynot usedpublicKey / pbk
UUIDclients[].idvnext[].users[].id
Short IDshortIds[]shortId / sid
Server nameserverNames[]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

FieldValue in this configWhat it decides
listen, port203.0.113.10, 443The 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).
protocolvlessThe transport inside the TLS session. It carries addressing and identity, and does no encryption of its own.
clients[].ida UUIDWho is allowed in. Not derived from the key pair, so losing it means a new UUID and a new URI for that device.
clients[].flowxtls-rprx-visionFlow 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[].emaila labelA name in logs and stats. Nothing authenticates against it.
decryptionnoneSays that VLESS declines to encrypt, because TLS outside it does.
dest192.0.2.46:443Where anything that fails authentication goes. A real service with a real certificate, not a decoy page.
xver0Do 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.
privateKeyx25519 private halfHalf the credential, mode 0600, never in a client profile.
shortIdsone 16-hex valueThe other half. Must include whatever the client sends, and several may be listed.
showfalseKeeps the accepted server name out of the verbose log.
sniffinghttp, tls, quicRecovers the destination name for routing rules. Without it, rules keyed on a domain never fire.
routing.rules192.0.2.0/24 → directAnswers anything addressed on the LAN on the LAN interface instead of treating it as tunnel traffic.
outboundsfreedom, blackholeTwo exits: one delivers, one drops. The direct tag is what the routing rule and the default path point at.
log.loglevelwarningDebug 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

FieldValue in this configWhat it decides
SOCKS inbound, 10808udp: trueA 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, 10809no settingsA capture point for anything that only speaks HTTP CONNECT, which includes curl -x and a browser configured by hand.
vnext[].address, port203.0.113.10, 443The endpoint is an IP literal by design. Nothing resolves a name to reach the tunnel.
idthe UUIDWho you are. It has to match a clients[].id, or the server splices you to the fallback.
flowxtls-rprx-visionMust match clients[].flow. A different string authenticates as a stranger.
encryptionnoneThe client-side statement of what the server says with decryption.
serverNamemedia.example.netThe name the client claims in the ClientHello, and the name it expects the server to prove. One entry from serverNames.
fingerprintchromeWhich browser fingerprint the client imitates. Client-side only; nothing on the server checks it.
publicKeythe x25519 public halfWhat the client uses to build its tag. Exactly one value is correct.
shortId9f2c1a7d4b8e6350One 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

PlaceFieldMust equal
Server configserverNames[]the hostname
Client profileserverName / snithe hostname
Certificate at destsubject or SANthe 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

PhaseCheckPassing looks like
Service came uppidof xray; netstat -lnt | grep ':443 'one PID, the WAN address bound, nginx on the LAN address
Port not stolenuci show port_forward | grep 443no output
Handshake workstest A, ifconfig.methe WAN address
Fallback workstest Bthe media origin’s own certificate and a 200
Survives reboot/etc/rc.d/S90xray exists, then reboot and re-run the first rowthe 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: debug writes several KB per connection burst into /etc/xray/error.log and no rotation exists for it. Debug is for the build, warning for 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.