From 2de55c29ce9c0c7f1d5fff368429c329295ad199 Mon Sep 17 00:00:00 2001 From: Ran The <197520740+ranthe21@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:04:53 +0300 Subject: [PATCH 1/2] swap vmess for vless httpupgrade (non-tls) with encryption Drop the vmess-ws-cdn inbound and replace it with a VLESS httpupgrade pair carried over plain HTTP, fronted by nginx like the existing hu-tls inbounds. Confidentiality comes from VLESS Encryption (post-quantum ML-KEM-768 + X25519 AEAD) inside the stream instead of TLS, so no cert is needed. - inbounds.json: vless-hu-direct (xray:8005) and vless-hu-cdn (xray:8055), decryption set to the mlkem768x25519plus string; links carry the matching encryption= value. - config.py: derive the X25519 keypair deterministically from the identifier (same as REALITY) so links survive redeploys; guard that drops the inbounds if key gen fails, mirroring the REALITY/TLS guards. - nginx: plaintext :8080 server block (both paths), replica include for 8080; CF fronts the CDN variant over HTTP on 8080. - docker-compose: move the 8080 publish from xray to nginx. Firewall port list is unchanged (8080 was already open for vmess). --- docker-compose.yml | 4 +- env_file.example | 2 +- nginx/entrypoint.sh | 2 +- nginx/nginx.conf | 75 +++++++++++++++++++++++++++++++ xray-config/config.py | 92 ++++++++++++++++++++++++++++++++++----- xray-config/inbounds.json | 68 ++++++++++++++++++----------- 6 files changed, 204 insertions(+), 39 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7f94f8f..744b618 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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) @@ -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" diff --git a/env_file.example b/env_file.example index 6eabde0..2e49f5a 100644 --- a/env_file.example +++ b/env_file.example @@ -24,7 +24,7 @@ XRAY_OUTBOUND=direct # Inbounds to enable (comma-separated). Append : 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 diff --git a/nginx/entrypoint.sh b/nginx/entrypoint.sh index 64955b2..fd7ef2a 100755 --- a/nginx/entrypoint.sh +++ b/nginx/entrypoint.sh @@ -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" diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 8483ec1..461c679 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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 diff --git a/xray-config/config.py b/xray-config/config.py index 885032c..cfc85cc 100644 --- a/xray-config/config.py +++ b/xray-config/config.py @@ -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 @@ -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"), @@ -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 @@ -307,6 +316,52 @@ 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.native.600s. + encryption: mlkem768x25519plus.native.0rtt. + """ + 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 + self.vless_enc_decryption = ( + f"mlkem768x25519plus.native.600s.{self.vless_enc_private_key}" + ) + self.vless_enc_encryption = ( + f"mlkem768x25519plus.native.0rtt.{self.vless_enc_password}" + ) + def initialize(self) -> None: """Perform all initialization logic. Only executes once.""" if self.initialized: @@ -332,7 +387,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 @@ -436,6 +491,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: @@ -464,15 +520,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: @@ -497,10 +555,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. @@ -566,6 +620,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: diff --git a/xray-config/inbounds.json b/xray-config/inbounds.json index 47dc429..b20fa29 100644 --- a/xray-config/inbounds.json +++ b/xray-config/inbounds.json @@ -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, From 1a55392cd3eba5992570bab3f574cd5babf85e74 Mon Sep 17 00:00:00 2001 From: Ran The <197520740+ranthe21@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:29:33 +0300 Subject: [PATCH 2/2] use xorpub + default padding for vless encryption xorpub masks the handshake public key against DPI at near-zero cost; stock padding hides handshake length while keeping us in the common crowd. Keeps X25519 auth + 0rtt for fast, battery-friendly reconnects. --- xray-config/config.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/xray-config/config.py b/xray-config/config.py index cfc85cc..1ff19cf 100644 --- a/xray-config/config.py +++ b/xray-config/config.py @@ -325,8 +325,11 @@ def _setup_vless_encryption(self) -> None: 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.native.600s. - encryption: mlkem768x25519plus.native.0rtt. + decryption: mlkem768x25519plus.xorpub.600s.. + encryption: mlkem768x25519plus.xorpub.0rtt.. + 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("=") @@ -355,11 +358,14 @@ def _setup_vless_encryption(self) -> None: 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.native.600s.{self.vless_enc_private_key}" + f"mlkem768x25519plus.xorpub.600s.{_padding}.{self.vless_enc_private_key}" ) self.vless_enc_encryption = ( - f"mlkem768x25519plus.native.0rtt.{self.vless_enc_password}" + f"mlkem768x25519plus.xorpub.0rtt.{_padding}.{self.vless_enc_password}" ) def initialize(self) -> None: