From 26a3aef760a0b036104b67b8a848b6b13b244a78 Mon Sep 17 00:00:00 2001 From: orohimaru2 Date: Sun, 9 Aug 2026 19:36:52 +0300 Subject: [PATCH] Fix Xray crash from invalid xhttp headers arrays (v3.1.3). Use string headers only and auto-heal broken server.json on status check. Co-authored-by: Cursor --- app.py | 2 +- managers/xray_manager.py | 81 +++++++++++++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/app.py b/app.py index 709d290..88b448d 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.2" +CURRENT_VERSION = "v3.1.3" 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 a98b6b3..ab06ccc 100644 --- a/managers/xray_manager.py +++ b/managers/xray_manager.py @@ -160,6 +160,11 @@ class XrayManager: def get_server_status(self, protocol): exists = self.check_protocol_installed() + if exists: + try: + self._heal_invalid_xhttp_headers() + except Exception as e: + logger.warning(f"Xray config heal skipped: {e}") running = self.check_container_running() clients = self.get_clients() if exists else [] meta = self._get_meta_json() if exists else {} @@ -171,8 +176,59 @@ class XrayManager: 'domain': meta.get('domain') or meta.get('site_name'), 'transport': meta.get('transport') or 'xhttp', 'security': meta.get('security') or 'tls', + 'acme_method': meta.get('acme_method'), } + def _normalize_xhttp_headers_in_config(self, config): + """Xray xhttp headers must be map[string]string — arrays crash the process.""" + changed = False + for inbound in (config.get('inbounds') or []): + stream = inbound.get('streamSettings') or {} + for key in ('xhttpSettings', 'splithttpSettings'): + xs = stream.get(key) + if not isinstance(xs, dict): + continue + headers = xs.get('headers') + if headers is None: + continue + if not isinstance(headers, dict): + xs.pop('headers', None) + changed = True + continue + fixed = {} + for hk, hv in headers.items(): + if isinstance(hv, str): + fixed[hk] = hv + continue + changed = True + if isinstance(hv, list) and hv: + fixed[hk] = str(hv[0]) + elif hv is not None and not isinstance(hv, (dict, list)): + fixed[hk] = str(hv) + if fixed: + xs['headers'] = fixed + else: + 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).""" + config = self._get_server_json() + if not config or not self._normalize_xhttp_headers_in_config(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.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)") + return True + def _validate_domain(self, domain): domain = (domain or '').strip().lower().rstrip('.') if not domain or not re.match(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$', domain): @@ -425,13 +481,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ] "xhttpSettings": { "path": xhttp_path, "host": domain, - "mode": xhttp_mode, - "headers": { - "User-Agent": [ - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - ] - } + "mode": xhttp_mode }, "sockopt": { "tcpFastOpen": True, @@ -546,18 +596,21 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ] return self._write_server_json(data, restart=True) def _write_server_json(self, data, restart=True): - """Write server.json into container via docker cp AND sync to host path.""" + """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") tmp_file = "/tmp/_xray_server.json" + path = self._config_path() self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file) + # Host path first — volume mount survives crash loops + self.ssh.run_sudo_command(f"cp {tmp_file} {path}") self.ssh.run_sudo_command( - f"docker cp {tmp_file} {self.container_name}:{self._config_path()}" - ) - # Also keep host copy in sync (handles both volume-mount and no-mount installs) - self.ssh.run_sudo_command( - f"cp {tmp_file} {self._config_path()} 2>/dev/null || true" + f"docker cp {tmp_file} {self.container_name}:{path} 2>/dev/null || true" ) if restart: - self.ssh.run_sudo_command(f"docker restart {self.container_name}") + self.ssh.run_sudo_command( + f"docker restart {self.container_name} 2>/dev/null || docker start {self.container_name} 2>/dev/null || true" + ) def _get_vless_inbound(self, config): for inbound in config.get('inbounds', []):