Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,6 @@ services:
depends_on:
- xray-config # needs the generated config before it can start
ports: # public entry points (both TCP and UDP)
- "8080:8080/tcp"
- "8080:8080/udp"
- "443:443/tcp"
- "443:443/udp"
- "2083:2083/tcp" # vless-tcp-reality-direct (raw TCP, no nginx in front)
Expand Down Expand Up @@ -128,6 +126,8 @@ services:
env_file:
- env_file
ports: # public entry points (both TCP and UDP)
- "8080:8080/tcp" # vless-hu-* (non-TLS httpupgrade, VLESS encryption)
- "8080:8080/udp"
- "8880:8880/tcp"
- "8880:8880/udp"
- "2053:2053/tcp"
Expand Down
2 changes: 1 addition & 1 deletion env_file.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ XRAY_OUTBOUND=direct

# Inbounds to enable (comma-separated). Append :<count> for replicas via path-based
# routing, e.g. vless-hu-tls-cdn:3
XRAY_INBOUNDS=vless-tcp-tls-direct,vless-tcp-reality-direct,vless-hu-tls-direct,vless-xhttp-quic-direct
XRAY_INBOUNDS=vless-hu-direct,vless-tcp-tls-direct,vless-tcp-reality-direct,vless-hu-tls-direct,vless-xhttp-quic-direct

# TLS certificate authority: "letsencrypt" or "zerossl".
SSL_PROVIDER=letsencrypt
Expand Down
2 changes: 1 addition & 1 deletion nginx/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ else
fi

# Write per-port replica location blocks.
for port in 2053 8880 8443; do
for port in 2053 8880 8443 8080; do
mkdir -p "/etc/nginx/locations.d/$port"
printf '%s\n' "$(printf '%s' "$LOCATIONS" | jq -r ".\"$port\" // empty")" \
> "/etc/nginx/locations.d/$port/replicas.conf"
Expand Down
75 changes: 75 additions & 0 deletions nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,81 @@ http {
}
}

# Vless-HU (non-TLS, VLESS-encryption) Handling. (Both direct and CDN)
# No TLS here: confidentiality comes from VLESS Encryption inside the
# httpupgrade stream, so this block needs no cert and is always present.
# CF fronts the CDN variant over plain HTTP on port 8080.
server {
listen 8080;
listen [::]:8080;
http2 on;

# Block sensitive paths
if ($is_forbidden_path = 1) {
return 403;
}

# Forwards this path requests to xray (direct).
location = /${NGINX_PATH}/1 {
if ($http_upgrade != "websocket") {
return 404;
}
proxy_pass http://xray:8005;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
proxy_read_timeout 315;
proxy_socket_keepalive on;
}

# Forwards this path requests to xray (cdn).
location = /${NGINX_PATH}/cdn/1 {
if ($http_upgrade != "websocket") {
return 404;
}
proxy_pass http://xray:8055;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
proxy_read_timeout 315;
proxy_socket_keepalive on;
}

include /etc/nginx/locations.d/8080/replicas.conf;

# Fallback all other requests to an external website.
location / {
sub_filter $proxy_host $host;
sub_filter_once off;
set $website ${FAKE_WEBSITE};
proxy_pass https://$website;

proxy_set_header Host $proxy_host;
proxy_http_version 1.1;
proxy_cache_bypass $http_upgrade;
proxy_ssl_server_name on;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Forwarded $proxy_add_forwarded;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}

# TLS server blocks (ports 2053 and 8443) are rendered into this file by
# the entrypoint only when a Cloudflare subdomain and certificate are
# available. The file is always present (empty for non-CF deployments) so
Expand Down
98 changes: 88 additions & 10 deletions xray-config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import requests

from shared_lib.network import get_public_ip
from shared_lib.xray import register_warp, generate_vmess_link
from shared_lib.xray import register_warp
from shared_lib.config import load_env, get_identifier
from shared_lib.system import exec_command
from shared_lib.logger import log, is_debug
Expand All @@ -30,10 +30,12 @@


# Inbounds that bind a port directly (no HTTP path) — replicas not supported
_NO_REPLICA_SUPPORT = {"vless-tcp-tls-direct", "vmess-ws-cdn", "vless-tcp-reality-direct"}
_NO_REPLICA_SUPPORT = {"vless-tcp-tls-direct", "vless-tcp-reality-direct"}

# Maps inbound name → (nginx server port, location template)
_NGINX_PROXY_MAP: Dict[str, tuple] = {
"vless-hu-direct": (8080, "hu"),
"vless-hu-cdn": (8080, "hu"),
"vless-hu-tls-direct": (2053, "hu"),
"vless-hu-tls-cdn": (2053, "hu"),
"vless-xhttp-direct": (8880, "xhttp"),
Expand Down Expand Up @@ -166,6 +168,13 @@ def __init__(self) -> None:
self.reality_public_key: str = ""
self.reality_short_id: str = ""
self.reality_sni: str = ""
# VLESS Encryption (post-quantum AEAD) for the non-TLS httpupgrade
# inbounds: derived deterministically like REALITY so links survive
# redeploys. Empty until _setup_vless_encryption populates them.
self.vless_enc_private_key: str = ""
self.vless_enc_password: str = ""
self.vless_enc_decryption: str = ""
self.vless_enc_encryption: str = ""
self.configured_inbounds: List[Dict[str, Any]] = []
self.xray_config: Dict[str, Any] = {}
self.warps_ready: bool = False
Expand Down Expand Up @@ -307,6 +316,58 @@ def _setup_reality(self) -> None:
f"{self.config_id}:reality-sid".encode()
).hexdigest()[:8]

def _setup_vless_encryption(self) -> None:
"""Derive the VLESS Encryption keypair deterministically from the
identifier (same idea as REALITY) so the non-TLS httpupgrade links
survive redeploys without persisting any key material.

Authentication uses X25519 (the ephemeral exchange is ML-KEM-768 +
X25519, so it stays post-quantum safe either way). The server keeps the
private key in `decryption`; clients carry the matching public key
("Password") in `encryption`. Format per Xray-core:
decryption: mlkem768x25519plus.xorpub.600s.<padding>.<PrivateKey>
encryption: mlkem768x25519plus.xorpub.0rtt.<padding>.<Password>
xorpub masks the handshake public key (cheap obfs against DPI); the
default padding hides the handshake length. Client uses 0rtt for fast,
battery-friendly reconnects.
"""
seed = hashlib.sha256(f"{self.config_id}:vless-enc".encode()).digest()
seed_b64 = base64.urlsafe_b64encode(seed).decode().rstrip("=")
result = exec_command(["xray", "x25519", "-i", seed_b64], capture_output=True)
if result.returncode != 0 or not result.stdout:
log.error(
"xray x25519 failed; VLESS-encryption inbounds will be skipped",
hypothesisId="CFG",
exit_code=result.returncode,
)
return
# v26 prints "PrivateKey:" / "Password (PublicKey):"; older builds
# printed "Private key:" / "Public key:" — accept both.
for line in result.stdout.splitlines():
label, _, value = line.partition(":")
label = label.strip().lower()
value = value.strip()
if label in ("privatekey", "private key"):
self.vless_enc_private_key = value
elif label.startswith("password") or label == "public key":
self.vless_enc_password = value
if not self.vless_enc_private_key or not self.vless_enc_password:
self.vless_enc_private_key = self.vless_enc_password = ""
log.error(
"Could not parse xray x25519 output; VLESS-encryption inbounds will be skipped",
hypothesisId="CFG",
)
return
# xorpub obfuscation + the stock padding profile (shared by everyone,
# so it blends into the largest crowd rather than standing out).
_padding = "100-111-1111.75-0-111.50-0-3333"
self.vless_enc_decryption = (
f"mlkem768x25519plus.xorpub.600s.{_padding}.{self.vless_enc_private_key}"
)
self.vless_enc_encryption = (
f"mlkem768x25519plus.xorpub.0rtt.{_padding}.{self.vless_enc_password}"
)

def initialize(self) -> None:
"""Perform all initialization logic. Only executes once."""
if self.initialized:
Expand All @@ -332,7 +393,7 @@ def initialize(self) -> None:
self.xray_inbounds = {}
for entry in self.env_config.get(
"XRAY_INBOUNDS",
"vmess-ws-cdn,vless-tcp-tls-direct,vless-tcp-reality-direct,vless-hu-tls-direct,vless-hu-tls-cdn,vless-xhttp-quic-direct,vless-xhttp-quic-cdn,vless-xhttp-direct,vless-xhttp-cdn",
"vless-hu-direct,vless-hu-cdn,vless-tcp-tls-direct,vless-tcp-reality-direct,vless-hu-tls-direct,vless-hu-tls-cdn,vless-xhttp-quic-direct,vless-xhttp-quic-cdn,vless-xhttp-direct,vless-xhttp-cdn",
).split(","):
name, _, count = entry.strip().partition(":")
self.xray_inbounds[name] = int(count) if count.isdigit() else 1
Expand Down Expand Up @@ -436,6 +497,7 @@ def run_acme(cmd_args: str) -> bool:
# X25519 site reachable from this server.
self.reality_sni = self.env_config.get("FAKE_WEBSITE", "www.divar.ir")
self._setup_reality()
self._setup_vless_encryption()

# Process Inbounds Template
def substitute_vars(data: Any, mapping: Dict[str, Any]) -> Any:
Expand Down Expand Up @@ -464,15 +526,17 @@ def substitute_vars(data: Any, mapping: Dict[str, Any]) -> Any:
"reality_public_key": self.reality_public_key,
"reality_short_id": self.reality_short_id,
"reality_sni": self.reality_sni,
"vless_enc_decryption": self.vless_enc_decryption,
"vless_enc_encryption": self.vless_enc_encryption,
},
)
self.configured_inbounds = [
ib for ib in all_inbounds if ib.get("name") in self.xray_inbounds
]

# Expand replicas (before vmess link encoding so dict links are still patchable).
# Indices start at 1: replica 1 keeps the original port (matched by static
# nginx location blocks), replicas 2+ get ports from the 9000+ pool.
# Expand replicas. Indices start at 1: replica 1 keeps the original
# port (matched by static nginx location blocks), replicas 2+ get
# ports from the 9000+ pool.
expanded: List[Dict[str, Any]] = []
_replica_port = 9000
for ib in self.configured_inbounds:
Expand All @@ -497,10 +561,6 @@ def substitute_vars(data: Any, mapping: Dict[str, Any]) -> Any:
log.info(f"{name}: {count} replicas configured", hypothesisId="CFG")
self.configured_inbounds = expanded

for inbound in self.configured_inbounds:
if isinstance(inbound.get("link"), dict):
inbound["link"] = generate_vmess_link(inbound["link"])

# Drop any inbound whose TLS cert/key resolved to an empty string after
# variable substitution. An empty cert would make `xray -test` fail and
# bring down the whole container.
Expand Down Expand Up @@ -566,6 +626,24 @@ def _has_valid_tls(inbound_def: Dict[str, Any]) -> bool:
hypothesisId="CFG",
)

# Same idea for VLESS Encryption: an empty decryption string would fail
# `xray -test` and bring down the whole container.
if not self.vless_enc_decryption:
before = len(self.configured_inbounds)
self.configured_inbounds = [
ib for ib in self.configured_inbounds
if not ib.get("inbound", {})
.get("settings", {})
.get("decryption", "")
.startswith("mlkem768x25519plus")
]
skipped = before - len(self.configured_inbounds)
if skipped:
log.warning(
f"Skipped {skipped} VLESS-encryption inbound(s): key generation failed",
hypothesisId="CFG",
)

# Build nginx location blocks for replicas that survived TLS filtering
nginx_locs: Dict[int, List[str]] = {}
for ib in self.configured_inbounds:
Expand Down
68 changes: 43 additions & 25 deletions xray-config/inbounds.json
Original file line number Diff line number Diff line change
@@ -1,41 +1,59 @@
[
{
"name": "vmess-ws-cdn",
"link": {
"add": "$cf_clean_ip_domain",
"aid": "0",
"host": "$subdomain",
"id": "$config_id",
"net": "ws",
"path": "/$nginx_path/cdn",
"port": "8080",
"ps": "vmess-ws-cdn",
"scy": "auto",
"sni": "",
"tls": "",
"type": "",
"v": "2"
},
"cloudflare": true,
"name": "vless-hu-direct",
"link": "vless://$config_id@$direct_subdomain:8080?type=httpupgrade&host=$direct_subdomain&path=%2F$nginx_path&security=none&encryption=$vless_enc_encryption#vless-hu-direct",
"cloudflare": false,
"inbound": {
"tag": "vmess-ws-cdn",
"tag": "vless-hu-direct",
"listen": "0.0.0.0",
"port": 8080,
"protocol": "vmess",
"port": 8005,
"protocol": "vless",
"settings": {
"clients": [
{
"flow": "",
"id": "$config_id"
}
],
"decryption": "$vless_enc_decryption"
},
"streamSettings": {
"network": "httpupgrade",
"httpupgradeSettings": {
"path": "/$nginx_path"
}
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
},
{
"name": "vless-hu-cdn",
"link": "vless://$config_id@$cf_clean_ip_domain:8080?type=httpupgrade&host=$subdomain&path=%2F$nginx_path%2Fcdn&security=none&encryption=$vless_enc_encryption#vless-hu-cdn",
"cloudflare": true,
"inbound": {
"tag": "vless-hu-cdn",
"listen": "0.0.0.0",
"port": 8055,
"protocol": "vless",
"settings": {
"clients": [
{
"id": "$config_id"
}
],
"decryption": "$vless_enc_decryption"
},
"streamSettings": {
"network": "ws",
"wsSettings": {
"network": "httpupgrade",
"httpupgradeSettings": {
"path": "/$nginx_path/cdn"
},
"security": "none"
}
},
"sniffing": {
"enabled": true,
Expand Down