From 2f84365db40725bde0b9beb151db7c4327480451 Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 27 May 2026 15:30:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20inbound=20?= =?UTF-8?q?=E7=9A=84=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/config.md | 4 +- docs/config/inbounds.md | 48 +++++++++++++++- docs/routing-custom-rules.md | 2 +- pyxray/libs/xray_assets.py | 33 ++++++++++- pyxray/libs/xray_config/generator.py | 28 +++++++-- pyxray/libs/xray_config/settings.py | 4 ++ pyxray/libs/xray_runtime.py | 2 +- pyxray/web/templates/configs/inbounds.html | 31 ++++++---- .../web/templates/partials/download_tab.html | 2 +- pyxray/web/xray_config.py | 2 + tests/libs/test_xray_config.py | 57 ++++++++++++++----- tests/web/test_xray_assets_web.py | 9 ++- 12 files changed, 179 insertions(+), 43 deletions(-) diff --git a/docs/config.md b/docs/config.md index a4fce1b..49af29a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -20,7 +20,7 @@ UI 中有些字段隐藏但仍存在于 `settings.toml`。下表的“UI”列 | 分类 | 文档 | 主要影响 | | --- | --- | --- | | 核心 | [config/core.md](config/core.md) | Xray 日志、Mux、TCP Fast Open。 | -| 入站 | [config/inbounds.md](config/inbounds.md) | SOCKS/HTTP/rule/API/自定义入站。 | +| 入站 | [config/inbounds.md](config/inbounds.md) | Mixed/rule/API/自定义入站。 | | 路由 | [config/routing.md](config/routing.md) | rule 入站和透明代理流量的分流策略。 | | DNS | [config/dns.md](config/dns.md) | Xray DNS 模块、本地 DNS 入站、DNS 服务器路由。 | | 透明代理 | [config/transparent.md](config/transparent.md) | transparent inbound、iptables/nft、resolv、TinyTun。 | @@ -45,7 +45,7 @@ UI 中有些字段隐藏但仍存在于 `settings.toml`。下表的“UI”列 | 设置 | 默认值 | 说明 | | --- | --- | --- | | `core.log_level` | `info` | 日常日志等级。 | -| `inbounds.rule_http_port` | `20172` | 规则 HTTP 代理入口。 | +| `inbounds.rule_http_port` | `20172` | 规则 Mixed 代理入口。 | | `routing.mode` | `whitelist` | 国内/私有直连,其它代理。 | | `transparent.mode` | `close` | 默认不启用透明代理。 | | `transparent.type` | `redirect` | 推荐先用 redirect。 | diff --git a/docs/config/inbounds.md b/docs/config/inbounds.md index aa831fc..5ccbaaf 100644 --- a/docs/config/inbounds.md +++ b/docs/config/inbounds.md @@ -8,8 +8,10 @@ | `port_sharing` | 隐藏 | `false` | `bool` | 为 `true` 时监听地址强制变成 `0.0.0.0`。 | 当前 UI 用 `listen` 控制,通常不改。 | | `socks_port` | 隐藏 | `20170` | `0-65535` | 普通 SOCKS 入站,流量最终兜底走 `proxy`。UI 保存时写 `0`。 | 需要无规则 SOCKS 入口时手改。 | | `http_port` | 隐藏 | `20171` | `0-65535` | 普通 HTTP 入站,流量最终兜底走 `proxy`。UI 保存时写 `0`。 | 需要无规则 HTTP 入口时手改。 | -| `rule_socks_port` | 显示 | `0` | `0-65535` | 规则 SOCKS 入站,流量按 `[routing]` 规则分流。UI 显示值会在为 `0` 时回退展示 `socks_port`。 | 应用要用 SOCKS 并希望按规则分流时设置。 | -| `rule_http_port` | 显示 | `20172` | `0-65535` | 规则 HTTP 入站,流量按 `[routing]` 规则分流。 | 浏览器/系统显式 HTTP 代理时使用。 | +| `rule_socks_port` | 隐藏 | `0` | `0-65535` | 旧版规则 SOCKS 入站兼容字段。当前 UI 保存时写 `0`。 | 通常不改。 | +| `rule_http_port` | 显示为 Mixed 端口 | `20172` | `0-65535` | 规则 mixed 入站,同一个端口同时支持 HTTP 和 SOCKS,流量按 `[routing]` 规则分流。 | 浏览器、系统代理、CLI 工具显式代理时使用。 | +| `auth_user` | 显示 | `""` | 字符串 | mixed/SOCKS 入站认证用户名。 | 需要给局域网开放代理但不想裸奔时设置。 | +| `auth_password` | 显示 | `""` | 字符串 | mixed/SOCKS 入站认证密码。 | 与 `auth_user` 一起设置;两者都为空时使用 `noauth`。 | | `vmess_port` | 隐藏 | `0` | `0-65535` | 额外 VMess 入站。`0` 不生成。 | 当前很少需要。 | | `inbound_sniffing` | 显示 | `http,tls,quic` | `disable` / `http,tls` / `http,tls,quic` | 写入每个支持入站的 `sniffing.destOverride`。 | 域名路由不准时保持开启;兼容性异常时降级或关闭。 | | `route_only` | 显示 | `false` | `bool` | 写入 `sniffing.routeOnly`。 | 只希望嗅探域名用于路由、不改连接目标时开启。 | @@ -31,6 +33,46 @@ | 入站 tag | 来源 | 路由行为 | | --- | --- | --- | | `socks` / `http` | `socks_port` / `http_port` | 不进入 `[routing]` 模式规则,最终兜底走 `proxy`。 | -| `rule-socks` / `rule-http` | `rule_socks_port` / `rule_http_port` | 进入 `[routing]` 模式规则。 | +| `rule-mixed` | `rule_http_port` | 同一端口支持 HTTP 和 SOCKS,进入 `[routing]` 模式规则。 | | `vmess` | `vmess_port` | 额外 VMess 入站。 | | `api-in` | `api.port` | 路由到 `api-out`。 | + +## Mixed 入站 + +`rule-mixed` 生成示例: + +```json +{ + "tag": "rule-mixed", + "listen": "0.0.0.0", + "port": 20172, + "protocol": "mixed", + "settings": { + "auth": "noauth", + "udp": true, + "allowTransparent": false + } +} +``` + +如果填写用户名和密码: + +```json +"settings": { + "auth": "password", + "udp": true, + "allowTransparent": false, + "accounts": [ + {"user": "alice", "pass": "secret"} + ] +} +``` + +使用方式: + +```sh +curl -x http://10.11.11.100:20172 http://google.com/ +curl --socks5-hostname 10.11.11.100:20172 https://google.com/ +curl -x http://alice:secret@10.11.11.100:20172 http://google.com/ +curl --socks5-hostname alice:secret@10.11.11.100:20172 https://google.com/ +``` diff --git a/docs/routing-custom-rules.md b/docs/routing-custom-rules.md index 819a7f1..2833900 100644 --- a/docs/routing-custom-rules.md +++ b/docs/routing-custom-rules.md @@ -167,7 +167,7 @@ tags = ["google"] | 问题 | 原因 | 处理 | | --- | --- | --- | -| 规则没生效 | 入口不是 rule 入站或透明代理入站。 | 使用 `rule_http_port` / `rule_socks_port`,或开启透明代理。 | +| 规则没生效 | 入口不是 rule 入站或透明代理入站。 | 使用 `rule_http_port` 对应的 mixed 端口,或开启透明代理。 | | 域名规则没命中 | 流量只有 IP,没有域名。 | 开启 sniffing,或改用 `ip(...)` 规则。 | | IP 规则导致 DNS 查询 | 当前 pyxray 生成 `domainStrategy = "IPOnDemand"`。 | 避免过度使用 IP 规则,或接受 Xray 为路由进行 DNS 解析。 | | `routing_a` 里的 `default:` 无效 | pyxray 解析器只识别 `domain(...)` 和 `ip(...)`。 | 用 `default_rule` 设置兜底。 | diff --git a/pyxray/libs/xray_assets.py b/pyxray/libs/xray_assets.py index e53e671..14f618c 100644 --- a/pyxray/libs/xray_assets.py +++ b/pyxray/libs/xray_assets.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import stat +import urllib.parse import urllib.request import zipfile from dataclasses import dataclass @@ -202,13 +203,39 @@ def _open_url(url: str, proxy_url: str | None): """打开 URL;指定代理时使用该代理,否则走系统默认代理配置。""" if proxy_url: - opener = urllib.request.build_opener( - urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}) - ) + opener = _proxy_opener(proxy_url) return opener.open(url) return urllib.request.urlopen(url) # noqa: S310 +def _proxy_opener(proxy_url: str): + parsed = urllib.parse.urlsplit(proxy_url) + if not parsed.username: + return urllib.request.build_opener(urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})) + + clean_proxy_url = _proxy_url_without_auth(parsed) + password_manager = urllib.request.HTTPPasswordMgrWithDefaultRealm() + password_manager.add_password( + None, + clean_proxy_url, + urllib.parse.unquote(parsed.username), + urllib.parse.unquote(parsed.password or ""), + ) + return urllib.request.build_opener( + urllib.request.ProxyHandler({"http": clean_proxy_url, "https": clean_proxy_url}), + urllib.request.ProxyBasicAuthHandler(password_manager), + urllib.request.ProxyDigestAuthHandler(password_manager), + ) + + +def _proxy_url_without_auth(parsed: urllib.parse.SplitResult) -> str: + host = parsed.hostname or "" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + netloc = f"{host}:{parsed.port}" if parsed.port else host + return urllib.parse.urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) + + def _extract_from_zip(data: bytes, directory: Path, names: set[str]) -> None: """从 zip 中按文件名提取需要的文件,忽略目录层级。""" diff --git a/pyxray/libs/xray_config/generator.py b/pyxray/libs/xray_config/generator.py index 26fe731..fa89994 100644 --- a/pyxray/libs/xray_config/generator.py +++ b/pyxray/libs/xray_config/generator.py @@ -65,8 +65,7 @@ def _build_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]: inbounds = [ _socks_inbound(settings.inbounds.socks_port, listen, "socks", settings), _http_inbound(settings.inbounds.http_port, listen, "http", settings), - _socks_inbound(settings.inbounds.rule_socks_port, listen, "rule-socks", settings), - _http_inbound(settings.inbounds.rule_http_port, listen, "rule-http", settings), + _mixed_inbound(settings.inbounds.rule_http_port, listen, "rule-mixed", settings), _vmess_inbound(settings.inbounds.vmess_port, listen, settings), ] for custom in settings.inbounds.custom: @@ -82,7 +81,7 @@ def _build_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]: def _socks_inbound(port: int, listen: str, tag: str, settings: XrayConfigSettings) -> dict[str, Any]: return _with_sniffing( - {"port": port, "listen": listen, "protocol": "socks", "settings": {"auth": "noauth", "udp": True}, "tag": tag}, + {"port": port, "listen": listen, "protocol": "socks", "settings": _inbound_proxy_settings(settings), "tag": tag}, settings, ) @@ -91,6 +90,19 @@ def _http_inbound(port: int, listen: str, tag: str, settings: XrayConfigSettings return _with_sniffing({"port": port, "listen": listen, "protocol": "http", "tag": tag}, settings) +def _mixed_inbound(port: int, listen: str, tag: str, settings: XrayConfigSettings) -> dict[str, Any]: + return _with_sniffing( + { + "port": port, + "listen": listen, + "protocol": "mixed", + "settings": _inbound_proxy_settings(settings), + "tag": tag, + }, + settings, + ) + + def _vmess_inbound(port: int, listen: str, settings: XrayConfigSettings) -> dict[str, Any]: if port <= 0: return {"port": 0} @@ -101,6 +113,14 @@ def _vmess_inbound(port: int, listen: str, settings: XrayConfigSettings) -> dict ) +def _inbound_proxy_settings(settings: XrayConfigSettings) -> dict[str, Any]: + proxy_settings: dict[str, Any] = {"auth": "noauth", "udp": True, "allowTransparent": False} + if settings.inbounds.auth_user and settings.inbounds.auth_password: + proxy_settings["auth"] = "password" + proxy_settings["accounts"] = [{"user": settings.inbounds.auth_user, "pass": settings.inbounds.auth_password}] + return proxy_settings + + def _transparent_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]: if settings.transparent.mode == "close": return [] @@ -239,7 +259,7 @@ def _build_dns_routing(settings: XrayConfigSettings) -> list[dict[str, Any]]: def _build_rule_port_routing(settings: XrayConfigSettings) -> list[dict[str, Any]]: - tags = [tag for tag, port in (("rule-http", settings.inbounds.rule_http_port), ("rule-socks", settings.inbounds.rule_socks_port)) if port > 0] + tags = ["rule-mixed"] if settings.inbounds.rule_http_port > 0 else [] if not tags: return [] mode = settings.routing.mode diff --git a/pyxray/libs/xray_config/settings.py b/pyxray/libs/xray_config/settings.py index 0dbf8db..5c8e5c1 100644 --- a/pyxray/libs/xray_config/settings.py +++ b/pyxray/libs/xray_config/settings.py @@ -43,6 +43,8 @@ class InboundSettings: http_port: int = 20171 rule_socks_port: int = 0 rule_http_port: int = 20172 + auth_user: str = "" + auth_password: str = "" vmess_port: int = 0 inbound_sniffing: str = "http,tls,quic" route_only: bool = False @@ -202,6 +204,8 @@ def validate_settings(settings: XrayConfigSettings) -> None: if not 1 <= settings.core.mux_concurrency <= 1024: raise ValueError("core.mux_concurrency must be between 1 and 1024") _validate_choice(settings.inbounds.inbound_sniffing, {"disable", "http,tls", "http,tls,quic"}, "inbounds.inbound_sniffing") + if bool(settings.inbounds.auth_user) != bool(settings.inbounds.auth_password): + raise ValueError("inbounds.auth_user and inbounds.auth_password must be set together") _validate_choice(settings.routing.mode, {"whitelist", "gfwlist", "custom", "routingA", "proxy", "direct", "block"}, "routing.mode") _validate_choice(settings.routing.default_rule, {"direct", "proxy", "block"}, "routing.default_rule") _validate_choice(settings.transparent.mode, {"close", "proxy", "whitelist", "gfwlist", "pac"}, "transparent.mode") diff --git a/pyxray/libs/xray_runtime.py b/pyxray/libs/xray_runtime.py index 417dc1f..eeeb4f4 100644 --- a/pyxray/libs/xray_runtime.py +++ b/pyxray/libs/xray_runtime.py @@ -292,7 +292,7 @@ def _inbound_networks(inbound: dict) -> set[str]: return {item.strip() for item in network.split(",") if item.strip() in {"tcp", "udp"}} if protocol == "dokodemo-door": return {"tcp", "udp"} - if protocol == "socks" and (inbound.get("settings") or {}).get("udp"): + if protocol in {"socks", "mixed"} and (inbound.get("settings") or {}).get("udp"): return {"tcp", "udp"} return {"tcp"} diff --git a/pyxray/web/templates/configs/inbounds.html b/pyxray/web/templates/configs/inbounds.html index 0a8f424..b1500d6 100644 --- a/pyxray/web/templates/configs/inbounds.html +++ b/pyxray/web/templates/configs/inbounds.html @@ -1,22 +1,28 @@

入站端口

- +
+ + +
@@ -40,6 +46,7 @@ + diff --git a/pyxray/web/templates/partials/download_tab.html b/pyxray/web/templates/partials/download_tab.html index c389f41..8ad199d 100644 --- a/pyxray/web/templates/partials/download_tab.html +++ b/pyxray/web/templates/partials/download_tab.html @@ -35,7 +35,7 @@