diff --git a/app.py b/app.py index 88b448d..ec60649 100644 --- a/app.py +++ b/app.py @@ -103,7 +103,7 @@ else: application_path = os.path.dirname(__file__) 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_API_LATEST = api_latest_url() BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) diff --git a/managers/xray_manager.py b/managers/xray_manager.py index ab06ccc..64ab8bc 100644 --- a/managers/xray_manager.py +++ b/managers/xray_manager.py @@ -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}" )