A cloud SOCKS5 proxy whose exit nodes live behind NAT.
The machine that traffic actually exits from never opens a port, never needs a static IP, and never needs to be reachable from the internet. It dials out to a small relay server you run on a VPS, and stays registered there. Clients connect to that one relay address and pick an exit by username.
curl -x socks5h://alice:secret@relay.example.com:1080 https://ifconfig.co/ip
# └── routed to whichever agent registered as "alice",
# and exits from the network interface that agent chose
Running a SOCKS5 proxy on a remote machine normally means port forwarding on the router, a firewall rule, and a stable public IP. That breaks the moment the machine sits behind CGNAT, the ISP rotates the address, or you simply do not control the router.
RevSocksRelay inverts the direction. The exit machine (the agent) opens an outbound connection to your relay server and keeps it alive. When a client asks the relay for a connection, the relay hands the job down that existing tunnel. Nothing ever connects to the agent.
| Problem | How it is solved |
|---|---|
| Router port forwarding | Agent only makes outbound connections |
| CGNAT / no public IP | Same — outbound works from anywhere |
| IP address changes | Agent re-registers; the relay never stores its address |
| Many exits, one entry point | One SOCKS5 port, routed by username |
Three components: the agent (exit node), the relay server (rendezvous point), and any SOCKS5 client.
[Agent machine] [Relay server / VPS] [Client]
behind NAT public IP + domain
1. outbound TCP ───────────> :7000 control registry["alice"] = agent
HELLO_CONTROL
{user, pass, exitIp}
2. outbound TCP ───────────> :7000 worker × 4 parked in pool
HELLO_WORKER <──── 3. SOCKS5 :1080
auth alice:secret
CONNECT host:port
4. look up registry, take a parked worker
<──── TASK {host, port} ───┘
5. resolve DNS,
bind(exitIp), connect
──── TASK_OK {bnd} ──────>
6. <========= raw byte relay =========>
7. open a replacement
worker to refill the pool
Connection pooling. As soon as an agent registers, it opens a few idle worker
connections that park on the relay. When a client arrives, a worker is already waiting —
so there is no round trip spent asking the agent to dial back. If the pool happens to be
empty the relay sends SPAWN over the control channel and waits briefly, so pooling is
purely an optimization, never a correctness requirement.
DNS resolves on the agent. Hostnames travel over the wire and are resolved at the
exit node. That avoids DNS leaks and gives geographically correct answers for the exit's
location. Use socks5h:// (not socks5://) so your client sends the hostname instead of
resolving it locally.
.\deploy\publish.ps1| Output | Size | Requirements on target |
|---|---|---|
publish/linux-x64/revsocks-relay-server |
13.4 MB | None — .NET is not needed |
publish/win-x64/RevSocksRelayAgent.exe |
46.9 MB | None — .NET is not needed |
Both are self-contained, single-file and compressed. No DLLs, no runtime, no config files sitting next to them — settings files are created on first run.
The Android agent is built separately with Gradle — see Android agent → Build.
Every push to main also builds all three components and attaches them to a GitHub
Release automatically; see Automated builds and releases.
The server is additionally trimmed (70 MB → 13 MB). To make trimming safe, JSON
serialization uses a source generator rather than reflection
(ProtocolJsonContext). The agent is not
trimmed, because WinForms does not support it.
Copy the single binary and the deploy/ folder to your VPS:
chmod +x deploy/install-ubuntu.sh
sudo ./deploy/install-ubuntu.sh ./revsocks-relay-serverThis creates a revsocks-relay system user, installs the binary to
/opt/revsocks-relay, writes /etc/revsocks-relay/server.json, and enables a systemd
service.
journalctl -u revsocks-relay-server -f # live logs
systemctl restart revsocks-relay-server # after editing the configOpen the firewall:
sudo ufw allow 7000/tcp # agent registration port
sudo ufw allow from <your-ip> to any port 1080 # SOCKS5 — restrict if you canAnyone who learns a username/password pair can use your proxy. If you connect from a known address, restrict port 1080 to it rather than exposing it to the internet.
Run RevSocksRelayAgent.exe on the machine you want traffic to exit from.
- Relay server — your VPS hostname and port (
7000). - Uplink interface — which local connection carries the tunnel itself. Leave it on Automatic to follow the operating system's routing table, or pin it to one address. See below for why this matters on a machine with more than one internet connection.
- Add one row per exit:
- Username / Password — the credentials clients will use.
- Exit IP — pick from the dropdown of local addresses. The agent binds this address when connecting to targets, so traffic leaves through that interface.
- Press Start.
Settings are saved to agent.json next to the executable.
The Status column shows Registered (green), Connecting / Reconnecting
(orange), or Rejected / Error (red). Pool shows how many worker connections are
currently parked and ready.
Minimizing the window sends the agent to the notification area instead of the taskbar, and the tray icon is colour-coded so you can read its state at a glance:
| Icon | Meaning |
|---|---|
| Grey | Stopped |
| Amber | Connecting, or only some profiles registered |
| Green | All profiles registered |
| Red | No profile could connect |
Left-click the icon to bring the window back, right-click for a menu with Show, Start, Stop and Exit. Closing the window while profiles are running also minimizes to the tray rather than killing the tunnels — use Exit in the tray menu to actually quit.
curl -x socks5h://alice:secret@relay.example.com:1080 https://ifconfig.co/jsonIn Firefox: Settings → Network Settings → Manual proxy → SOCKS v5, tick Proxy DNS when using SOCKS v5. Credentials are prompted on first use.
Authentication is mandatory — the relay rejects the no-auth method, because the
username is what selects the route.
The Android agent speaks the same protocol as the desktop one, so the relay server needs no changes. Its reason to exist: register over Wi-Fi while exiting through the mobile carrier, giving you a carrier IP without any port forwarding on either side.
[Android phone] [Relay server] [Client]
someone's Wi-Fi + own SIM
control + workers ── Wi-Fi ─────────> :7000
<──── SOCKS5 :1080
target connections ── cellular ─────> the internet
Android lets several networks be active at once. The agent asks for the cellular network explicitly, which keeps mobile data up even while Wi-Fi is the default route, then binds each socket to the network it belongs to:
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivity.requestNetwork(request, callback) // holds the radio up
val socket = cellularNetwork.socketFactory.createSocket() // bypasses default routingDNS is resolved through the same network (Network.getAllByName), so a cellular exit
does not leak lookups onto the Wi-Fi resolver.
bindProcessToNetwork()would have been simpler but is process-wide — it cannot hold two networks at once, which is exactly what this design needs.
cd android
.\gradlew.bat assembleDebugOutput: android/app/build/outputs/apk/debug/app-debug.apk. Install with
adb install -r app-debug.apk or copy it to the phone. Requires JDK 17+ (Android
Studio's bundled JBR works) and Android SDK 36.
For a distributable build, run .\gradlew.bat assembleRelease instead — the release
variant is signed with the debug key (see
app/build.gradle.kts), so it installs without any
signing setup. This is also what CI attaches to each release as
revsocks-relay-agent-android.apk — see
Automated builds and releases.
| Setting | Meaning |
|---|---|
| Main server | Relay host and port (7000) |
| Uplink network | Which network carries the tunnel — Wi-Fi, mobile data, or automatic |
| Exit network (per profile) | Which network reaches the target — mobile data, Wi-Fi, or automatic |
| Auto-start on boot | Reconnects after a reboot |
As on desktop, one profile is one username:password, and the client picks the exit by
authenticating with that pair. You can run a Wi-Fi-exit profile and a cellular-exit
profile side by side under different usernames.
Android fights long-running background work, so the app:
- runs as a foreground service with an ongoing notification;
- declares the
specialUseservice type. This is deliberate —dataSyncis capped at 6 hours per day on Android 15 and would cut a permanent tunnel; - holds a partial wake lock while the tunnel is up;
- offers a battery-optimisation exemption prompt, without which Doze can drop the tunnel;
- optionally restarts itself after a reboot.
Aggressive OEM task killers (Xiaomi, Huawei, Samsung and friends) still need the app whitelisted by hand in their own battery settings. No amount of code fixes that.
Tested on a Android 16 with both Wi-Fi and mobile data active at once, against the C# relay server:
| Check | Result |
|---|---|
| Registered over Wi-Fi | Server saw the connection arrive from the phone's Wi-Fi address |
| Exit bound to cellular | |
| Foreground service | Started with the specialUse type; system allowed it |
| Pool + relay + auth | Same protocol as desktop, server unchanged |
That IP split is the whole point: the phone stayed on Wi-Fi for the tunnel, but every proxied request left through the mobile network.
- Mobile data pays for everything. With a cellular exit, every proxied byte comes out of your data plan. The Wi-Fi leg carries the same bytes but does not spare the SIM.
- Battery. Holding the cellular radio up while Wi-Fi is connected keeps it from sleeping deeply. Fine on a charger, noticeable in a pocket.
- No SIM1/SIM2 picker. Most dual-SIM devices only run data on one subscription at a
time, and switching the default data SIM needs
MODIFY_PHONE_STATE, a signature-level permission unavailable to normal apps. The agent uses whichever SIM currently holds mobile data; change it in Android's own settings. - No TLS yet on the Android agent — plain TCP, matching the server default.
These are two different interfaces, and on a multi-homed machine you usually want to set both deliberately:
| Setting | Scope | Which leg it controls |
|---|---|---|
| Uplink interface | One per agent | Agent → relay server. Carries every byte of every tunnel. |
| Exit IP | One per profile | Agent → target host. Decides the source address the target sees. |
Proxied traffic crosses both. If the machine has a metered mobile connection alongside an unmetered one, pinning the uplink to the unmetered link keeps the tunnel off your data cap — regardless of which interface a profile exits from.
The uplink is enforced by binding the socket, not by hinting to the router. If the chosen interface cannot reach the relay server, the agent reports the error and retries with backoff rather than silently falling back to another connection — which is the point: a silent fallback onto a metered link is exactly what you were trying to avoid.
Because a bound socket cannot cross address families, an IPv4 uplink resolves the relay hostname to IPv4 only (and likewise for IPv6).
If the agent machine has several interfaces, add one row per interface with a different username. All of them register at once, and the username a client authenticates with decides which interface the traffic leaves through.
| Username | Exit IP | Interface |
|---|---|---|
alice |
192.168.10.191 |
Wi-Fi |
bob |
10.8.0.6 |
VPN adapter |
| Key | Default | Meaning |
|---|---|---|
RelayListenIp / RelayListenPort |
0.0.0.0 / 7000 |
Where agents connect |
Socks5ListenIp / Socks5ListenPort |
0.0.0.0 / 1080 |
Where SOCKS5 clients connect |
PoolSize |
4 |
Idle worker connections kept per agent |
HeartbeatSeconds |
20 |
Control-channel ping interval; 2.5× silence means dead |
WorkerWaitMs |
15000 |
How long to wait for a worker when the pool is empty |
TaskTimeoutMs |
30000 |
How long the agent has to reach the target |
HandshakeTimeoutMs |
15000 |
First-frame and SOCKS5 handshake timeout |
UseTls |
false |
Wrap agent connections in TLS (must match the agent setting) |
Verbose |
false |
Debug logging (also via REVSOCKS_RELAY_VERBOSE=1) |
| Key | Meaning |
|---|---|
ServerHost / ServerPort |
Relay server address |
UplinkIp |
Local address to bind when connecting to the relay; empty means system routing |
UseTls |
Must match the server setting |
SkipCertificateValidation |
Accept any certificate (only relevant with TLS) |
PinnedThumbprint |
If set, the server certificate's SHA-256 must match exactly |
Profiles[] |
{ Enabled, Username, Password, ExitIp } per exit |
Passwords are stored in plain text in agent.json.
There is no user database on the server. Credentials are defined by the agent:
- An agent registers with
username:password; the relay stores that pair in memory. - A client presenting the same pair is routed to that agent.
- A client presenting an unknown username is rejected — that route simply does not exist.
If the same username registers a second time:
| Case | Result |
|---|---|
| Same password | The old session is dropped and the new one takes over (reconnect) |
| Different password | Registration is rejected; the existing session is untouched |
That lets an agent reclaim its name after a network drop, while stopping anyone else from squatting on it.
Length-prefixed frames between agent and relay:
[uint8 type][uint32 big-endian length][UTF-8 JSON payload]
| Type | Direction | Payload |
|---|---|---|
0x01 HELLO_CONTROL |
agent → relay | {username, password, exitIp, agentVersion} |
0x02 HELLO_WORKER |
agent → relay | {username, password} |
0x10 ACCEPTED |
relay → agent | {poolSize, heartbeatSeconds} |
0x11 REJECTED |
relay → agent | {reason} |
0x20 PING / 0x21 PONG |
both | — |
0x30 SPAWN |
relay → agent | {count} — pool exhausted |
0x40 TASK |
relay → agent | {host, port} — on a worker channel |
0x41 TASK_OK |
agent → relay | {bndAddr, bndPort} |
0x42 TASK_FAIL |
agent → relay | {socksCode, message} |
After TASK_OK framing stops and the connection carries raw bytes in both directions.
TLS is off by default; plain TCP works fine. Its only job here is protecting the password the agent sends during registration from anyone on the network path.
That password has to arrive in usable form, because the relay compares it against the plaintext password a SOCKS5 client sends per RFC 1929 — so hashing it in transit is not an option, and channel encryption is the only lever.
To enable it, set "UseTls": true on the server and tick Use TLS in the agent. If no
certificate is supplied the server generates a self-signed one next to the binary and
logs its SHA-256 fingerprint; the agent can either skip validation or pin that
fingerprint via PinnedThumbprint.
- TCP
CONNECTonly.UDP ASSOCIATEandBINDare not implemented, so UDP-based traffic (QUIC fallback aside, most games, WebRTC media) will not pass. - Address families must match. A socket bound to an IPv4 exit address cannot reach an
IPv6 target. The agent filters DNS results to the exit address's family and returns
0x04 host unreachablewhen nothing matches. For IPv6 targets, register a second profile with an IPv6 exit address. - Bandwidth doubles at the relay. Every byte crosses the VPS twice, once in and once out. Watch your traffic quota.
- One extra hop of latency. Client → relay → agent → target.
- Credentials are plaintext in memory on the relay and in
agent.jsonon the agent.
Verified end to end with a relay server, two agents on different interfaces, and curl:
| Scenario | Result |
|---|---|
HTTPS via hostname (socks5h), DNS resolved by the agent |
Correct public IP returned, byte-identical to a direct request |
| Two usernames → two different exit interfaces | Each exited through its own interface |
| Exit IP binding | Target observed the configured exit address as the source |
| 8 concurrent requests | 8/8 succeeded, pool refilled itself |
| Wrong password | SOCKS5 authentication rejected |
| Agent process killed | Relay deregistered it; later requests rejected |
| Agent restarted | Re-registered automatically, traffic resumed |
| Same username, different password | Rejected; existing session unaffected |
| Trimmed single-file server | Identical behaviour after trimming |
| Uplink pinned to a chosen interface | Relay saw the connection arrive from that exact address |
| Uplink pinned to an interface with no route | Clear error, exponential backoff, no silent fallback |
| Minimize to tray | Left the taskbar, process kept running |
| Project | Target | Contents |
|---|---|---|
src/RevSocksRelay.Protocol |
net10.0 |
Frame codec, message types, SOCKS5 constants, relay pump |
src/RevSocksRelay.Server |
net10.0 |
Relay server — console, Linux and Windows |
src/RevSocksRelay.Agent |
net10.0-windows |
Agent — WinForms |
The server does not reference the agent, so it publishes standalone on Linux.
Building by hand:
dotnet publish src\RevSocksRelay.Server\RevSocksRelay.Server.csproj -c Release -r linux-x64 -o publish\linux-x64
dotnet publish src\RevSocksRelay.Agent\RevSocksRelay.Agent.csproj -c Release -r win-x64 -o publish\win-x64Single-file, compression and trimming settings live in a PropertyGroup that activates
whenever a RuntimeIdentifier is supplied, so passing -r is enough to turn them on.
.github/workflows/release.yml builds every component
on each push to main (and on demand from the Actions tab) and publishes the results
to the repository's Releases page.
| Job | Runner | Produces |
|---|---|---|
| Server | ubuntu-latest |
revsocks-relay-server-linux-x64, revsocks-relay-server-win-x64.exe |
| Agent | windows-latest |
RevSocksRelayAgent-win-x64.exe |
| Android | ubuntu-latest |
revsocks-relay-agent-android.apk |
The server is a console app and cross-compiles both runtimes on Linux; the WinForms agent
needs a Windows toolchain, so it builds on a Windows runner; the Android APK is a
release build signed with the debug key, so it needs no signing secrets.
Each run creates a release tagged build-<run-number> against the pushed commit with all
four files attached. The workflow authenticates with the built-in GITHUB_TOKEN — no
extra configuration is required.