Your own proxy against DPI: Xray, VLESS and REALITY
These days it sometimes happens that someone really wants to keep you from reaching some resource. Who exactly, and what the resource is, doesn't matter for this article: the mechanics are the same everywhere.
At first the blocking is simple: the site's address goes on a blacklist, and the DNS response gets spoofed. Any VPN or proxy gets you around that: the traffic goes to someone else's server outside the blocking, and that server visits the site for you. At the next step that someone deploys DPI, and the usual tools stop helping: the VPN won't connect, the proxy drops a second after it starts. DPI doesn't need to know what's inside the tunnel: it recognizes the tunnel itself by what the traffic looks like and cuts it, no matter how strongly the tunnel is encrypted.
What DPI is and what it looks at, we'll go through in order below. The article itself is about a stack built to counter it: Xray with the VLESS protocol, the XTLS Vision flow and REALITY camouflage. From the outside such a connection looks like ordinary HTTPS to a big foreign site, and even if an inspector connects to the server themselves, they'll see that site with its real certificate.
In the article: what DPI sees and how it blocks, why it recognizes ordinary VPNs and proxies, how VLESS, Vision and REALITY work byte by byte, how to set up a server with IPv4 and IPv6, how to add and revoke users, and how to set up the v2rayN client on Windows, in system proxy mode and in TUN mode, with two routing options.
The article builds on two parts of the series "Networking for beginners": the twentieth, "VPN and proxies", which covers what the ISP sees of all this, and the twenty-third, on TLS. If you haven't read it, at least look at the section on the TLS 1.3 handshake.
The addresses in the article are documentation addresses: the server is 203.0.113.10 and 2001:db8::10, the camouflage site is www.example.com. Keys are placeholders in angle brackets.
What DPI sees
DPI, Deep Packet Inspection, is ISP equipment that looks not only at the IP and TCP headers (who, to whom, which port) but also inside the data: which protocol, which site, does it look like something forbidden. It sits inline or on a traffic mirror and decides whether to let a connection through, throttle it or cut it (usually with a forged TCP RST, a "reset the connection" packet that DPI sends on behalf of one of the parties).
What it can rely on:
- Addresses. A list of forbidden IPs and subnets. The crudest and the most reliable: if your server's address is on the list, no camouflage will help.
- DNS. An ordinary DNS query (part twelve) goes over UDP to port 53 in clear text: the site name is visible, and the response can be spoofed.
- SNI. In the first TLS message, the ClientHello, the client writes the site name in clear text in the
server_nameextension (Server Name Indication): the server needs to know which certificate to present. DPI reads it and blocks by name. - Client fingerprint. The ClientHello of Chrome, Firefox, curl and a homegrown program all differ: the order of ciphers, the set and order of extensions, the supported groups. A fingerprint hash is computed from these fields (the best-known methods are JA3 and JA4), and an unusual fingerprint is suspicious in itself.
- Protocol signatures. Every VPN protocol has a recognizable handshake: fixed bytes, fixed sizes of the first packets.
- Statistics. Packet sizes and intervals, connection volume and duration. For example, TLS inside TLS produces a characteristic size pattern; more on that below.
- Active probing. If a server looks suspicious, DPI connects to it itself and sees what comes back: a real web server, a proxy or silence. That's how servers are caught that, on a "wrong" request, don't behave like an ordinary site.
A reminder of what's visible in TLS 1.3. Only the ClientHello and ServerHello are in the clear: the version, random values, ciphers, extensions, SNI, ALPN (the list of application-layer protocols, e.g. h2 for HTTP/2 and http/1.1) and key_share, the Diffie-Hellman public keys. Everything after the ServerHello, including the server certificate, is already encrypted. This matters: in TLS 1.3 an observer doesn't see the certificate, and REALITY takes advantage of that.
Why ordinary VPNs won't do
- IPsec with IKEv2 (part nineteen): IKE runs over UDP to ports 500 and 4500, and the first IKE_SA_INIT message starts with the initiator's eight-byte SPI, the responder's zero SPI and version number 0x20: it's recognizable from the first packet. The data goes in ESP, a separate IP protocol 50, or in UDP 4500.
- WireGuard (how to set it up): UDP, the first message is type 1 and exactly 148 bytes, the response is type 2 and 92 bytes. The encryption is strong, but the shape of the packets is constant.
- OpenVPN: its own protocol over UDP or TCP, the first packet starts with the
P_CONTROL_HARD_RESET_CLIENTopcode. Even on TCP 443 it's preceded by a two-byte length, which looks nothing like TLS. - SOCKS5 is a proxy protocol, RFC 1928: the client starts with the bytes
05 01 00(version 5, one authentication method, "no password"), followed by the destination address in clear text. There's no encryption at all. - Shadowsocks is a proxy whose stream is encrypted from the first byte and looks like random noise. That works against signatures, but "a completely random stream without a single recognizable byte" is a tell in itself: normal traffic doesn't look like that. And a server that stays silent on a garbage request stands out nicely under active probing.
The conclusion suggests itself: you have to hide not in noise but in the most ordinary internet traffic, in HTTPS to a big site. That's what the stack in this article does.
What it's made of
Xray is an open-source proxy program, a fork of the V2Ray project. It's a single core that works as both server and client: it accepts connections over various protocols (inbounds), decides by routing rules where to send them, and sends them out through outbounds: directly to the internet, to another proxy or into nowhere. The protocols and camouflages described below are Xray modules.
VLESS
VLESS is a proxy protocol with no encryption of its own: it only tells the server who you are and where to connect, and the transport underneath it (REALITY in our case) protects it. The client's request byte by byte:
| Field | Bytes | What |
|---|---|---|
| Version | 1 | 0 |
| UUID | 16 | user ID: this is the "password" |
| Addons length | 1 | M |
| Addons | M | a Protobuf structure; currently it holds only flow, e.g. xtls-rprx-vision |
| Command | 1 | 1: TCP, 2: UDP, 3: multiplexing (Mux) |
| Port | 2 | destination port |
| Address type | 1 | 1: IPv4, 2: domain name, 3: IPv6 |
| Address | 4, 1+N or 16 | destination address |
The data follows right after the header. The server's response is even simpler: a version byte, an addons length byte, the addons, and then the data from the destination. Protobuf here is Google's binary serialization format: fields with numbers and lengths, more compact than JSON.
A UUID is a 128-bit random number written as xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. The server keeps a list of UUIDs, one per user; an unknown UUID means the connection is closed. Hence simple access control: to add a user, add a UUID; to revoke one, remove it.
XTLS Vision and the TLS-in-TLS problem
Almost everything you open through the proxy will itself be HTTPS. That gives you TLS inside TLS: the inner handshake (the browser's ClientHello to the real site, the ServerHello, the certificate) travels inside the outer encrypted stream. DPI doesn't see the contents, but it does see the sizes: the first records of the outer connection mirror the sizes of the inner handshake (a ClientHello of about 500 bytes, then a few kilobytes of certificates), and this pattern doesn't look like ordinary HTTPS, where requests follow right after the handshake. On top of that, the data is encrypted twice, which costs CPU.
XTLS Vision (the value flow: xtls-rprx-vision) does two things. The first packets of the connection, while the inner handshake is going on, it pads to random sizes, blurring the pattern. And once the inner TLS 1.3 handshake is done, Vision stops re-encrypting: the inner TLS records, already encrypted with the keys of the browser and the site, travel in the outer connection as is. The observer sees a single stream of TLS records, like an ordinary site, and the server doesn't spend time on a second layer of encryption.
REALITY
REALITY is a transport camouflage that replaces ordinary TLS with your own certificate. With ordinary TLS, the proxy would need a domain, a certificate issued for it and a decoy site kept on the server; anyone who connected to the server would see that little-known domain. REALITY instead impersonates someone else's big site (the camouflage site), and does it in such a way that probing by connecting yields nothing.
First, two cryptographic building blocks:
- X25519 is Diffie-Hellman key exchange on the elliptic curve Curve25519. Each side has a pair: a private key (32 random bytes) and a public key (the result of multiplying a curve point by the private key). Each side takes its own private key and the other's public key and gets the same shared number, while an observer who saw only the public keys can't get it. TLS 1.3 uses this same exchange to agree on keys in
key_share. - HKDF and AES-GCM. HKDF is a function that derives keys of the required length from a shared secret. AES-GCM is a cipher with integrity checking: it encrypts the data and adds a tag by which the receiver verifies that nothing was tampered with; you can additionally "bind" unencrypted data (AAD) to the tag without encrypting it.
A REALITY server has a permanent X25519 pair: the private key sits in its config, and the public key is handed out to clients. The server also has a list of shortIds, short (up to 8 bytes) identifiers, and the name of the camouflage site. Here's how a connection goes:
- The client builds a ClientHello indistinguishable from a browser's. For this Xray uses uTLS, a library that reproduces the ClientHello of a specific browser byte for byte (
fingerprint: chrome), so the JA3/JA4 fingerprint matches real Chrome. The SNI carries the camouflage site's name,www.example.com; ALPN carriesh2andhttp/1.1, like a browser;key_sharecarries an ordinary one-time X25519 public key. - The client hides a marker in
session_id. In TLS 1.3 this 32-byte ClientHello field is kept for compatibility and filled with random bytes, so an observer expects nothing else there. The client computes the X25519 shared secret between its one-time private key and the server's permanent public key, derives a key from it with HKDF, and encrypts a few bytes with AES-GCM: the Xray version, the current time and the shortId; the whole ClientHello serves as the AAD. It puts the result intosession_id. From the outside it's still the same 32 random bytes. - The server checks. It takes the client's one-time public key from
key_shareand its own permanent private key, gets the same shared secret and tries to decryptsession_id. If decryption succeeds, the shortId is in the list, the time is within bounds and the SNI is one of the allowed names, the client is one of ours. - A stranger gets the real site. If the check fails (it's a scanner, a DPI probe, a browser that wandered onto the address), the server doesn't answer itself: it opens a connection to
www.example.com:443and from then on simply relays bytes in both directions. The inspector goes through a full handshake with the real site, sees its real certificate from a public certificate authority and real pages. There's no way to tell such a server from a reverse proxy in front of the site by connecting to it. - One of ours gets our own TLS. If the check passes, the server completes the TLS 1.3 handshake itself and sends a "temporary" certificate whose signature is computed from the shared secret (HMAC, a hash-based message authentication code with a key). The client verifies this signature: only a server with the right private key could have produced it, so the client will notice a man in the middle. And the observer doesn't see this certificate at all: in TLS 1.3 the certificate is already encrypted.
- After that, a VLESS request with the UUID runs inside this TLS, followed by the data with Vision.
Hence the requirements for the camouflage site. It must support TLS 1.3 (otherwise the certificate would be sent in the clear and give the substitution away) and X25519 in key_share. HTTP/2 is desirable, because the client, like a browser, offers h2. And it must be reachable from your network: if the camouflage site is blocked, your proxy is blocked along with it.
There are two levels of protection, and that matters for access control. The REALITY keys and shortIds are shared by all users of the server: they get you through the camouflage. But who exactly uses the proxy is decided by the UUID in VLESS: someone who knows the keys but not a valid UUID won't get past the handshake.
Choosing a camouflage site
Criteria:
- TLS 1.3, X25519, HTTP/2;
- a 200 response on the home page with no redirect to another name;
- a fast response from your server: the server's handshake with the camouflage site is part of every "stranger" connection, and a long delay is noticeable;
- reachable directly from your network;
- not the most popular choice:
www.microsoft.comandwww.apple.comare the ones most often used for REALITY, and it's exactly their combination with hosting provider addresses that DPI would logically check first; - ideally a site from the same region as the server. The perfect case is a site physically hosted in the same network as your server: then there isn't even the mismatch of "a big site's SNI, but the IP belongs to a VPS host". Such a site is hard to find, and that remains the main weakness of the scheme.
You can check a candidate right from the server; Xray can do a TLS ping for this:
xray tls ping www.example.com
curl -s -o /dev/null --http2 -w "%{http_version} %{http_code} tls=%{time_appconnect}s redirect=%{redirect_url}\n" https://www.example.com/
xray tls ping performs a handshake without SNI and with SNI and shows the TLS version, the key exchange (big sites now answer with the hybrid X25519MLKEM768, X25519 plus post-quantum ML-KEM, which is fine), the certificate chain and the names the certificate was issued for. Discard candidates with TLS 1.2, with a redirect to another domain, or with a certificate for the wrong name. Do the last check at home, without the proxy: the site must open.
The server
You need a virtual machine outside the blocking zone with public IPv4 and IPv6. The smallest resources are enough: 1 vCPU and 1 GB of memory comfortably handle one or two users. Below it's Ubuntu and SSH key login as root.
Updates and firewall
apt update && apt full-upgrade -y
[ -f /var/run/reboot-required ] && reboot
The firewall is ufw, a front end for netfilter (the packet filtering subsystem in the Linux kernel) that turns simple rules like "allow 443/tcp" into nftables rules, the current format of netfilter tables. We open only SSH and 443:
grep ^IPV6= /etc/default/ufw # must be IPV6=yes, so that the rules apply to IPv6 too
ufw allow 22/tcp
ufw allow 443/tcp
ufw enable
ufw status verbose
There's no need to open UDP, even if games will go through the proxy: the client's UDP travels inside the same TCP connection on 443 (more on that below).
Installing Xray
The project has an official install script. It's worth downloading it and looking at it first: it fetches the latest stable Xray-core release and the geoip.dat and geosite.dat databases from GitHub, installs the binary to /usr/local/bin/xray and creates a systemd service running as nobody with a single privilege: binding ports below 1024.
mkdir -p /root/xray-install && cd /root/xray-install
curl -fsSL -o install-release.sh https://github.com/XTLS/Xray-install/raw/main/install-release.sh
less install-release.sh
bash install-release.sh install
xray version
Update later with the same install command; the databases alone with install-geodata. Xray doesn't update itself.
Keys
xray x25519 # PrivateKey goes into the server config, Password (PublicKey) to clients
openssl rand -hex 8 # shortId: 8 random bytes in hex
xray uuid # the first user's UUID
In newer Xray versions the public key is labeled Password (PublicKey), in older ones Public key. Don't show the private key to anyone: with it, anyone can impersonate your server.
The config
/usr/local/etc/xray/config.json:
{
"log": { "loglevel": "warning", "access": "none" },
"inbounds": [
{
"tag": "vless-reality",
"listen": "::",
"port": 443,
"protocol": "vless",
"settings": {
"clients": [
{ "id": "<UUID alice>", "flow": "xtls-rprx-vision", "email": "alice" }
],
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"target": "www.example.com:443",
"serverNames": [ "www.example.com" ],
"privateKey": "<X25519 private key>",
"shortIds": [ "<shortId>" ]
}
},
"sniffing": { "enabled": true, "destOverride": [ "http", "tls", "quic" ], "routeOnly": true }
}
],
"outbounds": [
{ "tag": "direct", "protocol": "freedom", "settings": { "domainStrategy": "UseIPv4v6" } },
{ "tag": "block", "protocol": "blackhole" }
],
"routing": {
"domainStrategy": "IPIfNonMatch",
"rules": [
{ "ip": [ "geoip:private" ], "outboundTag": "block" },
{ "protocol": [ "bittorrent" ], "outboundTag": "block" }
]
}
}
Let's go through the fields.
log.warningmeans only warnings and errors.access: noneturns off the access log: otherwise Xray logs every connection with its destination address, and the server accumulates the browsing history of all users.listen: "::"means listening on all IPv6 addresses, and on Linux such a socket accepts IPv4 too by default (addresses like::ffff:203.0.113.10). One inbound serves both families.clientsare the users: UUID, the Vision flow and theemaillabel. The label is for humans and scripts; Xray only shows it in logs.decryption: none: VLESS has no encryption of its own; REALITY provides it.realitySettings.targetis where to relay strangers (in older versions the field was calleddest).serverNameslists which SNIs count as ours: the client must send one of these names.privateKeyandshortIdsare what we discussed above; you can issue several shortIds, but access is still decided by the UUID.sniffing: Xray peeks at the first bytes of the client's connection and extracts the site name (from the HTTPHostheader, from the SNI of the inner TLS, or from QUIC, the HTTP/3 transport over UDP).routeOnlymeans the name is used only for routing rules, while the connection goes to the address the client sent.freedomis the "straight to the internet" outbound.UseIPv4v6: if the client sent a name, resolve it to A and AAAA and prefer IPv4, but if the site has only IPv6, go over IPv6. IPv6 destination addresses go out over IPv6.blackholeis the "into nowhere" outbound. The rules sendgeoip:privatethere (private IPv4 and IPv6 networks:10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,fc00::/7,fe80::/10and others), so that your proxy can't be used to reach the hosting provider's internal network or your own networks behind the server, and BitTorrent, because that's what gets complaints to the server's provider fastest.
Checking and starting:
chown root:nogroup /usr/local/etc/xray/config.json
chmod 640 /usr/local/etc/xray/config.json # the config holds the private key; nobody reads it via the group
xray run -test -config /usr/local/etc/xray/config.json
systemctl enable --now xray
systemctl status xray --no-pager
ss -tlnp | grep :443 # *:443 users:(("xray",...))
One subtlety: Xray determines the config format by the file extension. It won't be able to read a temporary file named config.json.tmp, so name such files something.json.
Checking without a key
Let's connect to the server as an ordinary browser would, but to the camouflage site's name, the way an inspector would:
curl -sv -o /dev/null --resolve www.example.com:443:203.0.113.10 https://www.example.com/ 2>&1 | grep -E "subject|issuer|SSL connection|HTTP/"
curl -sv -o /dev/null --resolve "www.example.com:443:[2001:db8::10]" https://www.example.com/ 2>&1 | grep -E "subject|issuer|SSL connection|HTTP/"
--resolve makes curl connect to our address but send the camouflage name in the SNI and the Host header. The response should show the real www.example.com certificate from a public certificate authority, TLS 1.3 and code 200: REALITY didn't recognize a key and relayed us to the site.
Users
Each person gets their own UUID. Then one person's access can be revoked without touching the others: remove their UUID, and their client won't get past the handshake, while everyone else's links stay the same. The REALITY keys only need changing if the server's private key leaks, in which case new links have to go out to everyone.
By hand, this means editing the clients array and restarting. A small script is more convenient: it adds and removes users, saves a copy of the config before every edit, tests the new config and, if the service doesn't come up, restores the copy. Put it in /usr/local/sbin/xray-user and make it executable (chmod 755); it needs jq, a JSON utility.
#!/usr/bin/env bash
# xray-user list | add <name> | del <name> | link <name>
set -euo pipefail
CFG=/usr/local/etc/xray/config.json
TAG=vless-reality
ADDR4=203.0.113.10
ADDR6=2001:db8::10
FP=chrome
die() { echo "xray-user: $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "run as root"
inbound='.inbounds[] | select(.tag == $tag)'
check_name() { [[ "$1" =~ ^[A-Za-z0-9._@-]{1,64}$ ]] || die "bad name"; }
apply() {
local new=$1 backup
xray run -test -config "$new" >/dev/null || { rm -f "$new"; die "new config does not pass xray run -test"; }
backup="$CFG.$(date +%Y%m%d-%H%M%S)"
cp -p "$CFG" "$backup"
chown root:nogroup "$new"; chmod 640 "$new"; mv "$new" "$CFG"
systemctl restart xray; sleep 1
systemctl is-active --quiet xray || { cp -p "$backup" "$CFG"; systemctl restart xray; die "xray did not start, restored $backup"; }
}
links() {
local id pk pub sni sid q
id=$(jq -r --arg tag "$TAG" --arg n "$1" "$inbound | .settings.clients[] | select(.email == \$n) | .id" "$CFG")
[ -n "$id" ] || die "no user $1"
pk=$(jq -r --arg tag "$TAG" "$inbound | .streamSettings.realitySettings.privateKey" "$CFG")
sni=$(jq -r --arg tag "$TAG" "$inbound | .streamSettings.realitySettings.serverNames[0]" "$CFG")
sid=$(jq -r --arg tag "$TAG" "$inbound | .streamSettings.realitySettings.shortIds[0]" "$CFG")
pub=$(xray x25519 -i "$pk" | sed -n 's/^Password (PublicKey): //p')
q="encryption=none&flow=xtls-rprx-vision&security=reality&sni=$sni&fp=$FP&pbk=$pub&sid=$sid&type=tcp&headerType=none"
echo "vless://$id@$ADDR4:443?$q#$1-ipv4"
echo "vless://$id@[$ADDR6]:443?$q#$1-ipv6"
}
case "${1:-}" in
list) jq -r --arg tag "$TAG" "$inbound | .settings.clients[] | .email" "$CFG" ;;
add)
[ $# -eq 2 ] || die "usage: xray-user add <name>"; check_name "$2"
jq -e --arg tag "$TAG" --arg n "$2" "$inbound | .settings.clients[] | select(.email == \$n)" "$CFG" >/dev/null && die "user $2 exists"
new=$(mktemp --suffix=.json "${CFG%.json}.new.XXXXXX")
jq --arg tag "$TAG" --arg n "$2" --arg id "$(xray uuid)" \
'(.inbounds[] | select(.tag == $tag) | .settings.clients) += [{id: $id, flow: "xtls-rprx-vision", email: $n}]' "$CFG" > "$new"
apply "$new"; links "$2" ;;
del)
[ $# -eq 2 ] || die "usage: xray-user del <name>"
jq -e --arg tag "$TAG" --arg n "$2" "$inbound | .settings.clients[] | select(.email == \$n)" "$CFG" >/dev/null || die "no user $2"
[ "$(jq --arg tag "$TAG" "$inbound | .settings.clients | length" "$CFG")" -gt 1 ] || die "$2 is the last user"
new=$(mktemp --suffix=.json "${CFG%.json}.new.XXXXXX")
jq --arg tag "$TAG" --arg n "$2" \
'(.inbounds[] | select(.tag == $tag) | .settings.clients) |= map(select(.email != $n))' "$CFG" > "$new"
apply "$new" ;;
link) [ $# -eq 2 ] || die "usage: xray-user link <name>"; links "$2" ;;
*) die "usage: xray-user list | add <name> | del <name> | link <name>" ;;
esac
Here's how it's used:
xray-user add bob # creates a UUID and prints two links: for IPv4 and for IPv6
xray-user list # alice, bob
xray-user link bob # show the links again
xray-user del bob # revoke bob's access; nothing changes for alice
xray x25519 -i <private key> computes the public key from the private one, so the script doesn't have to store it separately. The links are secret: they contain the user's UUID. Pass them on through a channel you trust.
Restarting Xray on an edit drops all users' current connections for a second; the clients reconnect on their own. You could avoid restarts through Xray's control API, but for a few users that's unnecessary complexity.
The vless:// link
The client doesn't need to type in the parameters by hand: everything fits into one link.
vless://<UUID>@203.0.113.10:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=<public key>&sid=<shortId>&type=tcp&headerType=none#alice-ipv4
| Parameter | What |
|---|---|
<UUID>@address:port |
the user and the server; an IPv6 address goes in square brackets: [2001:db8::10]:443 |
encryption=none |
VLESS without its own encryption |
flow=xtls-rprx-vision |
the Vision flow |
security=reality |
the REALITY transport |
sni |
the camouflage site's name |
fp=chrome |
the ClientHello fingerprint uTLS imitates |
pbk |
the server's X25519 public key |
sid |
shortId |
type=tcp&headerType=none |
plain TCP with no extra wrapping |
#… |
the profile name in the client |
The link carries the server's address, not its name: that way connecting to the proxy doesn't depend on DNS, and a lookup of your server's name doesn't show up at the ISP. There are two links, for IPv4 and for IPv6: if one family has problems, you can switch to the other.
The client: v2rayN on Windows
v2rayN is a client for Windows (also available for Linux and macOS) with a GUI and built-in cores: Xray for the protocols and sing-box for TUN mode. sing-box is another open-source proxy core; in v2rayN it handles the virtual network adapter.
Installation
On the github.com/2dust/v2rayN releases page there are two archives for Windows x64: v2rayN-windows-64.zip (WPF UI) and v2rayN-windows-64-desktop.zip (Avalonia UI). Both are portable, with the cores already inside; take the first one. GitHub shows the SHA256 next to each file; compare it:
Get-FileHash "$env:USERPROFILE\Downloads\v2rayN-windows-64.zip" -Algorithm SHA256
Expand-Archive "$env:USERPROFILE\Downloads\v2rayN-windows-64.zip" "$env:LOCALAPPDATA\v2rayN"
The portable version keeps its settings in its own folder; there's no installer and no service. You run v2rayN.exe; for TUN mode, with administrator rights (right-click → "Run as administrator"), because TUN installs a network adapter driver.
Importing the server
The menu item names below are from the English UI of v2rayN 7.x; the language is switched in the settings.
Copy the vless://… link to the clipboard and press Ctrl+V in the v2rayN main window: that's the Import Share Links from clipboard item in the server list's context menu. A profile alice-ipv4 appears; the second link, for IPv6, can be imported the same way. Right-click the profile → Set as active to make it active; Test real delay makes v2rayN open a test page through the proxy and show the response time. If you get an error instead of a time, check that the key, shortId and SNI in the link match the server.
Two modes: system proxy and TUN
System proxy. v2rayN brings up a local proxy on 127.0.0.1 (HTTP and SOCKS on one port, 10808 by default) and writes it into the Windows proxy settings, the ones under "Settings → Network & internet → Proxy". Programs that read this setting go through the tunnel: browsers, most .NET and Electron applications, the Microsoft Store. Bypassing it are console programs (git, curl, Node.js, Python), clients with their own network settings, everything that runs over UDP, as well as WSL, Docker and virtual machines. Many console programs take the proxy from environment variables:
$env:HTTP_PROXY = 'http://127.0.0.1:10808'
$env:HTTPS_PROXY = 'http://127.0.0.1:10808'
The upside of this mode is that it breaks nothing: services that don't use the proxy keep working as before.
TUN. Through sing-box, v2rayN creates a virtual network adapter (TUN is an interface that hands IP packets to a program instead of a wire) and sets up routes so that all of the machine's IP traffic (TCP and UDP, IPv4 and IPv6) goes into that adapter. sing-box assembles connections from the packets and sends them according to the routing rules: directly through the real adapter or into the proxy. This really is "everything", whatever the program knows or doesn't, including games over UDP. The price:
- administrator rights are required;
- v2rayN intercepts DNS queries, and all names are resolved by its rules: internal names from a corporate or home DNS server will stop resolving unless you exclude them;
- a mistake in the rules breaks access to everything at once, not to a single site;
- other VPN clients on the same machine may conflict with the TUN routes.
TUN settings are in Settings → Option Setting, the Tun Mode settings tab:
- Auto Route: on; sing-box sets up the routes into the adapter itself;
- Strict Route: on; sing-box doesn't let traffic, and DNS above all, bypass the adapter. Windows likes to ask the same DNS question of every resolver on every adapter at once; with Strict Route, queries to the DNS server of the router or the corporate network simply don't get through, and answers come only from the tunnel;
- Stack and MTU: defaults;
- Enable IPv6 Address: must be turned on: otherwise the TUN adapter has no IPv6, IPv6 traffic goes around the tunnel directly, and sites reachable over IPv6 see your real address. This is the classic "IPv6 leak".
TUN is turned on with the Enable Tun toggle at the bottom of the main window or in the tray icon menu, and turned off the same way: sing-box removes the adapter and the routes, and everything goes back to how it was. If v2rayN is running without administrator rights, TUN won't turn on: close it from the tray and start it again as administrator (or use Restart as Administrator). The system gets an adapter singbox_tun, and its DNS settings show the addresses 172.18.0.2 and fc00::172:18:0:2: that's where the sing-box DNS answers.
Routing: two options
v2rayN rules are a list of conditions "these addresses or names → this outbound", checked top to bottom, and the first match decides. The outbounds are proxy (through the server), direct and block. Conditions can be written as addresses and subnets, domains, and ready-made lists from the databases: geoip:… for country subnets and private ranges from geoip.dat, geosite:… for domain sets by category from geosite.dat. v2rayN has ready-made rule sets, but the built-in "Global" sends more traffic directly than its name suggests: all of geoip:private (besides the three private ranges, also 100.64.0.0/10, 169.254.0.0/16 and others) and the geosite:private domains. It's safer to create your own set:
- Put the rules (the JSON below) on the clipboard.
- Settings → Routing Setting opens the "Pre-defined Rule Set List".
- In the window's menu, Rule → Add opens the "Rule Settings" window for a new set; put a name in the Remarks field, e.g.
All via proxy except LAN. - In that window's menu, Rule List → Import Rules From Clipboard, then Confirm.
- In the list of sets, select the new one and click Set as active rule, then Confirm. It should now be selected in the Routing list at the bottom of the main window.
A JSON rule in v2rayN format is an object with an outboundTag and conditions: ip, domain, port, network (tcp, udp). All conditions of a rule must hold at once.
Option 1: everything through the proxy except local traffic. Only private networks go directly (the home network, corporate networks behind a VPN, local virtual machines), plus link-local fe80::/10 for IPv6: these addresses aren't routed beyond the segment, and IPv6 itself relies on them on the adapter (Neighbor Discovery from part fourteen). Multicast ff00::/8 and 224.0.0.0/3 v2rayN drops by itself in TUN mode, with a separate built-in rule. Everything else goes to the proxy:
[
{ "remarks": "Block QUIC (UDP 443)", "outboundTag": "block", "port": "443", "network": "udp" },
{ "remarks": "Private ranges direct", "outboundTag": "direct",
"ip": [ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fe80::/10" ] },
{ "remarks": "Everything else via proxy", "outboundTag": "proxy", "port": "0-65535" }
]
The first rule blocks QUIC, HTTP/3 over UDP to port 443: the browser immediately switches to HTTPS over TCP, which goes through Vision with its camouflage (the built-in v2rayN sets do the same). Games aren't affected; they use other ports. The last rule, on port 0-65535, matches anything: it's "everything else". If the local network has internal names (server.corp.example.com) and DNS goes through the proxy, they won't resolve: in this option internal resources are reachable only by IP. If that's a problem, add "domain": [ "domain:corp.example.com" ] to the first rule and point those names at the local DNS in the DNS settings.
Option 2: only what's needed. Selected sites and services go through the proxy, everything else directly, with your ISP's speed and latency:
[
{ "remarks": "Block QUIC (UDP 443)", "outboundTag": "block", "port": "443", "network": "udp" },
{ "remarks": "Private ranges direct", "outboundTag": "direct", "ip": [ "geoip:private" ] },
{ "remarks": "Selected sites via proxy", "outboundTag": "proxy",
"domain": [ "domain:example.org", "domain:example.net", "geosite:google" ] },
{ "remarks": "Everything else direct", "outboundTag": "direct", "port": "0-65535" }
]
domain:example.org matches the domain itself and all its subdomains. Instead of individual domains you can use geosite categories, for example the domain sets of a particular service; the list of categories is in the repository of the databases your client uses. The downside of this option is that the list has to be maintained: a newly blocked site won't open until you add it.
DNS
In option 1 and in TUN mode, DNS must also go through the proxy; otherwise the ISP sees the names of all the sites in your DNS queries, even though the connections themselves are hidden. The settings are in Settings → DNS Settings, the Basic DNS Settings tab:
- Remote DNS is the resolver v2rayN reaches through the proxy. DoH is better, DNS over HTTPS (DNS queries inside ordinary HTTPS to the resolver), and by address rather than by name:
https://1.1.1.1/dns-query. Then nothing has to be resolved to find the resolver itself. - Domestic DNS is for names that go directly. In option 1 there are none, but the field is still worth fixing: by default it and Bootstrap DNS are set to the Chinese public resolver
119.29.29.29; v2rayN has Chinese roots. Set1.1.1.1or your ISP's resolver. - Bootstrap DNS is what v2rayN uses to resolve the names of the DNS servers themselves, if they're given by name. With an address in Remote DNS it's hardly needed, but it's better to replace
119.29.29.29there too. - FakeIP: leave it off. With it, applications get fake addresses from
198.18.0.0/15instead of the real ones, which is needless complexity.
Since the link has the server's address rather than its name, connecting to the proxy itself needs no DNS at all; v2rayN excludes the server's address from the tunnel by itself, or else the connection to the proxy would try to go through the proxy.
You can check who answers your queries with special names that return the address of the resolver that asked:
Resolve-DnsName whoami.akamai.net -Type A # the address of the resolver that reached Akamai
Resolve-DnsName o-o.myaddr.l.google.com -Type TXT # the same at Google
Both answers should show the addresses of the resolver from Remote DNS (for 1.1.1.1, Cloudflare addresses), not your ISP's or your router's.
Route Only and IPv6
Here's a trap I fell into myself. In TUN mode sing-box hands connections to the built-in Xray, which has sniffing on by default: it extracts the site name from the connection and replaces the destination address with it. Instead of "connect to the site's IPv6 address", what goes to the server is "connect to example.com", and the server resolves the name itself and picks IPv4. Nothing leaks, but curl -6 will show the server's IPv4 address, and applications that deliberately chose IPv6 will go out over IPv4.
The fix is the Route Only checkbox in Settings → Option Setting → Core: basic settings (leave Turn on Sniffing on). With it, the name is used only by routing rules, and the connection goes to the address the application chose: the same routeOnly as in the server config.
UDP and games
VLESS carries the client's UDP inside the same TCP connection: each UDP packet is wrapped with a length and an address (the XUDP format, an extension of Xray's multiplexer for UDP) and travels in the stream. On the server the packet is unwrapped and goes out to the internet as real UDP. That's why the server doesn't need open UDP ports, and DPI doesn't see a separate UDP flow.
The downside is the TCP property from part ten: if one segment is lost, all the data behind it waits for the retransmission. For a UDP application this means a single loss delays all the packets after it, not just the lost one. In games this shows up as stutter on a bad connection; and the latency to the server adds to your ping. For games, pick a server closer to you.
One more quirk: Vision drops UDP to port 443 by default, i.e. QUIC and HTTP/3 (the article on HTTP/3). The browser, getting no answer over QUIC, falls back to ordinary HTTPS over TCP, which then goes through Vision with its camouflage; the block rule for UDP 443 in the sets above just speeds up that fallback.
You can verify that UDP really goes through the server with a STUN request. STUN is the protocol calling apps use to learn their external address: the client sends a short Binding request over UDP, and the STUN server replies with the address and port it saw the request come from (the XOR-MAPPED-ADDRESS attribute). Through TUN, the answer should name your proxy server's address, separately for IPv4 and IPv6. Ready-made WebRTC test pages do the same from the browser.
A laptop in a café
All of this works the same on a laptop in someone else's network, with three caveats.
The Wi-Fi login page. Until you've signed in through the café's page (the captive portal), there's no internet, and with TUN on, DNS goes through the proxy, so the login page won't open. The order: turn TUN off, sign in to the Wi-Fi, turn TUN on.
Private ranges are no longer yours. The rule "
192.168.0.0/16,10.0.0.0/8,172.16.0.0/12direct" leads into your network at home, but into the café's network in a café. A laptop used to home or corporate addresses will knock on them via SMB or Kerberos, and any device in the café with such an address can answer, including to intercept a sign-in attempt. For other people's networks, create a second rule set, with everything through the proxy and no exceptions:[ { "remarks": "Block QUIC (UDP 443)", "outboundTag": "block", "port": "443", "network": "udp" }, { "remarks": "Link-local direct", "outboundTag": "direct", "ip": [ "fe80::/10" ] }, { "remarks": "Everything via proxy", "outboundTag": "proxy", "port": "0-65535" } ]Private addresses then go to the server, which sends them to
blackhole: nothing reaches the café's network. Switch between sets with the Routing list at the bottom of the main window.A separate user for every device. The laptop gets its own UUID (
xray-user add alice-laptop). Lose the laptop, and you revoke only it; the other devices keep working as before.
The café may have no IPv6 at all, which doesn't matter: the connection to the server goes over the IPv4 link, and applications reach IPv6 sites through the tunnel, from the server's IPv6 address.
Looking with your own eyes
What the ISP sees. Turn on the proxy, open Wireshark (or pktmon, the sniffer built into Windows) on the real adapter and filter for the first TLS messages to the server:
ip.addr == 203.0.113.10 && tls.handshake.type == 1
In the ClientHello: SNI www.example.com, ALPN h2, http/1.1, key_share with X25519, a set of extensions like Chrome's, and 32 "random" bytes in session_id. Then the ServerHello, and after that only encrypted Application Data records. Nothing to distinguish this from a visit to www.example.com, except the server's address.
What an inspector sees. The curl --resolve commands from the server section, from any machine, without a key: the real site with the real certificate.
Which address you go out from. With TUN on:
curl.exe -4 https://ifconfig.co # 203.0.113.10
curl.exe -6 https://ifconfig.co # 2001:db8::10
Console curl is a good test of TUN mode: in system proxy mode, without the environment variables, it would go directly and show your real address. IPv6 and DNS leak test sites in the browser should show the server's addresses and the resolver the remote DNS goes through, not your ISP's.
What the server sees. With the access log turned off, Xray writes nothing about visited addresses on the server. For debugging, temporarily enable access in log (without a path the log goes to journald) and watch journalctl -u xray -f: every connection with the user label and the outbound, [vless-reality >> direct], or [vless-reality -> block] for private addresses. Don't forget to turn it back off.
Limitations
- The address. Camouflage doesn't protect against blocking by IP. If the server's address gets on the list, you need a new address, which takes a minute with most clouds.
- SNI and address mismatch. A big site's SNI on a VPS host's address is an indirect sign. On its own it proves little (ordinary reverse proxies look the same), but combined with traffic volume it can draw attention.
- Volumes. A connection lasting hours with gigabytes going to "a site's home page" is statistics too. For ordinary use it's not a problem; for handing out a proxy to hundreds of people, it is.
- Updates. DPI methods change, and Xray changes with them: keep the core on the server and the client updated. Incompatible format changes do happen, so it's best to keep the server and the client on versions from the same period.
- Keys. The server's X25519 private key and the users' UUIDs are secrets. If one user's link leaks, delete their UUID and issue a new one. If the private key leaks, a new key pair and new links for everyone.
To sum up: Xray accepts connections on 443, VLESS takes care of who you are and where you're going, Vision makes sure TLS inside TLS isn't visible by its sizes, and REALITY makes the server look like a real big site both from the outside and when probed by connecting to it. And on the client, v2rayN's TUN mode wraps all of the machine's traffic into this, including UDP and IPv6.
Only registered users can leave a comment.
Sign in or register to leave a comment.