rttgap
Spot visitors behind residential, corporate and relay proxies
from the gap between the TCP round trip and a WebSocket echo.
Live check · Research write-up · Install · Quick start · Limits
IP reputation can't see residential proxies: the exits are real home and mobile lines. What a proxy can't hide is the detour.
A proxy ends the visitor's TCP connection and opens its own. Your kernel times the round trip to the proxy. An echo sent to the page travels on to the real browser and back. The difference between the two is the part of the path the proxy hides.
Does it work?
I sent 798 browser sessions through residential proxies in 8 countries, plus 50 direct visits, to a server I control.
| Check | Proxied sessions flagged | Direct visits flagged |
|---|---|---|
| Free IP reputation | 7 of 798 | 0 of 50 |
| rttgap, gap over 25 ms | 798 of 798 | 0 of 50 |
Direct visits landed between 0.3 and 8.5 ms. The closest proxy exit still left 31.5 ms. Full method and data.
Install
pip install "rttgap[aiohttp]" # reads the kernel's RTT from the socket (Linux)
pip install "rttgap[starlette]" # FastAPI / Starlette behind nginx
pip install rttgap # the verdict only, no dependencies
Python 3.9+. The kernel read (tcp_info) is Linux-only; everywhere else, pass the round trip in yourself.
Quick start
aiohttp
aiohttp exposes the socket, so rttgap reads the kernel's minimum RTT for the connection directly.
from aiohttp import web
import rttgap.aiohttp
async def on_result(request, result):
if result.is_proxied:
print("proxied:", request.remote, f"{result.gap_ms:.0f} ms")
app = web.Application()
app.router.add_get("/rttgap", rttgap.aiohttp.handler(on_result))
web.run_app(app)
On the page, load the client (serve rttgap.CLIENT_JS as a static file, or inline it) and start the measurement:
<script src="/static/rttgap.js"></script>
<script>rttgap.run('/rttgap');</script>
The browser only learns that the measurement finished. The verdict stays on your server unless you pass reveal=True.
FastAPI / Starlette
ASGI servers don't expose the socket, so let nginx pass its round trip in a header:
location /rttgap {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-TCP-RTT $tcpinfo_rtt; # microseconds
}
from fastapi import FastAPI, WebSocket
import rttgap.asgi
app = FastAPI()
@app.websocket("/rttgap")
async def rtt_gap(websocket: WebSocket):
result = await rttgap.asgi.measure(websocket)
# result.verdict: "direct" | "unsure" | "proxied" | "unknown"
$tcpinfo_rtt is a smoothed RTT, sampled when the request arrives. It runs high on congested links, which shrinks the gap, so expect a few proxied sessions to read unsure. Only trust X-TCP-RTT from your own proxy.
Just the verdict
import rttgap
r = rttgap.evaluate(tcp_rtt_ms=2.1, echoes_ms=[176.0, 174.2, 180.9])
r.verdict # "proxied"
r.gap_ms # 172.1
How it decides
- The server sends 15 short text messages over a WebSocket, one at a time, and times each echo.
- It takes the TCP round trip from the kernel (
tcpi_min_rtt), or from the value you pass. - It compares the fastest echo with the fastest TCP round trip. A smoothed RTT runs high on congested links, and one lucky sample against many echoes fakes a gap.
| Gap | Verdict | Why |
|---|---|---|
| up to 12 ms | direct |
the largest direct gap measured was 8.5 ms |
| 12 to 25 ms | unsure |
busy Wi-Fi or mobile network: measure again |
| over 25 ms | proxied |
the smallest proxied gap measured was 31.5 ms |
| no data | unknown |
result.reason says why |
Change the limits with rttgap.Thresholds(proxied=..., unsure=...).
A page can delay an echo, but it can't answer one early, so the gap can't be faked away. Text echoes are used instead of WebSocket pings, because proxies and servers may answer pings themselves.
API
rttgap.evaluate(tcp_rtt_ms, echoes_ms, thresholds=DEFAULT) |
verdict from numbers you already have |
rttgap.aiohttp.handler(on_result=None, *, reveal=False, **options) |
ready-made aiohttp WebSocket route |
rttgap.aiohttp.measure(request, ws, **options) |
run the echoes over your own WebSocketResponse |
rttgap.asgi.measure(websocket, *, tcp_rtt_ms=None, header="x-tcp-rtt", ...) |
Starlette / FastAPI |
rttgap.read_tcp_info(sock) |
TcpInfo(rtt_ms, rttvar_ms, min_rtt_ms) on Linux, else None |
rttgap.CLIENT_JS |
the browser client, as a string |
Result |
verdict, gap_ms, tcp_rtt_ms, min_echo_ms, echoes_ms, tcp_source, reason, is_proxied, as_dict() |
Options: echoes=15, interval=0.12 s between probes, timeout=5.0 s per echo, thresholds, tcp_rtt_ms, allow_loopback=False.
Limits
- VPNs forward packets instead of ending TCP, so there's no gap to find. Pair rttgap with an IP reputation check.
- Anything in front of your server ends the TCP connection first. Behind nginx or a tunnel on the same machine, rttgap returns
unknownrather than a wrong answer: passtcp_rtt_msfrom the proxy. Behind a CDN, measure at the edge instead. On Cloudflare Workers,request.cf.clientTcpRttgives the TCP half, which is how the live check works. - Mobile networks speed up and slow down from one second to the next, which can fake a small gap. Treat
unsure, and gaps just over the threshold, as "measure again on a fresh connection". A real proxy's detour shows up every time. - Blocked WebSockets. Some data-saver modes and corporate proxies cut them. No echoes means
unknown.
Demo
pip install "rttgap[aiohttp]"
python -m rttgap --port 8080
Run it on a machine visitors reach directly, then open it with and without a proxy. Each visit's result prints as JSON.
Contributing
Bug reports, integrations for other frameworks, and data from other proxy networks are all welcome. See CONTRIBUTING.md. Security issues: SECURITY.md.
License
Metadata
Release files for rttgap 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| rttgap-0.1.0.tar.gz | 16.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| rttgap-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 32.2 kB
Release files / rttgap-0.1.0.tar.gz
| Download URL | rttgap-0.1.0.tar.gz |
|---|---|
| Size | 16.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a1934c07b0f5f92b3da7fd3263715306e50c0d745e17cc6d940106f82e544dce
|
|
BLAKE2b-256 checksum How to use checksums |
1cde374bdbc736a1aa432b6701b542ec468c2293dfa928164b4707b1ebba2705
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.6
|
Release files / rttgap-0.1.0-py3-none-any.whl
| Download URL | rttgap-0.1.0-py3-none-any.whl |
|---|---|
| Size | 15.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1634ac09e7ff7621a323462f139c20e3bce08087b8f33a4c85a38e22a4b60eed
|
|
BLAKE2b-256 checksum How to use checksums |
a58b971773d3a3215901b38e45b50086a53a0dca35335a4002cb0afcee5b3c08
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.6
|