feat: validate xray config before every restart, local + managed nodes
Runs 'xray run -test' against the candidate config before writing it and restarting the service (local node, and over SSH for managed nodes) — a bad config now fails loudly with the panel/bot call raising an error instead of xray crash-looping in production. Also checks TLS certificate/key file permissions against the actual xray service user (nobody:nogroup) before accepting a config — this is the exact class of bug that caused yesterday's WS+TLS outage (cert readable by root but not by nobody). Verified live against the real shayba server: replaying that exact bad config now gets rejected with 'cert permission problem(s): ... keyFile=... not readable' instead of being written and restarted. Managed-node restarts now also check systemctl's own exit status instead of discarding it, so a restart failure surfaces as an error too, not just silently swallowed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f9ee4f5933
commit
abdf18faae
2 changed files with 86 additions and 6 deletions
29
nodeprov.py
29
nodeprov.py
|
|
@ -250,6 +250,10 @@ def _mgmt_connect(address: str, ssh_port: int = 22) -> paramiko.SSHClient:
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteConfigError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _remote_edit_clients(node: dict, mutate_fn):
|
def _remote_edit_clients(node: dict, mutate_fn):
|
||||||
client = _mgmt_connect(node["address"])
|
client = _mgmt_connect(node["address"])
|
||||||
try:
|
try:
|
||||||
|
|
@ -265,14 +269,27 @@ def _remote_edit_clients(node: dict, mutate_fn):
|
||||||
if new_clients is not None:
|
if new_clients is not None:
|
||||||
ib["settings"]["clients"] = new_clients
|
ib["settings"]["clients"] = new_clients
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if not changed:
|
||||||
data = json.dumps(cfg, indent=2).encode()
|
|
||||||
with sftp.open("/usr/local/etc/xray/config.json", "wb") as f:
|
|
||||||
f.write(data)
|
|
||||||
sftp.close()
|
sftp.close()
|
||||||
client.exec_command("systemctl restart xray")[1].channel.recv_exit_status()
|
return
|
||||||
else:
|
data = json.dumps(cfg, indent=2).encode()
|
||||||
|
tmp_path = "/usr/local/etc/xray/config.json.validate.tmp"
|
||||||
|
with sftp.open(tmp_path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
_, stdout, stderr = client.exec_command(f"/usr/local/bin/xray run -test -format=json -config {tmp_path}", timeout=15)
|
||||||
|
test_exit = stdout.channel.recv_exit_status()
|
||||||
|
test_out = (stdout.read().decode(errors="replace") + stderr.read().decode(errors="replace")).strip()
|
||||||
|
if test_exit != 0:
|
||||||
|
client.exec_command(f"rm -f {tmp_path}")
|
||||||
sftp.close()
|
sftp.close()
|
||||||
|
raise RemoteConfigError(f"config test failed on {node['address']}: {test_out}")
|
||||||
|
client.exec_command(f"mv {tmp_path} /usr/local/etc/xray/config.json")[1].channel.recv_exit_status()
|
||||||
|
sftp.close()
|
||||||
|
_, stdout, stderr = client.exec_command("systemctl restart xray", timeout=20)
|
||||||
|
restart_exit = stdout.channel.recv_exit_status()
|
||||||
|
if restart_exit != 0:
|
||||||
|
err = stderr.read().decode(errors="replace").strip()
|
||||||
|
raise RemoteConfigError(f"xray restart failed on {node['address']}: {err}")
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,70 @@ def _load():
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigValidationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _readable_by(path, uid, gid) -> bool:
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
mode = st.st_mode
|
||||||
|
if st.st_uid == uid and mode & 0o400:
|
||||||
|
return True
|
||||||
|
if st.st_gid == gid and mode & 0o040:
|
||||||
|
return True
|
||||||
|
if mode & 0o004:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_cert_permissions(cfg, uid=65534, gid=65534) -> list:
|
||||||
|
problems = []
|
||||||
|
for ib in cfg.get("inbounds", []):
|
||||||
|
stream = ib.get("streamSettings") or {}
|
||||||
|
tls = stream.get("tlsSettings")
|
||||||
|
if not tls:
|
||||||
|
continue
|
||||||
|
for cert in tls.get("certificates") or []:
|
||||||
|
for key in ("certificateFile", "keyFile"):
|
||||||
|
path = cert.get(key)
|
||||||
|
if path and not _readable_by(path, uid, gid):
|
||||||
|
problems.append(f"{ib.get('tag', '?')}: {key}={path} not readable by the xray service user")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(cfg, xray_bin="/usr/local/bin/xray") -> tuple:
|
||||||
|
tmp = XRAY_CONFIG_PATH + ".validate.tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[xray_bin, "run", "-test", "-format=json", "-config", tmp],
|
||||||
|
capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
ok = result.returncode == 0
|
||||||
|
detail = (result.stdout + result.stderr).strip()
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not ok:
|
||||||
|
return False, detail
|
||||||
|
perm_problems = check_cert_permissions(cfg)
|
||||||
|
if perm_problems:
|
||||||
|
return False, "cert permission problem(s): " + "; ".join(perm_problems)
|
||||||
|
return True, detail
|
||||||
|
|
||||||
|
|
||||||
def _save(cfg):
|
def _save(cfg):
|
||||||
|
ok, detail = validate_config(cfg)
|
||||||
|
if not ok:
|
||||||
|
raise ConfigValidationError(detail)
|
||||||
tmp = XRAY_CONFIG_PATH + ".tmp"
|
tmp = XRAY_CONFIG_PATH + ".tmp"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
json.dump(cfg, f, indent=2)
|
json.dump(cfg, f, indent=2)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue