3 Commits

13 changed files with 288 additions and 25 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pyxray"
version = "1.0.0"
version = "1.0.2"
description = "A lightweight Linux xray control plane."
readme = "README.md"
requires-python = ">=3.14"
+38
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import re
import signal
import socket
import subprocess
@@ -218,6 +219,17 @@ def read_log_since(path: str | Path, offset: int) -> tuple[str, int]:
return _tail_lines(content, 1000), size
def compact_xray_log(content: str) -> str:
"""Convert verbose Xray routing logs into concise route decisions."""
entries: list[str] = []
for line in content.splitlines():
entry = _compact_log_line(line)
if entry:
entries.append(entry)
return "\n".join(entries)
def log_file_size(path: str | Path) -> int:
resolved = Path(path)
if not resolved.exists():
@@ -230,6 +242,32 @@ def _tail_lines(content: str, line_count: int) -> str:
return "\n".join(lines[-line_count:])
def _compact_log_line(line: str) -> str | None:
match = _DETOUR_RE.search(line)
if match:
target = _compact_target(match.group("target"))
return f"{_log_time(line)} {target} -> {match.group('outbound')}".strip()
if _IMPORTANT_LOG_RE.search(line):
return line.strip()
return None
def _compact_target(target: str) -> str:
if target.startswith(("tcp:", "udp:")):
return target.split(":", 1)[1]
return target
def _log_time(line: str) -> str:
match = _LOG_TIME_RE.match(line)
return match.group(1) if match else ""
_LOG_TIME_RE = re.compile(r"^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})")
_DETOUR_RE = re.compile(r"taking detour \[(?P<outbound>[^\]]+)\] for \[(?P<target>[^\]]+)\]")
_IMPORTANT_LOG_RE = re.compile(r"\[(Warning|Error)\]|\b(failed|error|timeout|denied)\b", re.IGNORECASE)
def _inbound_port_errors(inbound: dict) -> list[str]:
port = int(inbound.get("port") or 0)
if port <= 0:
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
import logging
from flask import Flask
logger = logging.getLogger("pyxray")
def log_activity(app: Flask, message: str) -> None:
"""Write an operator-facing activity message to stdout/stderr logging."""
logger.info(message)
+2
View File
@@ -31,6 +31,7 @@ def index(): # noqa: ANN202
manager = get_node_manager(current_app)
nodes = [node.to_dict() for node in manager.list_nodes()]
selected_id = manager.selected_id()
selected_node = manager.get_selected_node()
settings_store = get_settings_store(current_app)
settings = settings_store.load()
service_status = get_xray_service(current_app).status()
@@ -44,6 +45,7 @@ def index(): # noqa: ANN202
official_url=official_archive_url(form["version"] or DEFAULT_VERSION),
nodes=nodes,
selected_id=selected_id,
selected_name=selected_node.name if selected_node is not None else "",
settings=settings.to_dict(),
settings_toml=dump_settings_toml(settings),
config_path=current_app.config["XRAY_CONFIG_PATH"],
+13 -1
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from flask import Blueprint, Flask, current_app, jsonify, request
from pyxray.libs.nodes import NodeManager, NodeStore
from pyxray.web.activity_log import log_activity
blueprint = Blueprint("nodes", __name__, url_prefix="/api/nodes")
@@ -42,6 +43,9 @@ def import_nodes_api(): # noqa: ANN202
text = request.form.get("links", "").strip()
results = get_node_manager(current_app).import_links(text)
imported = sum(1 for item in results if item.node is not None)
failed = len(results) - imported
log_activity(current_app, f"Nodes imported: imported={imported} failed={failed}")
return jsonify(
{
"results": [
@@ -66,7 +70,14 @@ def select_node_api(): # noqa: ANN202
node = get_node_manager(current_app).select_node(node_id)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"node": node.to_dict()})
log_activity(current_app, f"Node selected: {node.name or node.id}")
try:
from pyxray.web.xray_service import restart_xray_service_if_running
restart_status = restart_xray_service_if_running(current_app, reason="node selected")
except Exception as exc: # noqa: BLE001
return jsonify({"error": str(exc), "node": node.to_dict()}), 400
return jsonify({"node": node.to_dict(), "service": restart_status})
@blueprint.delete("/<node_id>")
@@ -74,4 +85,5 @@ def delete_node_api(node_id: str): # noqa: ANN202
"""删除节点。"""
removed = get_node_manager(current_app).remove_node(node_id)
log_activity(current_app, f"Node deleted: {node_id} removed={removed}")
return jsonify({"removed": removed})
+29
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import atexit
import logging
import os
import signal
import socket
import sys
from pathlib import Path
@@ -39,6 +42,11 @@ def create_app(
def run_web(host: str, port: int, default_xray_dir: str | Path = "data/xray") -> None:
"""启动开发模式 Web 服务。"""
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s", force=True)
logging.getLogger("pyxray").setLevel(logging.INFO)
if os.environ.get("PYXRAY_ACCESS_LOG") not in {"1", "true", "yes", "on"}:
logging.getLogger("werkzeug").disabled = True
_print_listen_urls(host, port)
create_app(default_xray_dir).run(host=host, port=port)
@@ -49,6 +57,27 @@ def _default_data_dir(default_xray_dir: str | Path) -> Path:
return path.parent if path.name == "xray" else path
def _print_listen_urls(host: str, port: int) -> None:
if host in {"0.0.0.0", "::"}:
print(f" * Pyxray URL: http://127.0.0.1:{port}", flush=True)
lan_ip = _primary_lan_ip()
if lan_ip:
print(f" * Pyxray URL: http://{lan_ip}:{port}", flush=True)
return
print(f" * Pyxray URL: http://{host}:{port}", flush=True)
def _primary_lan_ip() -> str | None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect(("8.8.8.8", 80))
return sock.getsockname()[0]
except OSError:
return None
finally:
sock.close()
def _bind_xray_lifecycle(app: Flask) -> None:
"""确保 pyxray 进程退出时关闭它启动的 Xray。"""
+53 -8
View File
@@ -12,9 +12,9 @@ const mainProgressBar = document.querySelector("#main-progress-bar");
const mainProgressText = document.querySelector("#main-progress-text");
const statusCards = Array.from(document.querySelectorAll("[data-asset-name]"));
const serviceToggle = document.querySelector("#xray-service-toggle");
const serviceState = document.querySelector("#xray-service-state");
const refreshLogsButton = document.querySelector("#refresh-logs-button");
const clearLogsButton = document.querySelector("#clear-logs-button");
const logFormatSelect = document.querySelector("#log-format-select");
const logContent = document.querySelector("#xray-log-content");
const activeJobKey = "pyxray.activeAssetJobId";
const activeFormKey = "pyxray.activeAssetForm";
@@ -134,6 +134,9 @@ nodeList.addEventListener("click", async (event) => {
}
selectedNodeId = payload.node.id;
renderNodes();
if (payload.service) renderServiceStatus(payload.service);
else refreshServiceStatus();
await refreshLogs();
showBox(nodeMessage, `已选择:${payload.node.name}`, "done");
}
@@ -147,6 +150,7 @@ nodeList.addEventListener("click", async (event) => {
if (selectedNodeId === nodeId) selectedNodeId = "";
showBox(nodeMessage, payload.removed ? "节点已删除" : "节点不存在", payload.removed ? "done" : "warn");
await refreshNodes();
refreshServiceStatus();
}
});
@@ -163,6 +167,8 @@ settingsForm.addEventListener("submit", async (event) => {
}
settingsSnapshot = serializeForm(settingsForm);
updateSettingsActions();
if (payload.service) renderServiceStatus(payload.service);
await refreshLogs();
showBox(configMessage, "设置已保存", "done");
});
@@ -241,8 +247,8 @@ function initServiceControls() {
renderServiceStatus(payload);
await refreshLogs();
} catch (error) {
serviceState.textContent = error.message;
serviceState.className = "rounded-full border border-red-500/30 bg-red-500/10 px-4 py-2 font-mono text-xs text-red-300";
serviceToggle.textContent = truncateServiceLabel(error.message);
serviceToggle.className = "rounded-full border border-red-500/30 bg-red-500/10 px-5 py-2 text-sm font-medium text-red-300";
await refreshServiceStatus();
await refreshLogs();
if (logContent && !logContent.textContent.includes(error.message)) {
@@ -260,6 +266,7 @@ function initLogControls() {
if (!refreshLogsButton) return;
refreshLogsButton.addEventListener("click", refreshLogs);
clearLogsButton.addEventListener("click", clearLogs);
logFormatSelect?.addEventListener("change", loadLogs);
resetLogOffset();
setInterval(refreshLogs, 2000);
}
@@ -272,17 +279,24 @@ async function refreshServiceStatus() {
function renderServiceStatus(status) {
serviceToggle.dataset.running = status.running ? "true" : "false";
serviceToggle.textContent = status.running ? "停止 Xray" : "启动 Xray";
serviceToggle.textContent = `${status.running ? "关闭" : "开启"}: ${selectedNodeNameShort()}`;
serviceToggle.className = status.running
? "rounded-full border border-red-500/30 bg-red-500/10 px-5 py-2 text-sm font-medium text-red-300"
: "rounded-full border border-emerald-500/30 bg-emerald-500/10 px-5 py-2 text-sm font-medium text-emerald-300";
serviceState.textContent = status.running ? `pid: ${status.pid}` : "xray: stopped";
serviceState.className = "rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 font-mono text-xs text-zinc-400";
}
function selectedNodeNameShort() {
const selected = nodes.find((node) => node.id === selectedNodeId);
return truncateServiceLabel(selected?.name || "未选择");
}
function truncateServiceLabel(value) {
return value.length > 8 ? value.slice(0, 8) : value;
}
async function refreshLogs() {
if (!logContent) return;
const query = logOffset === null ? "?offset=end" : `?offset=${encodeURIComponent(logOffset)}`;
const query = logQuery(logOffset === null ? "end" : logOffset);
const response = await fetch(`/api/xray/service/logs${query}`);
const payload = await response.json();
if (!response.ok) {
@@ -315,15 +329,46 @@ async function clearLogs() {
}
}
async function loadLogs() {
if (!logContent) return;
const response = await fetch(`/api/xray/service/logs${logFormatQuery()}`);
const payload = await response.json();
if (!response.ok) {
logContent.textContent = payload.error || `HTTP ${response.status}`;
return;
}
if (typeof payload.offset === "number") logOffset = payload.offset;
logContent.textContent = payload.content || "暂无日志。";
logContent.scrollTop = logContent.scrollHeight;
}
async function resetLogOffset() {
if (!logContent) return;
const response = await fetch("/api/xray/service/logs?offset=end");
const response = await fetch(`/api/xray/service/logs${logQuery("end")}`);
if (!response.ok) return;
const payload = await response.json();
logOffset = payload.offset || 0;
logContent.textContent = "暂无日志。";
}
function logQuery(offset) {
const params = new URLSearchParams();
params.set("offset", String(offset));
if ((logFormatSelect?.value || "compact") === "compact") {
params.set("format", "compact");
}
return `?${params.toString()}`;
}
function logFormatQuery() {
const params = new URLSearchParams();
if ((logFormatSelect?.value || "compact") === "compact") {
params.set("format", "compact");
}
const query = params.toString();
return query ? `?${query}` : "";
}
function updateSettingsActions() {
configActions.classList.toggle("is-visible", serializeForm(settingsForm) !== settingsSnapshot);
}
+1 -7
View File
@@ -21,14 +21,8 @@
type="button"
data-running="{{ 'true' if service_status.running else 'false' }}"
>
{{ "停止 Xray" if service_status.running else "启动 Xray" }}
{{ "关闭" if service_status.running else "开启" }}: {{ selected_name[:8] if selected_name else "未选择" }}
</button>
<div id="xray-service-state" class="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 font-mono text-xs text-zinc-400">
{{ "pid: " ~ service_status.pid if service_status.running else "xray: stopped" }}
</div>
<div class="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 font-mono text-xs text-zinc-400">
config: {{ config_path }}
</div>
</div>
</header>
+7 -1
View File
@@ -4,7 +4,13 @@
<h2 class="text-xl font-medium">运行日志</h2>
<p class="mt-1 text-sm text-zinc-500">Xray stdout / stderr,日志文件:{{ log_path }}</p>
</div>
<div class="flex flex-wrap gap-3">
<div class="flex flex-wrap items-center gap-3">
<label class="flex items-center gap-2 text-sm text-zinc-400">
<select id="log-format-select" class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 text-sm font-medium text-zinc-100 outline-none focus:border-emerald-500">
<option value="compact" selected>解析优化日志</option>
<option value="raw">原始日志</option>
</select>
</label>
<button id="refresh-logs-button" class="rounded-2xl border border-zinc-700 bg-zinc-950 px-5 py-3 text-sm font-medium text-zinc-100 hover:bg-zinc-800" type="button">刷新日志</button>
<button id="clear-logs-button" class="rounded-2xl border border-red-500/30 bg-red-500/10 px-5 py-3 text-sm font-medium text-red-300 hover:bg-red-500/20" type="button">清除日志</button>
</div>
+4
View File
@@ -11,6 +11,7 @@ from pyxray.libs.xray_assets import (
ensure_xray_assets,
)
from pyxray.libs.xray_asset_settings import XrayAssetSettings, XrayAssetSettingsStore
from pyxray.web.activity_log import log_activity
from pyxray.web.jobs import Job, get_job_store
@@ -35,6 +36,7 @@ def get_asset_settings_store(app: Flask) -> XrayAssetSettingsStore:
def ensure_api(): # noqa: ANN202
form = _form_values()
get_asset_settings_store(current_app).save(_settings_from_form(form))
log_activity(current_app, f"Asset ensure started: target={form['target'] or 'all'} directory={form['directory']}")
job = get_job_store(current_app).start(lambda item: _run_asset_job(item, form), payload=form)
return jsonify({"job_id": job["id"]})
@@ -50,6 +52,7 @@ def save_asset_settings_api(): # noqa: ANN202
form = _form_values()
settings = _settings_from_form(form)
get_asset_settings_store(current_app).save(settings)
log_activity(current_app, f"Asset settings saved: directory={settings.directory} version={settings.version}")
return jsonify(settings.to_dict())
@@ -66,6 +69,7 @@ def cancel_job_api(job_id: str): # noqa: ANN202
job = get_job_store(current_app).cancel(job_id)
if job is None:
return jsonify({"error": "job not found"}), 404
log_activity(current_app, f"Asset job cancelled: {job_id}")
return jsonify({"job_id": job_id, "cancel_requested": True})
+10 -1
View File
@@ -19,6 +19,7 @@ from pyxray.libs.xray_config.settings import (
OutboundSetting,
)
from pyxray.libs.xray_config.store import dump_settings_toml
from pyxray.web.activity_log import log_activity
from pyxray.web.nodes import get_node_manager
@@ -66,7 +67,14 @@ def save_settings_api(): # noqa: ANN202
get_settings_store(current_app).save(settings)
except Exception as exc: # noqa: BLE001
return jsonify({"error": str(exc)}), 400
return jsonify({"settings_toml": dump_settings_toml(settings)})
log_activity(current_app, "Xray settings saved")
try:
from pyxray.web.xray_service import restart_xray_service_if_running
restart_status = restart_xray_service_if_running(current_app, reason="settings saved")
except Exception as exc: # noqa: BLE001
return jsonify({"error": str(exc), "settings_toml": dump_settings_toml(settings)}), 400
return jsonify({"settings_toml": dump_settings_toml(settings), "service": restart_status})
@blueprint.post("/generate")
@@ -77,6 +85,7 @@ def generate_config_api(): # noqa: ANN202
generated = generate_current_xray_config(current_app)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
log_activity(current_app, f"Xray config generated: {generated['config_path']}")
return jsonify(generated)
+37 -3
View File
@@ -6,8 +6,9 @@ from typing import Any
from flask import Blueprint, Flask, current_app, jsonify, request
from pyxray.libs.xray_runtime import XrayServiceManager, log_file_size, read_log_since, read_log_tail
from pyxray.libs.xray_runtime import XrayServiceManager, compact_xray_log, log_file_size, read_log_since, read_log_tail
from pyxray.libs.xray_transparent_runtime import TransparentRuntime
from pyxray.web.activity_log import log_activity
from pyxray.web.xray_assets import get_asset_settings_store
from pyxray.web.xray_config import generate_current_xray_config, get_settings_store
@@ -51,17 +52,22 @@ def status_api(): # noqa: ANN202
@blueprint.post("/start")
def start_api(): # noqa: ANN202
try:
log_activity(current_app, "Xray start requested")
status = start_xray_service(current_app)
save_service_state(current_app, desired_running=True)
log_activity(current_app, f"Xray started: pid={status['pid']}")
return jsonify(status)
except Exception as exc: # noqa: BLE001
log_activity(current_app, f"Xray start failed: {exc}")
return jsonify({"error": str(exc), "status": get_xray_service(current_app).status()}), 400
@blueprint.post("/stop")
def stop_api(): # noqa: ANN202
log_activity(current_app, "Xray stop requested")
status = get_xray_service(current_app).stop()
save_service_state(current_app, desired_running=False)
log_activity(current_app, "Xray stopped")
return jsonify(status)
@@ -69,20 +75,27 @@ def stop_api(): # noqa: ANN202
def logs_api(): # noqa: ANN202
path = current_app.config["XRAY_LOG_PATH"]
offset = request.args.get("offset")
compact = request.args.get("format") == "compact"
if offset == "end":
size = log_file_size(path)
return jsonify({"path": path, "content": "", "offset": size})
if offset is not None:
content, size = read_log_since(path, int(offset or 0))
if compact:
content = compact_xray_log(content)
return jsonify({"path": path, "content": content, "offset": size})
return jsonify({"path": path, "content": read_log_tail(path), "offset": log_file_size(path)})
content = read_log_tail(path)
if compact:
content = compact_xray_log(content)
return jsonify({"path": path, "content": content, "offset": log_file_size(path)})
@blueprint.delete("/logs")
def clear_logs_api(): # noqa: ANN202
path = Path(current_app.config["XRAY_LOG_PATH"])
path.parent.mkdir(parents=True, exist_ok=True)
return jsonify({"path": str(path), "content": "", "offset": log_file_size(path)})
path.write_text("", encoding="utf-8")
return jsonify({"path": str(path), "content": "", "offset": 0})
def start_xray_service(app: Flask) -> dict[str, Any]:
@@ -102,16 +115,37 @@ def start_xray_service(app: Flask) -> dict[str, Any]:
return status
def restart_xray_service_if_running(app: Flask, *, reason: str) -> dict[str, Any] | None:
"""Restart Xray after a config-affecting change when it is currently running."""
service = get_xray_service(app)
if not service.status()["running"]:
return None
log_activity(app, f"Xray restart requested: {reason}")
service.stop()
try:
status = start_xray_service(app)
except Exception as exc:
log_activity(app, f"Xray restart failed: {reason}: {exc}")
raise
save_service_state(app, desired_running=True)
log_activity(app, f"Xray restarted: pid={status['pid']}")
return status
def restore_xray_service(app: Flask) -> None:
"""应用启动时按上次用户期望恢复 Xray 运行状态。"""
if not load_service_state(app).get("desired_running", False):
return
try:
log_activity(app, "Xray restore requested")
start_xray_service(app)
_append_service_message(app, "restored desired running state")
log_activity(app, "Xray restored")
except Exception as exc: # noqa: BLE001
_append_service_message(app, f"failed to restore desired running state: {exc}")
log_activity(app, f"Xray restore failed: {exc}")
def load_service_state(app: Flask) -> dict[str, Any]:
+79 -3
View File
@@ -426,10 +426,10 @@ def test_xray_service_api_clears_logs(tmp_path: Path) -> None:
assert response.status_code == 200
assert response.get_json()["content"] == ""
assert response.get_json()["offset"] == len("old log")
assert logs.get_json()["content"] == "old log"
assert response.get_json()["offset"] == 0
assert logs.get_json()["content"] == ""
assert client.get(f"/api/xray/service/logs?offset={response.get_json()['offset']}").get_json()["content"] == ""
assert (tmp_path / "xray.log").read_text(encoding="utf-8") == "old log"
assert (tmp_path / "xray.log").read_text(encoding="utf-8") == ""
def test_xray_service_logs_api_reads_from_offset(tmp_path: Path) -> None:
@@ -447,6 +447,29 @@ def test_xray_service_logs_api_reads_from_offset(tmp_path: Path) -> None:
assert payload["offset"] == log.stat().st_size
def test_xray_service_logs_api_returns_compact_route_lines(tmp_path: Path) -> None:
log = tmp_path / "xray.log"
log.write_text(
"\n".join(
[
"2026/05/27 04:17:06.829641 [Info] [3636958196] app/dispatcher: sniffed domain: git.pchuan.top",
"2026/05/27 04:17:06.829662 [Info] [3636958196] app/dispatcher: taking detour [direct] for [tcp:git.pchuan.top:80]",
"2026/05/27 04:17:06.829733 from 192.168.0.76:53842 accepted tcp:117.72.47.28:80 [transparent -> direct]",
"2026/05/27 04:17:18.057882 [Info] app/proxyman/outbound: failed to process outbound traffic",
]
),
encoding="utf-8",
)
app = create_app(tmp_path)
client = app.test_client()
payload = client.get("/api/xray/service/logs?format=compact").get_json()
assert "2026/05/27 04:17:06 git.pchuan.top:80 -> direct" in payload["content"]
assert "accepted tcp" not in payload["content"]
assert "failed to process outbound traffic" in payload["content"]
def test_xray_service_logs_api_returns_latest_1000_lines(tmp_path: Path) -> None:
(tmp_path / "xray.log").write_text("\n".join(f"line-{index}" for index in range(1205)), encoding="utf-8")
app = create_app(tmp_path)
@@ -530,6 +553,31 @@ def test_nodes_api_imports_lists_selects_and_deletes_node(tmp_path: Path) -> Non
assert client.get("/api/nodes").get_json()["nodes"] == []
def test_selecting_node_restarts_running_xray(tmp_path: Path) -> None:
xray = tmp_path / "xray"
xray.write_text("#!/bin/sh\necho started-$@\nsleep 30\n", encoding="utf-8")
os.chmod(xray, 0o755)
app = create_app(tmp_path)
client = app.test_client()
client.post("/api/nodes/import", data={"links": "\n".join([_ss_link("one", "one"), _ss_link("two", "two")])})
nodes = client.get("/api/nodes").get_json()["nodes"]
client.post("/api/nodes/select", data={"node_id": nodes[0]["id"]})
client.post(
"/api/xray/config/settings",
data={"settings_toml": '[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'},
)
first = client.post("/api/xray/service/start").get_json()
selected = client.post("/api/nodes/select", data={"node_id": nodes[1]["id"]})
config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
client.post("/api/xray/service/stop")
assert selected.status_code == 200
assert selected.get_json()["service"]["running"] is True
assert selected.get_json()["service"]["pid"] != first["pid"]
assert config["outbounds"][0]["settings"]["servers"][0]["password"] == "two"
def test_xray_config_api_saves_settings_and_generates_config(tmp_path: Path) -> None:
app = create_app(tmp_path)
client = app.test_client()
@@ -554,6 +602,34 @@ def test_xray_config_api_saves_settings_and_generates_config(tmp_path: Path) ->
assert (tmp_path / "transparent" / "transparent-iptables-setup.sh").exists()
def test_saving_settings_restarts_running_xray(tmp_path: Path) -> None:
xray = tmp_path / "xray"
xray.write_text("#!/bin/sh\necho settings-started\nsleep 30\n", encoding="utf-8")
os.chmod(xray, 0o755)
app = create_app(tmp_path)
client = app.test_client()
client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")})
node = client.get("/api/nodes").get_json()["nodes"][0]
client.post("/api/nodes/select", data={"node_id": node["id"]})
client.post(
"/api/xray/config/settings",
data={"settings_toml": '[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'},
)
first = client.post("/api/xray/service/start").get_json()
saved = client.post(
"/api/xray/config/settings",
data={"settings_toml": '[core]\nlog_level = "debug"\n[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'},
)
config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
client.post("/api/xray/service/stop")
assert saved.status_code == 200
assert saved.get_json()["service"]["running"] is True
assert saved.get_json()["service"]["pid"] != first["pid"]
assert config["log"]["loglevel"] == "debug"
def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> None:
app = create_app(tmp_path)
client = app.test_client()