How to proxy a 3x-ui subscription service via Nginx on a different host

I have a feeling that I might be creating a problem only to heroically solve it, but here’s the issue: Xray and the subscription service listen on different ports. If VLESS-Reality is used, the host should mimic a legitimate third-party host as closely as possible. Therefore, having an HTTPS service with a different certificate on a non-standard port looks suspicious. Possible options include:

  1. SNI Shunting: (I haven’t tried this yet. I’ve seen examples using Nginx, but it’s unclear how to make Nginx appear as a legitimate third-party host and make it use the same certificate and more importantly how to get key for it? So it should be some point of transport proxying.)
  2. Using a Second IP on the Server

I’ve tried searching for examples or documentation, but without much success.

The most promising idea that came to mind: why not use a second server to proxy requests for the subscription service? The downside is the cost of maintaining a second VPS. However, the advantage is that the services are decoupled. If the proxy host gets blocked, the subscription service host remains functional and can be repointed to another server.

In this setup, the subscription service would listen on a port opened in the firewall only for the second server (or the servers could connect via a VPN using a private address). On the second server (assuming Nginx is used), you’d expect to set up a location block with proxy_pass, right? Wrong.

The subscription link looks like this: https://<subscription host>:<subscription port>/<uri>/<subscription>
When 3x-ui generates the link, it uses the Listen Domain, Listen Port, or Reverse Proxy URI. The solution seems obvious: set the Reverse Proxy URI, and… here’s where things start getting strange.
The subscription service returns a list of URLs with parameters for connecting to inbounds. For example, in the case of VLESS, the format is as follows: vless://<someuuid>@<address>:<subscription port>?parameters

The issue is that if the Listen Domain is not set, the subscription service substitutes the IP address from the X-Real-IP header in place of the connection address. As a result, the URL ends up looking like this: vless://<someuuid>@<client IP address>:<subscription port>?parameters
This, of course, breaks everything.
I found two solutions to this issue. Initially, I didn’t want to create a domain for 3x-ui, so I simply hardcoded the X-Real-IP in the Nginx configuration like this:

location ~ ^(/<subscription_location>/|/<subscription_json_location>/) {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP <3x-ui public ip address>;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://<3x-ui public/vpn ip address>:<filtered port>;
}

The second solution is to create a domain that points to the Xray public IP, set it as the Listen Domain, and configure Nginx as follows:

location ~ ^(/<subscription_location>/|/<subscription_json_location>/) {
proxy_set_header Host 3x-ui.domain.name; #Same as configured in Listen Domain
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://<3x-ui public/private ip address>:<filtered port>;
}
If the Xray public IP is different from the 3x-ui address, the Host header and proxy_pass should be adjusted accordingly. However, if they are the same, and the subscription service is listening on an address filtered by the firewall, the configuration will follow a standard setup for reverse proxying a location, like this:
location ~ ^(/<subscription_location>/|/<subscription_json_location>/) {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://<3x-ui public address>:<filtered port>;
}

Below is an example screenshot of the subscription configuration:

Simple OpenVPN profile generator

Few month ago i learned that OpenVPN support profiles. Before that i generate config for every client, create keys and certs with easy-rsa, tar it’s all together and put on client. Now i can create profile that will contain all necessary keys, certs and config in one file, so i write simple script that generate .ovpn profile for new client.
Generated .ovpn profile can be imported from sd card in Android, via iTunes or email in iOS, or just type `openvpn your_new_profile.ovpn` at PC.
Prerequisites: configured easy-rsa (`pkitool clientname` must produce cert and key for client).
You must customize config part for your server, it is possible to fetch data from server config file, but i’m too lazy to modify script for it.
There is it:

#!/bin/bash
#Dir where easy-rsa is placed
EASY_RSA_DIR="/etc/ssl/easy-rsa"
KEYS_DIR="$EASY_RSA_DIR/keys"
# Dir where profiles will be placed
OVPN_PATH="/root/ovpn"
REMOTE="your.server port"
 
 
if [ -z "$1" ]
then 
        echo -n "Enter new client common name (CN): "
        read -e CN
else
        CN=$1
fi
 
 
if [ -z "$CN" ]
        then echo "You must provide a CN."
        exit
fi
 
cd $EASY_RSA_DIR
if [ -f $KEYS_DIR/$CN.crt ]
then 
        echo "Certificate with the CN $CN already exists!"
        echo " $KEYS_DIR/$CN.crt"
else
source ./vars > /dev/null
./pkitool $CN
fi
 
cat > $OVPN_PATH/${CN}.ovpn << END
client
dev tun
resolv-retry infinite
nobind
persist-key
persist-tun
verb 1
comp-lzo
proto tcp
remote $REMOTE
 
<ca>
`cat $KEYS_DIR/ca.crt`
</ca>
 
<cert>
`sed -n '/BEGIN/,$p' $KEYS_DIR/${CN}.crt`
</cert>
 
<key>
`cat $KEYS_DIR/${CN}.key`
</key>
END

Port based routing

After i came on new work i found that can not send email thru SMTPS, because port 465 closed on router. At this point i already had configured VPN access on my home router, so i think that it is good idea to route SMTPS traffic thru VPN, let’s start.

For this purposes i needed iproute2 and iptables. First i created new route table and add default route:

$ echo "1    VPN" >> /etc/iproute2/rt_tables
$ ip route add default via 192.168.107.5 src 192.168.107.6 dev tun_vpn table VPN
$ ip route show table VPN
default via 192.168.107.5 dev tun_vpn src 192.168.107.6

Where 192.168.107.5 – ip of my router into VPN and tun_vpn – VPN interface.
After that i created rule, that route marked packets thru VPN route table:

$ ip rule add from all fwmark 0x16 lookup VPN
$ ip  ru sh
0:      from all lookup local
32765:  from all fwmark 0x16 lookup VPN
32766:  from all lookup main
32767:  from all lookup default

There is time to mark SMTPS packets:

$ iptables -t mangle -I PREROUTING -p tcp --dport 465 -j MARK --set-mark 0x16
$ iptables -t mangle -I OUTPUT -p tcp --dport 465 -j MARK --set-mark 0x16

Let’s check:

$ traceroute -n -T -p 993 imap.gmail.com
traceroute to imap.gmail.com (173.194.69.109), 30 hops max, 60 byte packets
1  192.168.130.1  0.217 ms  0.233 ms  0.201 ms
2  *  2.318 ms  2.377 ms  2.503 ms
3  *  1.411 ms  1.714 ms  1.947 ms
4  *  1.486 ms  1.733 ms  1.796 ms
5  * 12.762 ms 72.14.212.22  12.791 ms  12.836 ms
6  * 65.528 ms  61.534 ms  67.431 ms
7  216.239.43.250  66.606 ms 209.85.248.132  61.808 ms 216.239.43.250  60.219 ms
8  216.239.48.53  66.225 ms 209.85.254.153  61.190 ms 64.233.174.55  66.038 ms
9  66.249.95.67  60.271 ms 66.249.95.175  60.510 ms  60.956 ms
10  64.233.174.55  65.304 ms  65.610 ms 64.233.174.29  76.697 ms
11  173.194.69.109  66.954 ms  65.824 ms  61.563 ms
$ traceroute -n -T -p 465 imap.gmail.com
traceroute to imap.gmail.com (173.194.69.108), 30 hops max, 60 byte packets
1  192.168.107.1  26.088 ms  42.767 ms  42.748 ms
2  *  42.813 ms  42.799 ms  68.297 ms
3  *  42.668 ms  42.665 ms  42.619 ms
4  * 42.539 ms  42.521 ms  42.504 ms
5  *  42.522 ms  68.071 ms  68.039 ms
6  *  68.015 ms  76.070 ms  85.085 ms
7  * 136.618 ms  136.634 ms  136.555 ms
8  216.239.43.250  110.732 ms  110.744 ms  110.712 ms
9  64.233.174.55  136.549 ms 209.85.254.153  136.506 ms  136.463 ms
10  * 66.249.95.67  137.978 ms 66.249.95.175  137.887 ms
11  173.194.69.108  137.893 ms 216.239.48.53  137.846 ms 64.233.174.55  130.177 ms

Profit!
PS
In my situation i observed strange effect, although that i set src ip, my host trying to send packets with src ip of local ethernet interface, so i just add masquerade rule into iptables.

$ iptables -A POSTROUTING -o tun_vpn -J MASQUERADE

Another way to fix it, set route to local work network on router, but i too lazy to do it.