notes from building a personal tunnel that survives DPI and still feels good enough for FPS games. not a product writeup. the goal was low jitter, not "any connection that works."
files in this repo:
setup.sh— server install path (Nginx + Xray, VLESS+WS+TLS, ubuntu-oriented)winInstallation— windows client with v2rayNadvanced-setup/— latency notes / later findings
read the sections in order if you care about why the stack looks the way it does. if you only want install steps, jump to the scripts.
i was behind a network that did more than block a few ports. UDP was restricted. web filtering was aggressive. normal VPNs either failed hard or "worked" with enough jitter that games felt awful.
DPI (deep packet inspection) is the usual name for this class of middlebox. it looks at early packets, classifies the flow, then allows, drops, throttles, or resets it. sophos-class gear is one example of the appliance side; the exact brand matters less than the checks it runs.
what i needed:
- a path that still works when classic VPN protocols are blocked
- low enough latency and jitter for FPS
- something i could run myself on a nearby VPS (i used digitalocean-style boxes; a 2 vCPU / 2 GB / ~2 TB transfer plan was enough for a small number of users)
getting "online" is easy. getting a tunnel that does not feel like lag soup is the actual problem.
firewalls do not have to decrypt you to block you. they need a reliable classifier.
IPsec / L2TP / IKEv2 — old enterprise remote-access stack. very recognizable on the wire. a lot of campus and corporate profiles still have explicit signatures for this. i saw this class of VPN die first.
OpenVPN — still common. handshake patterns and fixed ports make it easy to spot unless you put real work into obfuscation. even then, destination IPs of known providers get blocked.
WireGuard — this is what most modern consumer VPNs actually use, not IPsec. people still write "modern VPNs are IPsec" and that is wrong. WireGuard is excellent when the network allows it. on my path it was not useful: UDP was restricted, and WireGuard's fixed-size UDP handshake is easy to fingerprint when UDP is even allowed.
commercial VPN endpoints — protocol stealth does not help if the egress IP is already on a blocklist.
so the failure modes i kept hitting were:
- protocol signature match
- UDP / non-443 transport policy
- destination IP reputation
- later: active probing of anything that looked like a self-hosted "weird HTTPS"
anything that still behaves like a VPN on the wire loses here.
two buckets: passive and active.
- port / protocol: UDP 51820-ish for WireGuard defaults, ESP, IKE, openvpn ports, etc.
- length and timing: fixed handshake sizes, regular keepalives, traffic that does not look like a browser session
- TLS ClientHello (this one matters a lot): the first client TLS message is mostly cleartext. inside it you get:
- SNI — the hostname you claim you are visiting (e.g.
www.chess.com) - cipher suite list and order
- extension set and order (ALPN, supported groups, GREASE, etc.)
- SNI — the hostname you claim you are visiting (e.g.
those ClientHello fields hash into fingerprints people talk about as JA3 / JA4-style. chrome on windows has one shape. firefox on linux has another. a default go crypto/tls client has a different shape. DPI can just block or throttle the go-shaped one.
if passive signals look off, some boxes open their own TLS session to the same IP:port and check:
- what certificate comes back
- whether the server behaves like a normal HTTPS origin
- whether random probes still smell like a proxy
you need an answer for both. only fixing SNI is not enough. only buying a domain is not enough either if the ClientHello still screams "proxy tool."
HTTPS is TLS on TCP 443. the modern web is built on it. if a network blocks HTTPS as a class, banking, SaaS, updates, and half of campus tooling die. operators usually will not do that.
that is not the same as "firewalls cannot block HTTPS." they can. they do:
- drop specific SNI values (deny lists)
- only allow a small SNI allow-list (harsher environments)
- MITM TLS on managed devices with a corporate root
- throttle / reset flows that fail fingerprint or reputation checks while leaving normal browser traffic alone
so the useful property of HTTPS is operational: traffic that looks like ordinary browser HTTPS to somewhere boring is expensive to kill without collateral damage. the rest of this note is about sitting in that bucket, and where that still fails.
once the requirement is "look like HTTPS," the stack i landed on for the baseline install is VLESS over WebSocket over TLS, terminated on Nginx, with Xray on localhost.
what each piece is for:
- VLESS — light proxy protocol. UUID identity, not much framing. carries the real traffic once a channel exists.
- WebSocket — so the path can look like an upgraded HTTP connection and sit behind Nginx on a secret path.
- TLS — encrypts everything above. on the wire the middlebox sees TCP 443 + TLS.
rough path:
game / browser / whatever
-> client (v2rayN, clash, sing-box, ...)
-> VLESS in WebSocket in TLS
-> TCP 443 through the firewall
-> Nginx on VPS (TLS cert for your domain, WS upgrade on secret path)
-> Xray VLESS inbound on 127.0.0.1
-> out to the internet
why this combo worked for me as a starting point:
- 443/TLS matches what most allow-policies already expect
- a real domain + certbot cert beats raw-IP HTTPS, which some filters treat as sketchy (i used a free duckdns name pointed at the VPS)
- Nginx can serve something normal on
/and only proxy/my-secret-path(or whatever path you pick) - VLESS does not add a lot of overhead compared to heavier wrappers
setup.sh and winInstallation document this baseline. it beats "block all IPsec / WireGuard." it does not automatically beat fingerprinting or active probes.
TLS encrypts application data. it does not hide the whole handshake.
ClientHello is visible. ECH (encrypted client hello) exists but is not something you can assume is on everywhere, and a lot of proxy stacks still expose SNI the normal way.
so DPI can:
- read SNI and apply a blocklist / allow-list
- hash the rest of the ClientHello and compare to known browser vs known tool fingerprints
those are different controls. i used to mash them together in earlier notes. they are not the same.
| control | what it looks at | what actually helps |
|---|---|---|
| SNI policy | hostname string | allowed / unlisted name, or REALITY camouflage name |
| fingerprint policy | ClientHello structure | uTLS / browser fingerprint on the client |
| active probe | cert + server behavior | real site in front, or REALITY |
on my path, SNI was the first filter (blocked sites by name). after VLESS+WS+TLS with my own domain got through that, the remaining pain looked more like throttle/jitter than a hard block. that lines up with fingerprint / behavior based treatment, not "SNI said no."
a default go TLS client does not look like chrome. Xray is go. if you leave the fingerprint alone, you can still lose.
uTLS is a go TLS library that can rebuild ClientHellos to match real clients (chrome, firefox, safari, ios, etc.).
in practice you set something like:
"fingerprint": "chrome"in v2rayN / clash / sing-box / whatever client you use. the exact field name depends on the client.
what uTLS does: make the first packets look like a browser starting HTTPS, instead of "stock go crypto/tls."
what it does not do:
- hide your server certificate identity
- beat an SNI allow-list that simply does not include your name
- fix traffic shape / long-lived flow analysis
for the Nginx + VLESS+WS+TLS setup, turning on a chrome (or similar) fingerprint was one of the more useful anti-jitter changes after the tunnel already connected. hard blocks and soft throttles are different failure modes; fingerprinting often shows up as the second one.
more detail: https://github.com/refraction-networking/utls
even with uTLS and a let's encrypt cert on something like vpn.yourdomain.tld, an active probe can still see:
- cert is for a random / new name
- server does not behave like a big public origin
REALITY (in Xray-core; also in sing-box) is the next step people reach for. i treated sing-box / Xray as the engines that implement this; the interesting part is the handshake design, not the brand of the binary.
a common short explanation is: "REALITY forwards the handshake to microsoft and you ride inside that session." that is too sloppy.
you are not getting free bandwidth through microsoft. your proxy traffic still terminates on your VPS after a successful REALITY auth. microsoft (or whatever site you pick as camouflage) is a reference / dest used to shape what probers see, not your actual application endpoint.
- client uses uTLS, puts a high-traffic hostname in SNI (microsoft is a common example in tutorials; it is just a config choice, and some targets break when their TLS details change).
- client also carries REALITY auth material in the handshake (session id space, AEAD over an X25519 shared secret, short id, etc. — see XTLS/REALITY and this source walkthrough).
- server uses its private key to decide if this is a real client.
- for the handshake, the server dials a real
destand uses that exchange to produce ServerHello / cert presentation that matches what a probe expects for that name. for authorized clients, temporary cert / signature material is tied to the REALITY shared key so the client can authenticate the proxy without needing a CA cert for your personal hostname. - unauthorized connections get forwarded toward
dest, so a probe that is not a real client still sees something that looks like the real site's TLS behavior instead of a custom dead service. - after auth, VLESS (or whatever you run) rides between client and your server over that TLS channel.
so: cryptographic camouflage + probe handling + client auth. not "tunnel everything through microsoft."
- network only kills classic VPN / UDP and does not care about fingerprints or obscure certs
- you want simpler ops (certbot + Nginx, secret path, done)
- you want a real website on the same box as the proxy path
client machine
app traffic (system proxy or TUN)
-> VLESS
-> WebSocket
-> TLS <-- ClientHello is visible to DPI here
-> TCP/443
-> through firewall
-> your VPS
on VPS:
Nginx terminates TLS (your domain cert)
-> HTTP 101 upgrade on secret path
-> Xray VLESS on 127.0.0.1
-> outbound to destination
DPI sees: TCP to your VPS, port 443, ClientHello with your SNI, hopefully a browser-like fingerprint if you set uTLS. after handshake: opaque TLS records.
DPI does not see (without MITM on the client device): VLESS UUID, WS payload, destinations inside the tunnel.
client
VLESS
REALITY / TLS 1.3 via uTLS
SNI = camouflage name
auth = REALITY public key + short_id material
-> TCP/443 -> your VPS
server
if auth ok -> finish REALITY handshake -> VLESS -> internet
if auth bad -> forward / mimic via dest so probes look normal
DPI sees TLS to your IP with a popular SNI and a browser-shaped ClientHello. it does not automatically learn "this is VLESS" from cleartext alone.
flow hits middlebox
-> SNI policy (domain / camouflage name)
-> fingerprint policy (uTLS)
-> active probe (REALITY or a real site on the IP)
-> traffic analysis (still hard; long flows can look like proxies anyway)
bypass and "FPS feel" are different problems. a tunnel can connect and still feel bad.
what actually moved the needle for me:
- VPS location — closest region that is still outside the restricted network. RTT is mostly distance and peering. crypto on a modern CPU is rarely the bottleneck for a few users.
- provider — gaming/streaming burns transfer. some clouds punish that on price; i preferred simpler VPS pricing (digitalocean-class) for that reason.
- do not stack features you did not measure — VLESS is light; multiplex, extra routing, unnecessary wrappers add buffering.
- UDP inside TCP — if the firewall kills native UDP, you carry game UDP inside the TCP/WS tunnel. under loss that can feel worse (TCP-over-TCP style pain). low loss on the outer path matters.
- fingerprint throttle — wrong ClientHello sometimes does not hard-block; it throttles. that showed up as jitter for me more than as "connection failed."
- local junk — bufferbloat and wifi will fake a DPI problem. wire the client when you debug latency.
- box size — 2 vCPU / 2 GB was fine for a small group; path RTT dominated.
more on latency workarounds lives under advanced-setup/.
things this does not solve:
- managed-device MITM — if the network owns the client and installs a corporate root, they can intercept HTTPS. different fight.
- ECH is not a free win — deployment is uneven; many stacks still expose SNI.
- traffic analysis — long-lived, high-volume, steady duplex flows can still look like a proxy even when the handshake is perfect.
- IP blocklists — if your VPS IP is burned, camouflage protocols do not unburn it. move or front it.
- REALITY dest choice — targets need compatible TLS (usually TLS 1.3, parameters that do not break the implementation). popular names sometimes stop working when cert sizes / behavior change. test, have a backup dest.
- policy / law — bypassing network controls may break local rules. this is research and personal ops notes; you own your use.
- ops cost — REALITY + key handling is more moving parts than WireGuard on a clean network. use the smallest stack that matches the actual middlebox.
- read 1–4 if you have not already. the install only makes sense with the threat model.
- stand up baseline VLESS+WS+TLS with
setup.shon a nearby VPS. - connect from windows with
winInstallation. set fingerprint tochrome(or similar) if the client supports it. - if you still get throttle / probe-style failures, look at REALITY via Xray or sing-box instead of only changing ports.
- chase latency in
advanced-setup/.
- https://github.com/refraction-networking/utls — ClientHello mimicry
- https://github.com/XTLS/Xray-core — VLESS / REALITY side of Xray
- https://github.com/XTLS/REALITY — REALITY itself
- https://github.com/SagerNet/sing-box — multi-protocol client/server with REALITY
- https://objshadow.pages.dev/en/posts/how-reality-works/ — source-level REALITY walkthrough
- https://www.cloudflare.com/learning/ssl/what-happens-in-a-tls-handshake/ — plain TLS handshake primer
personal research notes. copy configs only after you know which layer of the problem you are actually stuck on.