Compare commits
3 Commits
+1
-1
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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。"""
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user