Fix Xray XHTTP+TLS client share links and simplify server stream (v3.1.4).

Drop incompatible inbound options, heal existing configs, and emit packet-up links clients accept.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohimaru2
2026-08-09 19:50:58 +03:00
co-authored by Cursor
parent 26a3aef760
commit 9dd92a6a92
2 changed files with 108 additions and 29 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ else:
application_path = os.path.dirname(__file__) application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v3.1.3" CURRENT_VERSION = "v3.1.4"
RELEASES_REPO_URL = repo_url() RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url() RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
+107 -28
View File
@@ -162,7 +162,7 @@ class XrayManager:
exists = self.check_protocol_installed() exists = self.check_protocol_installed()
if exists: if exists:
try: try:
self._heal_invalid_xhttp_headers() self._heal_xhttp_config()
except Exception as e: except Exception as e:
logger.warning(f"Xray config heal skipped: {e}") logger.warning(f"Xray config heal skipped: {e}")
running = self.check_container_running() running = self.check_container_running()
@@ -177,6 +177,7 @@ class XrayManager:
'transport': meta.get('transport') or 'xhttp', 'transport': meta.get('transport') or 'xhttp',
'security': meta.get('security') or 'tls', 'security': meta.get('security') or 'tls',
'acme_method': meta.get('acme_method'), 'acme_method': meta.get('acme_method'),
'path': meta.get('path'),
} }
def _normalize_xhttp_headers_in_config(self, config): def _normalize_xhttp_headers_in_config(self, config):
@@ -211,22 +212,95 @@ class XrayManager:
xs.pop('headers', None) xs.pop('headers', None)
return changed return changed
def _heal_invalid_xhttp_headers(self): def _normalize_xhttp_stream_for_compat(self, config):
"""Fix crash-loop configs written with array header values (pre-v3.1.3).""" """Make inbound XHTTP+TLS closer to known-working minimal configs."""
changed = self._normalize_xhttp_headers_in_config(config)
for inbound in (config.get('inbounds') or []):
if inbound.get('protocol') != 'vless':
continue
stream = inbound.get('streamSettings') or {}
network = str(stream.get('network') or '').lower()
security = str(stream.get('security') or '').lower()
if network not in ('xhttp', 'splithttp') or security != 'tls':
continue
# Prefer canonical network name
if network == 'splithttp':
stream['network'] = 'xhttp'
changed = True
xs_key = 'xhttpSettings' if 'xhttpSettings' in stream else (
'splithttpSettings' if 'splithttpSettings' in stream else 'xhttpSettings'
)
xs = stream.get(xs_key)
if not isinstance(xs, dict):
xs = {}
stream[xs_key] = xs
changed = True
# Migrate splithttpSettings → xhttpSettings
if xs_key == 'splithttpSettings':
stream['xhttpSettings'] = xs
stream.pop('splithttpSettings', None)
changed = True
# Server-side host rejects mismatched Host headers on some clients
if 'host' in xs:
xs.pop('host', None)
changed = True
if 'headers' in xs:
xs.pop('headers', None)
changed = True
if not xs.get('path'):
xs['path'] = '/'
changed = True
if xs.get('mode') not in (None, '', 'auto', 'packet-up', 'stream-up', 'stream-one'):
xs['mode'] = 'auto'
changed = True
elif not xs.get('mode'):
xs['mode'] = 'auto'
changed = True
tls = stream.get('tlsSettings')
if not isinstance(tls, dict):
tls = {}
stream['tlsSettings'] = tls
changed = True
# minVersion / serverName are optional and can hurt older clients
if 'minVersion' in tls:
tls.pop('minVersion', None)
changed = True
alpn = tls.get('alpn')
if not isinstance(alpn, list) or not alpn:
tls['alpn'] = ['h2', 'http/1.1']
changed = True
sockopt = stream.get('sockopt')
if isinstance(sockopt, dict):
# TCP Fast Open frequently breaks mobile/CGNAT paths
if sockopt.pop('tcpFastOpen', None) is not None:
changed = True
if not sockopt:
stream.pop('sockopt', None)
changed = True
inbound['streamSettings'] = stream
return changed
def _heal_xhttp_config(self):
"""Fix crash/compat issues in existing XHTTP+TLS server.json."""
config = self._get_server_json() config = self._get_server_json()
if not config or not self._normalize_xhttp_headers_in_config(config): if not config or not self._normalize_xhttp_stream_for_compat(config):
return False return False
path = self._config_path() path = self._config_path()
payload = json.dumps(config, indent=2) self.ssh.upload_file_sudo(json.dumps(config, indent=2), path)
self.ssh.upload_file_sudo(payload, path)
# Best-effort sync into running/stopped container filesystem
self.ssh.run_sudo_command( self.ssh.run_sudo_command(
f"docker cp {_q(path)} {self.container_name}:{path} 2>/dev/null || true" f"docker cp {_q(path)} {self.container_name}:{path} 2>/dev/null || true"
) )
self.ssh.run_sudo_command( self.ssh.run_sudo_command(
f"docker restart {self.container_name} 2>/dev/null || docker start {self.container_name} 2>/dev/null || true" f"docker restart {self.container_name} 2>/dev/null || docker start {self.container_name} 2>/dev/null || true"
) )
logger.info("Healed Xray xhttpSettings.headers (array → string / removed)") logger.info("Healed Xray XHTTP+TLS stream settings for client compatibility")
return True return True
def _validate_domain(self, domain): def _validate_domain(self, domain):
@@ -254,8 +328,8 @@ class XrayManager:
return f'{self._certs_dir()}/privkey.pem' return f'{self._certs_dir()}/privkey.pem'
def _random_xhttp_path(self): def _random_xhttp_path(self):
# Looks like a static/CDN asset path — harder for simple DPI signatures. # Short opaque path — better client compatibility than nested “asset” URLs.
return f'/assets/{secrets.token_hex(8)}/{secrets.token_hex(4)}.js' return f'/{secrets.token_hex(8)}'
def _cf_creds_path(self): def _cf_creds_path(self):
return f'{self._config_dir()}/cloudflare.ini' return f'{self._config_dir()}/cloudflare.ini'
@@ -470,8 +544,6 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
"network": "xhttp", "network": "xhttp",
"security": "tls", "security": "tls",
"tlsSettings": { "tlsSettings": {
"serverName": domain,
"minVersion": "1.3",
"alpn": ["h2", "http/1.1"], "alpn": ["h2", "http/1.1"],
"certificates": [{ "certificates": [{
"certificateFile": self._cert_path(), "certificateFile": self._cert_path(),
@@ -480,12 +552,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
}, },
"xhttpSettings": { "xhttpSettings": {
"path": xhttp_path, "path": xhttp_path,
"host": domain,
"mode": xhttp_mode "mode": xhttp_mode
},
"sockopt": {
"tcpFastOpen": True,
"tcpNoDelay": True
} }
}, },
"sniffing": { "sniffing": {
@@ -535,7 +602,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
'mode': xhttp_mode, 'mode': xhttp_mode,
'port': int(port), 'port': int(port),
'fingerprint': 'chrome', 'fingerprint': 'chrome',
'alpn': 'h2', 'alpn': 'h2,http/1.1',
'site_name': domain, 'site_name': domain,
'acme_method': used, 'acme_method': used,
} }
@@ -597,8 +664,8 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
def _write_server_json(self, data, restart=True): def _write_server_json(self, data, restart=True):
"""Write server.json to host path and into container when possible.""" """Write server.json to host path and into container when possible."""
if self._normalize_xhttp_headers_in_config(data): if self._normalize_xhttp_stream_for_compat(data):
logger.info("Normalized invalid xhttpSettings.headers before write") logger.info("Normalized XHTTP+TLS stream settings before write")
tmp_file = "/tmp/_xray_server.json" tmp_file = "/tmp/_xray_server.json"
path = self._config_path() path = self._config_path()
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file) self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
@@ -721,7 +788,11 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
) )
meta['site_name'] = meta.get('domain') or meta.get('site_name') meta['site_name'] = meta.get('domain') or meta.get('site_name')
meta['fingerprint'] = meta.get('fingerprint') or 'chrome' meta['fingerprint'] = meta.get('fingerprint') or 'chrome'
meta['alpn'] = meta.get('alpn') or 'h2' meta['alpn'] = meta.get('alpn') or 'h2,http/1.1'
# Prefer TLS ALPN from live server config when present
tls_alpn = tls.get('alpn')
if isinstance(tls_alpn, list) and tls_alpn:
meta['alpn'] = ','.join(str(x) for x in tls_alpn if x)
return meta return meta
# Legacy Reality # Legacy Reality
@@ -956,22 +1027,30 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
listen_port = meta.get('port', port) listen_port = meta.get('port', port)
if self._is_xhttp_tls_inbound(inbound) or (meta.get('transport') == 'xhttp' and meta.get('security') == 'tls'): if self._is_xhttp_tls_inbound(inbound) or (meta.get('transport') == 'xhttp' and meta.get('security') == 'tls'):
domain = meta.get('domain') or meta.get('site_name') or server_host domain = (meta.get('domain') or meta.get('site_name') or server_host or '').strip()
path = meta.get('path') or '/' path = meta.get('path') or '/'
if not str(path).startswith('/'):
path = '/' + str(path)
# TLS+XHTTP resolves auto → packet-up; pin it for older clients.
mode = meta.get('mode') or 'auto' mode = meta.get('mode') or 'auto'
if str(mode).lower() in ('auto', ''):
mode = 'packet-up'
fp = meta.get('fingerprint') or 'chrome' fp = meta.get('fingerprint') or 'chrome'
alpn = meta.get('alpn') or 'h2' alpn = meta.get('alpn') or 'h2,http/1.1'
# Prefer domain for TLS SNI / cert match; fall back to connect host. if isinstance(alpn, list):
host = domain or server_host alpn = ','.join(str(x) for x in alpn if x)
# Dial address: panel connect host (IP/domain). TLS identity: cert domain.
dial_host = (server_host or domain).strip()
path_q = urllib.parse.quote(str(path), safe='/')
return ( return (
f"vless://{client_id}@{host}:{listen_port}" f"vless://{client_id}@{dial_host}:{listen_port}"
f"?encryption=none&security=tls&type=xhttp" f"?encryption=none&security=tls&type=xhttp"
f"&path={urllib.parse.quote(path, safe='')}" f"&path={path_q}"
f"&mode={urllib.parse.quote(str(mode), safe='')}" f"&mode={urllib.parse.quote(str(mode), safe='')}"
f"&host={urllib.parse.quote(domain, safe='')}" f"&host={urllib.parse.quote(domain, safe='')}"
f"&sni={urllib.parse.quote(domain, safe='')}" f"&sni={urllib.parse.quote(domain, safe='')}"
f"&fp={urllib.parse.quote(fp, safe='')}" f"&fp={urllib.parse.quote(fp, safe='')}"
f"&alpn={urllib.parse.quote(alpn, safe='')}" f"&alpn={urllib.parse.quote(alpn, safe=',')}"
f"#{encoded_name}" f"#{encoded_name}"
) )