Enabling Reverse SSH Tunneling over VLESS + REALITY
Introduction
A brief guide to setting up a stealthy, NAT-transversing SSH tunnel using VLESS + REALITY on Debian. This configuration allows you to securely access a private machine (Client) through a public VPS (Server) while masking traffic as a standard TLS handshake to a legitimate website.
1. Pre-requisites
Have xray-core set up and running with a basic VLESS + REALITY configuration Check the previous article.
This guide focuses on the additional steps to enable SSH tunneling through the established VLESS + REALITY connection.
2. Server Configuration
Edit configuration file located at /etc/xray/config.json, which defines the portal receiveing the tunnel connection and the Dokodemo-door for user access.
Reverse
Registers the internal domain used for tunneling.
"reverse": {
"portals": [
{
"tag": "portal",
"domain": "ssh.internal.link"
}
]
}
Inbounds
Defines the entry points for incoming connections.
ssh-in-internal: Listens on a customized port (e.g.,2222). Traffic here is forwarded to the tunnel.reality-inbound: The VLESS entry point using REALITY.
"inbounds": [
{
"tag": "ssh-in-internal",
"listen": "127.0.0.1",
"port": 2222,
"protocol": "dokodemo-door",
"settings": {
"address": "127.0.0.1",
"network": "tcp"
}
},
{
"tag": "reality-inbound",
"port": 443,
"protocol": "vless",
/* ... Other settings remain unchanged ... */
}
]
Routing
Route traffic from the internal SSH port and the portal domain to the portal outbound.
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"domain": ["full:ssh.internal.link"],
"outboundTag": "portal"
},
{
"type": "field",
"inboundTag": ["ssh-in-internal"],
"outboundTag": "portal"
},
{
"type": "field",
"inboundTag": ["portal"],
"outboundTag": "portal"
}
]
}
1. The “Handshake” Rule (Outbound Initiation)
{
"type": "field",
"domain": ["full:ssh.internal.link"],
"outboundTag": "portal"
}
-
Purpose: To “pin” the tunnel between the client and server. Without this, the server treats the incoming connection from the client as a standard proxy request. It would try to resolve
ssh.internal.linkon the public internet and fail. It must be sent to the portal to register the bridge. -
Logic: When the client machine connects to the server via VLESS, it identifies itself using the
ssh.internal.linkdomain. This rule tells the server: “When seeing a connection request for this specific domain, don’t send it to the internet; send it to the internal portal to hold the tunnel open.” -
Workflow:
[Client: xray-core] | | (VLESS + Reality) | Target: "ssh.internal.link" v [Server: reality-inbound] | |--- [Routing Rule 1]: domain == "ssh.internal.link" ? | | | +--> YES: outboundTag = "portal" v [Server: Reverse Portal] <--- (Tunnel is now HELD open)
2. The “Data” Rule (Traffic Redirection)
{
"type": "field",
"inboundTag": ["ssh-in-internal"],
"outboundTag": "portal"
}
-
Purpose: To pipe SSH commands into the established tunnel. Without this, the server would accept SSH connections on port
2222but have no instructions on where to send that traffic. It would likely drop the connection or return an error. -
Logic: When running
ssh -p 2222from the server side, the traffic enters via thessh-in-internalinbound. This rule tells the server: “Anything coming into port 2222 must be pushed into the portal.” -
Workflow:
[User Command]: ssh -p 2222 username@127.0.0.1 | v [Server: ssh-in-internal (dokodemo-door)] | |--- [Routing Rule 2]: inboundTag == "ssh-in-internal" ? | | | +--> YES: outboundTag = "portal" v [Server: Reverse Portal] | | (Traffic is piped into the HELD tunnel) v [Client: bridge-tag] | +--> [Outbound]: ssh-out (redirect to 127.0.0.1:22) v [Client: sshd service]
3. The “Portal” Rule (Tunnel Maintenance)
{
"type": "field",
"inboundTag": ["portal"],
"outboundTag": "portal"
}
-
Purpose: To ensure the integrity of the reverse proxy’s internal state machine. This rule acts as a “logical anchor,” preventing return traffic from the Client (Bridge) from being hijacked by other, more general routing rules (such as a global direct or block rule).
-
Logic: When the Client sends data back through the tunnel, it enters the Server via the
portalinbound tag. This rule instructs Xray to immediately cycle that traffic back into theportaloutbound, allowing the reverse module to de-encapsulate the packet and deliver it to the original requester (user SSH session). -
Workflow:
[Client: Response Data] | | (Encrypted Tunnel) v [Server: portal (Inbound)] | |--- [Routing Rule 3]: inboundTag == "portal" ? | | | +--> YES: outboundTag = "portal" v [Server: Reverse Portal Module] | | (De-encapsulation & Handover) v [User Command]: ssh -p 2222 ... (Success)
Configuration Template
-
Configuration Example:
{ "log": { /* ... No changes, keep it as is ... */ }, "reverse": { "portals": [ { "tag": "portal", "domain": "ssh.internal.link" } ] }, "inbounds": [ { "tag": "ssh-in-internal", "listen": "127.0.0.1", "port": 2222, "protocol": "dokodemo-door", "settings": { "address": "127.0.0.1", "network": "tcp" } }, { "tag": "reality-inbound" /* ... No changes, keep it as is ... */ } ], "outbounds": { /* ... No changes, keep it as is ... */ }, "routing": { "domainStrategy": "AsIs", "rules": [ { "type": "field", "domain": ["full:ssh.internal.link"], "outboundTag": "portal" }, { "type": "field", "inboundTag": ["ssh-in-internal"], "outboundTag": "portal" }, { "type": "field", "inboundTag": ["portal"], "outboundTag": "portal" } /* ... No changes below, keep it as is ... */ ] } } -
Full Configuration Example:
{ "log": { // Path and level of the logs, can be anywhere "access": "/var/log/xray/access.log", "error": "/var/log/xray/error.log", "loglevel": "warning" }, "reverse": { "portals": [ { "tag": "portal", "domain": "ssh.internal.link" } ] }, "inbounds": [ { "tag": "ssh-in-internal", "listen": "127.0.0.1", "port": 2222, "protocol": "dokodemo-door", "settings": { "address": "127.0.0.1", "network": "tcp" } }, { "tag": "reality-inbound", "listen": "0.0.0.0", "port": 443, "protocol": "vless", "settings": { "clients": [ { "id": "<YOUR_UUID_HERE>", "flow": "xtls-rprx-vision" } ], "decryption": "none", // Enable UDP relay "udp": true }, "streamSettings": { "network": "tcp", "security": "reality", "realitySettings": { "sockopt": { // Enable TCP Fast Open "tcpFastOpen": true }, "show": false, "dest": "www.example.com:443", "serverNames": ["example.com", "www.example.com"], "privateKey": "<YOUR_PRIVATE_KEY_HERE>", "shortIds": [ "<YOUR_SHORT_ID_1>", "<YOUR_SHORT_ID_2>", "<YOUR_SHORT_ID_3>" ] } } } ], "outbounds": [ { "tag": "direct", "protocol": "freedom", "settings": {} }, { "protocol": "blackhole", "tag": "blocked", "settings": {} } ], "routing": { "domainStrategy": "AsIs", "rules": [ { "type": "field", "domain": ["full:ssh.internal.link"], "outboundTag": "portal" }, { "type": "field", "inboundTag": ["ssh-in-internal"], "outboundTag": "portal" }, { "type": "field", "inboundTag": ["portal"], "outboundTag": "portal" }, { "type": "field", "ip": [ "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" ], "outboundTag": "blocked" } ] } }
3. Client Configuration
Configure the client with the same UUID and REALITY settings as the server. Set the destination domain to ssh.internal.link to match the server’s reverse portal configuration.
Configuration Template
Using v2rayN as an example:
{
"logs": {
/* ... No changes, keep it as is ... */
},
"dns": {
/* ... No changes, keep it as is ... */
},
"reverse": {
"bridges": [
{
"tag": "bridge-tag",
"domain": "ssh.internal.link"
}
]
},
"inbounds": [
/* ... No changes, keep it as is ... */
],
"outbounds": [
{
"tag": "ssh-out",
"protocol": "freedom",
"settings": {
"redirect": "127.0.0.1:22"
}
}
/* ... No changes, keep it as is ... */
],
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"ip": ["127.0.0.1"],
"inboundTag": ["socks"],
"outboundTag": "direct"
},
{
"type": "field",
"domain": ["full:ssh.internal.link"],
"outboundTag": "proxy"
},
{
"type": "field",
"inboundTag": ["bridge-tag"],
"outboundTag": "ssh-out"
}
/* ... No changes, keep it as is ... */
]
}
}
Notes
-
Ensure the
127.0.0.1routing rule must be placed at the top of the routing list, and scoped to specific inbounds (likesocks). This prevents the reverse tunnel traffic from being hijacked and looped back into the direct outbound, which would cause an immediate connection closed -
Mux/Vision: Whilextls-rprx-visionis great for stealth, always set"mux": {"enabled": false}for SSH-over-Reverse-Proxy. SSH handles its own flow control; Xray’s Mux can cause protocol desync and latency spikes.
4. Testing the Tunnel
# Use -v for debugging and KeepAlive to prevent Reality-Vision timeout
ssh -p 2222 <YOUR_USERNAME>@127.0.0.1 -o "ServerAliveInterval=15" -v
-
-v: To see where the handshake hangs, or-vv/-vvvfor more verbose output. -
-o "ServerAliveInterval=15": To keep the multi-layered tunnel from being timed out by NAT gateways or firewall state-tracking.
References
- Gemini 3
- README - Xray-core: https://github.com/XTLS/Xray-core
- README - Reality: https://github.com/XTLS/REALITY