Template
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:
@@ -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'))
|
||||
|
||||
+67
-14
@@ -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', []):
|
||||
|
||||
Reference in New Issue
Block a user