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 <cursoragent@cursor.com>
This commit is contained in:
orohimaru2
2026-08-09 19:36:52 +03:00
co-authored by Cursor
parent d915b91f01
commit 26a3aef760
2 changed files with 68 additions and 15 deletions
+67 -14
View File
@@ -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', []):