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__)
|
application_path = os.path.dirname(__file__)
|
||||||
|
|
||||||
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
|
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_REPO_URL = repo_url()
|
||||||
RELEASES_API_LATEST = api_latest_url()
|
RELEASES_API_LATEST = api_latest_url()
|
||||||
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
|
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):
|
def get_server_status(self, protocol):
|
||||||
exists = self.check_protocol_installed()
|
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()
|
running = self.check_container_running()
|
||||||
clients = self.get_clients() if exists else []
|
clients = self.get_clients() if exists else []
|
||||||
meta = self._get_meta_json() 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'),
|
'domain': meta.get('domain') or meta.get('site_name'),
|
||||||
'transport': meta.get('transport') or 'xhttp',
|
'transport': meta.get('transport') or 'xhttp',
|
||||||
'security': meta.get('security') or 'tls',
|
'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):
|
def _validate_domain(self, domain):
|
||||||
domain = (domain or '').strip().lower().rstrip('.')
|
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):
|
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": {
|
"xhttpSettings": {
|
||||||
"path": xhttp_path,
|
"path": xhttp_path,
|
||||||
"host": domain,
|
"host": domain,
|
||||||
"mode": xhttp_mode,
|
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"sockopt": {
|
"sockopt": {
|
||||||
"tcpFastOpen": True,
|
"tcpFastOpen": True,
|
||||||
@@ -546,18 +596,21 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
|||||||
return self._write_server_json(data, restart=True)
|
return self._write_server_json(data, restart=True)
|
||||||
|
|
||||||
def _write_server_json(self, 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"
|
tmp_file = "/tmp/_xray_server.json"
|
||||||
|
path = self._config_path()
|
||||||
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
|
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(
|
self.ssh.run_sudo_command(
|
||||||
f"docker cp {tmp_file} {self.container_name}:{self._config_path()}"
|
f"docker cp {tmp_file} {self.container_name}:{path} 2>/dev/null || true"
|
||||||
)
|
|
||||||
# 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"
|
|
||||||
)
|
)
|
||||||
if restart:
|
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):
|
def _get_vless_inbound(self, config):
|
||||||
for inbound in config.get('inbounds', []):
|
for inbound in config.get('inbounds', []):
|
||||||
|
|||||||
Reference in New Issue
Block a user