From 2aacef3b3f11dc478cf01a36623cdf050c8b1bcd Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 27 May 2026 11:40:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=81=A2=E5=A4=8D=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/config/routing.md | 2 + docs/routing-custom-rules.md | 183 ++++++++++++++++++++++++++++++ pyxray/libs/xray_runtime.py | 5 + pyxray/web/xray_service.py | 72 ++++++++++-- tests/web/test_xray_assets_web.py | 30 +++++ 5 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 docs/routing-custom-rules.md diff --git a/docs/config/routing.md b/docs/config/routing.md index d7382fe..332ccd0 100644 --- a/docs/config/routing.md +++ b/docs/config/routing.md @@ -2,6 +2,8 @@ 对应 `settings.toml` 的 `[routing]`。 +自定义规则详细语法见 [路由自定义规则](../routing-custom-rules.md)。 + | 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 | | --- | --- | --- | --- | --- | --- | | `mode` | 显示 | `whitelist` | UI:`whitelist` / `gfwlist` / `proxy` / `direct` / `block`;模型另支持 `custom` / `routingA` | 控制 rule 入站和部分透明代理流量的分流模式。 | 改变整体分流策略时修改。 | diff --git a/docs/routing-custom-rules.md b/docs/routing-custom-rules.md new file mode 100644 index 0000000..819a7f1 --- /dev/null +++ b/docs/routing-custom-rules.md @@ -0,0 +1,183 @@ +# 路由自定义规则 + +本文说明 pyxray 配置页“路由 / 自定义规则”的实际语法,以及它最终生成到 Xray `routing.rules` 的方式。 + +## 适用入口 + +| 入口 | 字段 | UI | 适合场景 | +| --- | --- | --- | --- | +| RoutingA 文本 | `routing.routing_a` | 显示 | 少量域名/IP 前置规则。 | +| 结构化规则 | `routing.custom_rules` | 隐藏 | 手写 `settings.toml`,按 geosite/geoip/ext 列表分流。 | + +pyxray 当前不会让你直接手写完整 Xray `routing.rules` JSON;它只提供上述两种简化输入,然后生成 Xray 规则。 + +## 匹配顺序 + +```mermaid +flowchart TD + Request["连接进入 rule 入站或透明代理入站"] --> Custom["先应用 routing_a 前置规则"] + Custom --> Mode["再应用 routing.mode 内置规则"] + Mode --> Default["最后应用 default_rule 兜底"] + Default --> Outbound["proxy direct block"] +``` + +Xray 原生规则按 `routing.rules` 从上到下匹配,命中第一条后使用该规则的 `outboundTag` 或 `balancerTag`。同一条规则里多个字段同时存在时是 AND 关系;同一字段数组内通常是 OR 关系。 + +## RoutingA 文本语法 + +| 语法 | 示例 | 生成字段 | 说明 | +| --- | --- | --- | --- | +| `domain(...) -> proxy` | `domain(geosite:google)->proxy` | `domain` | 命中域名后走 `proxy`。 | +| `domain(...) -> direct` | `domain(domain:example.com)->direct` | `domain` | 命中域名或子域名后直连。 | +| `domain(...) -> block` | `domain(full:ads.example.com)->block` | `domain` | 命中完整域名后阻断。 | +| `ip(...) -> proxy` | `ip(geoip:telegram)->proxy` | `ip` | 命中 IP 列表后代理。 | +| `ip(...) -> direct` | `ip(geoip:private, geoip:cn)->direct` | `ip` | 命中私有或中国 IP 后直连。 | +| 注释 | `# comment` | 无 | 空行和 `#` 开头行会被忽略。 | + +格式要求: + +| 项 | 要求 | +| --- | --- | +| 匹配器 | 只能是 `domain(...)` 或 `ip(...)`。 | +| 分隔符 | 必须使用 `->`。 | +| 多个值 | 用英文逗号分隔。 | +| 出口 | 通常使用 `proxy`、`direct`、`block`。 | +| 生效范围 | 只作用于 rule 入站和透明代理入站,不影响普通 `socks` / `http` 入站。 | + +示例: + +```toml +[routing] +mode = "whitelist" +default_rule = "proxy" +routing_a = """ +# 公司内网直连 +domain(domain:corp.example.com)->direct +ip(10.0.0.0/8, 192.168.0.0/16)->direct + +# Google 代理 +domain(geosite:google)->proxy + +# 精确阻断广告域名 +domain(full:ads.example.com)->block +""" +``` + +## domain 值 + +| 写法 | 示例 | 匹配语义 | +| --- | --- | --- | +| `domain:` | `domain:example.com` | 匹配 `example.com` 和子域名,例如 `www.example.com`。 | +| `full:` | `full:example.com` | 只完整匹配 `example.com`。 | +| `keyword:` | `keyword:google` | 目标域名包含关键字即匹配。 | +| 无前缀字符串 | `google` | 等价于 `keyword:google`。 | +| `regexp:` | `regexp:\\.example\\.com$` | 使用正则匹配目标域名。 | +| `dotless:` | `dotless:printer` | 匹配不含点的内网短域名。 | +| `geosite:` | `geosite:cn` | 使用 `geosite.dat` 里的标签。 | +| `ext:` | `ext:geosite.dat:cn` | 从资源目录里的外部 geosite 格式文件读取标签。 | + +注意: + +| 项 | 说明 | +| --- | --- | +| 推荐默认 | 常规域名优先用 `domain:example.com`。 | +| 精确匹配 | 只想匹配单个域名时用 `full:`。 | +| 正则转义 | 写进 TOML 字符串时反斜杠要按 TOML 规则转义。 | +| 不支持 | `plain:` 不是当前 Xray 官方 routing 文档列出的 domain 前缀,不要使用。 | + +## ip 值 + +| 写法 | 示例 | 匹配语义 | +| --- | --- | --- | +| 单个 IP | `1.1.1.1` | 匹配目标 IP。 | +| CIDR | `10.0.0.0/8` | 匹配网段。 | +| IPv6 CIDR | `fc00::/7` | 匹配 IPv6 网段。 | +| `geoip:` | `geoip:cn` | 使用 `geoip.dat` 里的国家或分类标签。 | +| `geoip:private` | `geoip:private` | 匹配私有地址。 | +| `ext:` | `ext:geoip.dat:cn` | 从资源目录里的外部 geoip 格式文件读取标签。 | +| `!` 反选 | `!geoip:cn` | 匹配不在该 IP 列表内的目标。 | + +示例: + +```toml +[routing] +mode = "routingA" +default_rule = "proxy" +routing_a = """ +ip(geoip:private, geoip:cn)->direct +ip(geoip:telegram)->proxy +ip(!geoip:cn)->proxy +""" +``` + +## custom_rules 结构化规则 + +`custom_rules` 只有在 `routing.mode = "custom"` 时作为主规则集使用。UI 暂不显示,需要手写 `settings.toml`。 + +| 字段 | 默认值 | 可选值 | 作用 | +| --- | --- | --- | --- | +| `filename` | `""` | 文件名 | 非空时把每个 tag 生成 `ext::`。 | +| `tags` | `[]` | 字符串数组 | 要匹配的 geosite/geoip/ext 标签。 | +| `match_type` | `domain` | `domain` / `ip` | 决定生成 Xray rule 的 `domain` 还是 `ip`。 | +| `rule_type` | `proxy` | `proxy` / `direct` / `block` | 命中后的出口。 | + +示例: + +```toml +[routing] +mode = "custom" +default_rule = "proxy" + +[[routing.custom_rules]] +match_type = "domain" +rule_type = "direct" +tags = ["geosite:private", "geosite:cn"] + +[[routing.custom_rules]] +match_type = "ip" +rule_type = "direct" +tags = ["geoip:private", "geoip:cn"] + +[[routing.custom_rules]] +match_type = "domain" +rule_type = "proxy" +tags = ["geosite:geolocation-!cn"] +``` + +使用外部文件: + +```toml +[[routing.custom_rules]] +filename = "geosite.dat" +match_type = "domain" +rule_type = "proxy" +tags = ["google"] +``` + +上面会生成: + +```json +{ + "domain": ["ext:geosite.dat:google"], + "outboundTag": "proxy" +} +``` + +## 常见问题 + +| 问题 | 原因 | 处理 | +| --- | --- | --- | +| 规则没生效 | 入口不是 rule 入站或透明代理入站。 | 使用 `rule_http_port` / `rule_socks_port`,或开启透明代理。 | +| 域名规则没命中 | 流量只有 IP,没有域名。 | 开启 sniffing,或改用 `ip(...)` 规则。 | +| IP 规则导致 DNS 查询 | 当前 pyxray 生成 `domainStrategy = "IPOnDemand"`。 | 避免过度使用 IP 规则,或接受 Xray 为路由进行 DNS 解析。 | +| `routing_a` 里的 `default:` 无效 | pyxray 解析器只识别 `domain(...)` 和 `ip(...)`。 | 用 `default_rule` 设置兜底。 | +| `plain:` 无效 | 不是当前 Xray routing 官方 domain 前缀。 | 使用无前缀字符串或 `keyword:`。 | + +## 官方依据 + +| 内容 | 官方链接 | +| --- | --- | +| Xray RoutingObject / RuleObject | https://xtls.github.io/config/routing.html | +| 文档源码 | https://github.com/XTLS/Xray-docs-next/blob/main/docs/config/routing.md | +| Xray-core routing 解析代码 | https://github.com/XTLS/Xray-core/blob/main/infra/conf/router.go | +| 域名/IP 规则解析代码 | https://github.com/XTLS/Xray-core/blob/main/common/geodata/rule_parser.go | diff --git a/pyxray/libs/xray_runtime.py b/pyxray/libs/xray_runtime.py index 9ad627a..1a2d96a 100644 --- a/pyxray/libs/xray_runtime.py +++ b/pyxray/libs/xray_runtime.py @@ -102,6 +102,11 @@ class XrayServiceManager: self._run_before_stop() self._terminate_process(process) + def log_message(self, message: str) -> None: + """Append a pyxray service message to the service log.""" + + self._append_message(message) + def _run_before_stop(self) -> None: if self.before_stop is None: return diff --git a/pyxray/web/xray_service.py b/pyxray/web/xray_service.py index 720471a..43ac605 100644 --- a/pyxray/web/xray_service.py +++ b/pyxray/web/xray_service.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json from pathlib import Path +from typing import Any from flask import Blueprint, Flask, current_app, jsonify, request @@ -17,6 +19,7 @@ def register_xray_service(app: Flask, *, xray_dir: str | Path, config_path: str """注册 Xray 运行控制 API。""" app.config["XRAY_LOG_PATH"] = str(log_path) + app.config["XRAY_SERVICE_STATE_PATH"] = str(Path(config_path).parent / "service-state.json") app.extensions["pyxray_transparent_runtime"] = TransparentRuntime( transparent_dir=Path(config_path).parent / "transparent", log_path=log_path, @@ -29,6 +32,7 @@ def register_xray_service(app: Flask, *, xray_dir: str | Path, config_path: str before_stop=lambda: get_transparent_runtime(app).cleanup(best_effort=True), ) app.register_blueprint(blueprint) + restore_xray_service(app) def get_xray_service(app: Flask) -> XrayServiceManager: @@ -47,17 +51,8 @@ def status_api(): # noqa: ANN202 @blueprint.post("/start") def start_api(): # noqa: ANN202 try: - generate_current_xray_config(current_app) - settings = get_settings_store(current_app).load() - service = get_xray_service(current_app) - was_running = service.status()["running"] - status = service.start() - if not was_running: - try: - get_transparent_runtime(current_app).setup(settings) - except Exception: - service.stop() - raise + status = start_xray_service(current_app) + save_service_state(current_app, desired_running=True) return jsonify(status) except Exception as exc: # noqa: BLE001 return jsonify({"error": str(exc), "status": get_xray_service(current_app).status()}), 400 @@ -65,7 +60,9 @@ def start_api(): # noqa: ANN202 @blueprint.post("/stop") def stop_api(): # noqa: ANN202 - return jsonify(get_xray_service(current_app).stop()) + status = get_xray_service(current_app).stop() + save_service_state(current_app, desired_running=False) + return jsonify(status) @blueprint.get("/logs") @@ -86,3 +83,54 @@ 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)}) + + +def start_xray_service(app: Flask) -> dict[str, Any]: + """生成当前配置并启动 Xray 及透明代理规则。""" + + generate_current_xray_config(app) + settings = get_settings_store(app).load() + service = get_xray_service(app) + was_running = service.status()["running"] + status = service.start() + if not was_running: + try: + get_transparent_runtime(app).setup(settings) + except Exception: + service.stop() + raise + return status + + +def restore_xray_service(app: Flask) -> None: + """应用启动时按上次用户期望恢复 Xray 运行状态。""" + + if not load_service_state(app).get("desired_running", False): + return + try: + start_xray_service(app) + _append_service_message(app, "restored desired running state") + except Exception as exc: # noqa: BLE001 + _append_service_message(app, f"failed to restore desired running state: {exc}") + + +def load_service_state(app: Flask) -> dict[str, Any]: + path = Path(app.config["XRAY_SERVICE_STATE_PATH"]) + if not path.exists(): + return {"desired_running": False} + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {"desired_running": False} + return {"desired_running": bool(raw.get("desired_running", False))} + + +def save_service_state(app: Flask, *, desired_running: bool) -> None: + path = Path(app.config["XRAY_SERVICE_STATE_PATH"]) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"desired_running": desired_running}, indent=2) + "\n", encoding="utf-8") + + +def _append_service_message(app: Flask, message: str) -> None: + service = get_xray_service(app) + service.log_message(message) diff --git a/tests/web/test_xray_assets_web.py b/tests/web/test_xray_assets_web.py index 8464ef4..461c8a8 100644 --- a/tests/web/test_xray_assets_web.py +++ b/tests/web/test_xray_assets_web.py @@ -195,6 +195,36 @@ def test_xray_service_api_starts_stops_and_reads_logs(tmp_path: Path) -> None: assert "pyxray start xray" in logs.get_json()["content"] assert "xray-started" in logs.get_json()["content"] assert json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))["log"]["error"] == "" + assert json.loads((tmp_path / "service-state.json").read_text(encoding="utf-8")) == {"desired_running": False} + + +def test_xray_service_restores_desired_running_state_on_app_start(tmp_path: Path) -> None: + xray = tmp_path / "xray" + xray.write_text("#!/bin/sh\necho restored-start\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'}, + ) + + started = client.post("/api/xray/service/start") + app.extensions["pyxray_xray_service"].shutdown() + + restored_app = create_app(tmp_path) + restored_client = restored_app.test_client() + restored_status = restored_client.get("/api/xray/service") + restored_logs = restored_client.get("/api/xray/service/logs").get_json()["content"] + restored_app.extensions["pyxray_xray_service"].shutdown() + + assert started.status_code == 200 + assert json.loads((tmp_path / "service-state.json").read_text(encoding="utf-8")) == {"desired_running": True} + assert restored_status.get_json()["running"] is True + assert "pyxray restored desired running state" in restored_logs def test_xray_service_log_forwarder_flushes_line_output_quickly(tmp_path: Path) -> None: