Template
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:
+107
-28
@@ -162,7 +162,7 @@ class XrayManager:
|
||||
exists = self.check_protocol_installed()
|
||||
if exists:
|
||||
try:
|
||||
self._heal_invalid_xhttp_headers()
|
||||
self._heal_xhttp_config()
|
||||
except Exception as e:
|
||||
logger.warning(f"Xray config heal skipped: {e}")
|
||||
running = self.check_container_running()
|
||||
@@ -177,6 +177,7 @@ class XrayManager:
|
||||
'transport': meta.get('transport') or 'xhttp',
|
||||
'security': meta.get('security') or 'tls',
|
||||
'acme_method': meta.get('acme_method'),
|
||||
'path': meta.get('path'),
|
||||
}
|
||||
|
||||
def _normalize_xhttp_headers_in_config(self, config):
|
||||
@@ -211,22 +212,95 @@ class XrayManager:
|
||||
xs.pop('headers', None)
|
||||
return changed
|
||||
|
||||
def _heal_invalid_xhttp_headers(self):
|
||||
"""Fix crash-loop configs written with array header values (pre-v3.1.3)."""
|
||||
def _normalize_xhttp_stream_for_compat(self, config):
|
||||
"""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()
|
||||
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
|
||||
path = self._config_path()
|
||||
payload = json.dumps(config, indent=2)
|
||||
self.ssh.upload_file_sudo(payload, path)
|
||||
# Best-effort sync into running/stopped container filesystem
|
||||
self.ssh.upload_file_sudo(json.dumps(config, indent=2), path)
|
||||
self.ssh.run_sudo_command(
|
||||
f"docker cp {_q(path)} {self.container_name}:{path} 2>/dev/null || true"
|
||||
)
|
||||
self.ssh.run_sudo_command(
|
||||
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
|
||||
|
||||
def _validate_domain(self, domain):
|
||||
@@ -254,8 +328,8 @@ class XrayManager:
|
||||
return f'{self._certs_dir()}/privkey.pem'
|
||||
|
||||
def _random_xhttp_path(self):
|
||||
# Looks like a static/CDN asset path — harder for simple DPI signatures.
|
||||
return f'/assets/{secrets.token_hex(8)}/{secrets.token_hex(4)}.js'
|
||||
# Short opaque path — better client compatibility than nested “asset” URLs.
|
||||
return f'/{secrets.token_hex(8)}'
|
||||
|
||||
def _cf_creds_path(self):
|
||||
return f'{self._config_dir()}/cloudflare.ini'
|
||||
@@ -470,8 +544,6 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||
"network": "xhttp",
|
||||
"security": "tls",
|
||||
"tlsSettings": {
|
||||
"serverName": domain,
|
||||
"minVersion": "1.3",
|
||||
"alpn": ["h2", "http/1.1"],
|
||||
"certificates": [{
|
||||
"certificateFile": self._cert_path(),
|
||||
@@ -480,12 +552,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||
},
|
||||
"xhttpSettings": {
|
||||
"path": xhttp_path,
|
||||
"host": domain,
|
||||
"mode": xhttp_mode
|
||||
},
|
||||
"sockopt": {
|
||||
"tcpFastOpen": True,
|
||||
"tcpNoDelay": True
|
||||
}
|
||||
},
|
||||
"sniffing": {
|
||||
@@ -535,7 +602,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||
'mode': xhttp_mode,
|
||||
'port': int(port),
|
||||
'fingerprint': 'chrome',
|
||||
'alpn': 'h2',
|
||||
'alpn': 'h2,http/1.1',
|
||||
'site_name': domain,
|
||||
'acme_method': used,
|
||||
}
|
||||
@@ -597,8 +664,8 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||
|
||||
def _write_server_json(self, data, restart=True):
|
||||
"""Write server.json to host path and into container when possible."""
|
||||
if self._normalize_xhttp_headers_in_config(data):
|
||||
logger.info("Normalized invalid xhttpSettings.headers before write")
|
||||
if self._normalize_xhttp_stream_for_compat(data):
|
||||
logger.info("Normalized XHTTP+TLS stream settings before write")
|
||||
tmp_file = "/tmp/_xray_server.json"
|
||||
path = self._config_path()
|
||||
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['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
|
||||
|
||||
# Legacy Reality
|
||||
@@ -956,22 +1027,30 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||
listen_port = meta.get('port', port)
|
||||
|
||||
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 '/'
|
||||
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'
|
||||
if str(mode).lower() in ('auto', ''):
|
||||
mode = 'packet-up'
|
||||
fp = meta.get('fingerprint') or 'chrome'
|
||||
alpn = meta.get('alpn') or 'h2'
|
||||
# Prefer domain for TLS SNI / cert match; fall back to connect host.
|
||||
host = domain or server_host
|
||||
alpn = meta.get('alpn') or 'h2,http/1.1'
|
||||
if isinstance(alpn, list):
|
||||
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 (
|
||||
f"vless://{client_id}@{host}:{listen_port}"
|
||||
f"vless://{client_id}@{dial_host}:{listen_port}"
|
||||
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"&host={urllib.parse.quote(domain, safe='')}"
|
||||
f"&sni={urllib.parse.quote(domain, 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}"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user