Files
pyxray/tests/libs/test_xray_config.py
chuan b0af17bcb4
Docker Build / docker-build (push) Successful in 18s
feat: 实现 fake dns
2026-05-28 00:34:54 +08:00

459 lines
20 KiB
Python

from __future__ import annotations
import base64
import json
import pytest
from pyxray.libs.nodes import parse_node_link
from pyxray.libs.xray_config import XrayConfigSettings, XrayConfigSettingsStore, generate_xray_config
from pyxray.libs.xray_config.settings import CustomInboundSettings, CustomRoutingRuleSettings, DnsRuleSettings, validate_settings
def test_settings_store_round_trips_all_sections(tmp_path) -> None:
settings = XrayConfigSettings()
settings.core.log_level = "debug"
settings.core.tcp_fast_open = "yes"
settings.core.mux_enabled = True
settings.inbounds.port_sharing = True
settings.inbounds.api.port = 2017
settings.inbounds.custom.append(CustomInboundSettings(tag="lan-socks", protocol="socks", port=2080))
settings.routing.mode = "custom"
settings.routing.custom_rules.append(
CustomRoutingRuleSettings(filename="geosite.dat", tags=["cn"], match_type="domain", rule_type="direct")
)
settings.transparent.mode = "proxy"
settings.transparent.type = "tproxy"
settings.transparent.tproxy_white_country_codes = ["cn"]
settings.dns.query_strategy = "UseIP"
settings.dns.rules.append(DnsRuleSettings(server="https://dns.google/dns-query", domains="geosite:google", outbound="proxy"))
settings.outbounds[0].probe_interval = "30s"
settings.auto_update.gfwlist_auto_update_mode = "auto_update"
store = XrayConfigSettingsStore(tmp_path / "settings.toml")
store.save(settings)
loaded = store.load()
assert loaded.core.log_level == "debug"
assert loaded.core.tcp_fast_open == "yes"
assert loaded.core.mux_enabled is True
assert loaded.inbounds.port_sharing is True
assert loaded.inbounds.api.port == 2017
assert loaded.inbounds.custom[0].tag == "lan-socks"
assert loaded.routing.custom_rules[0].filename == "geosite.dat"
assert loaded.transparent.tproxy_white_country_codes == ["cn"]
assert loaded.dns.rules[-1].server == "https://dns.google/dns-query"
assert loaded.outbounds[0].probe_interval == "30s"
assert loaded.auto_update.gfwlist_auto_update_mode == "auto_update"
def test_settings_store_loads_toml_arrays_inside_dict(tmp_path) -> None:
store = XrayConfigSettingsStore(tmp_path / "settings.toml")
store.path.write_text(
"""
[dns.hosts]
"courier.push.apple.com" = ["1-courier.push.apple.com"]
""".strip(),
encoding="utf-8",
)
settings = store.load()
assert settings.to_dict()["dns"]["hosts"]["courier.push.apple.com"] == ["1-courier.push.apple.com"]
def test_settings_store_migrates_legacy_log_levels(tmp_path) -> None:
store = XrayConfigSettingsStore(tmp_path / "settings.toml")
store.path.write_text('[core]\nlog_level = "trace"\n', encoding="utf-8")
settings = store.load()
assert settings.core.log_level == "debug"
def test_settings_defaults_match_v2raya_core_values() -> None:
settings = XrayConfigSettings()
assert settings.core.log_level == "info"
assert settings.core.mux_concurrency == 8
assert settings.inbounds.socks_port == 20170
assert settings.inbounds.http_port == 20171
assert settings.inbounds.rule_socks_port == 0
assert settings.inbounds.rule_http_port == 20172
assert settings.inbounds.auth_user == ""
assert settings.inbounds.auth_password == ""
assert settings.transparent.mode == "close"
assert settings.transparent.type == "redirect"
assert settings.transparent.port == 52345
assert settings.transparent.docker_transparent is True
assert settings.transparent.docker_transparent_cidrs == "172.16.0.0/12"
assert settings.transparent.output_bypass_rules == ""
assert settings.dns.query_strategy == "UseIPv4"
assert settings.dns.special_mode == "none"
assert settings.dns.fakedns_domains == "geosite:geolocation-!cn"
assert settings.dns.rules == [
DnsRuleSettings(server="localhost", domains="geosite:private", outbound="direct"),
DnsRuleSettings(server="223.5.5.5", domains="geosite:cn", outbound="direct"),
DnsRuleSettings(server="8.8.8.8", domains="", outbound="proxy"),
]
def test_generate_default_config_matches_v2raya_template_shape() -> None:
node = parse_node_link(_ss_link())
config = generate_xray_config(node)
assert config["log"] == {"loglevel": "info", "access": "", "error": ""}
assert _inbound(config, "socks")["port"] == 20170
assert _inbound(config, "socks")["protocol"] == "socks"
assert _inbound(config, "socks")["settings"]["udp"] is True
assert _inbound(config, "http")["port"] == 20171
assert _inbound(config, "rule-mixed")["port"] == 20172
assert _inbound(config, "rule-mixed")["protocol"] == "mixed"
assert _inbound(config, "rule-mixed")["settings"] == {"auth": "noauth", "udp": True, "allowTransparent": False}
assert "rule-http" not in {item["tag"] for item in config["inbounds"]}
assert "rule-socks" not in {item["tag"] for item in config["inbounds"]}
assert _outbound(config, "proxy")["protocol"] == "shadowsocks"
assert _outbound(config, "direct")["protocol"] == "freedom"
assert _outbound(config, "direct")["settings"]["domainStrategy"] == "UseIP"
assert _outbound(config, "block")["protocol"] == "blackhole"
assert _outbound(config, "dns-out")["protocol"] == "dns"
assert config["routing"]["domainStrategy"] == "IPOnDemand"
assert config["routing"]["domainMatcher"] == "mph"
assert config["dns"]["hosts"]["courier.push.apple.com"] == ["1-courier.push.apple.com"]
assert config["dns"]["queryStrategy"] == "UseIPv4"
assert "dns-in" not in {item["tag"] for item in config["inbounds"]}
def test_generate_mixed_inbound_with_password_auth() -> None:
settings = XrayConfigSettings()
settings.inbounds.auth_user = "alice"
settings.inbounds.auth_password = "secret"
mixed = _inbound(generate_xray_config(parse_node_link(_ss_link()), settings), "rule-mixed")
assert mixed["protocol"] == "mixed"
assert mixed["settings"] == {
"auth": "password",
"udp": True,
"allowTransparent": False,
"accounts": [{"user": "alice", "pass": "secret"}],
}
def test_generate_none_log_level_disables_xray_logs() -> None:
settings = XrayConfigSettings()
settings.core.log_level = "none"
config = generate_xray_config(parse_node_link(_ss_link()), settings)
assert config["log"] == {"loglevel": "none", "access": "none", "error": "none"}
def test_generate_redirect_dns_inbound_when_transparent_redirect_enabled() -> None:
settings = XrayConfigSettings()
settings.transparent.mode = "proxy"
settings.transparent.type = "redirect"
config = generate_xray_config(parse_node_link(_ss_link()), settings)
assert _inbound(config, "transparent")["listen"] == "0.0.0.0"
assert _inbound(config, "dns-in")["listen"] == "127.2.0.17"
assert {"type": "field", "inboundTag": ["dns-in"], "outboundTag": "dns-out"} in config["routing"]["rules"]
assert _outbound(config, "proxy")["streamSettings"]["sockopt"]["mark"] == 128
assert _outbound(config, "direct")["streamSettings"]["sockopt"]["mark"] == 128
def test_generate_redirect_transparent_inbound_stays_local_when_docker_transparent_disabled() -> None:
settings = XrayConfigSettings()
settings.transparent.mode = "proxy"
settings.transparent.type = "redirect"
settings.transparent.docker_transparent = False
config = generate_xray_config(parse_node_link(_ss_link()), settings)
assert _inbound(config, "transparent")["listen"] == "127.0.0.1"
def test_generate_lan_dns_inbound_when_redirect_and_port_sharing_enabled() -> None:
settings = XrayConfigSettings()
settings.inbounds.port_sharing = True
settings.transparent.mode = "proxy"
settings.transparent.type = "redirect"
config = generate_xray_config(parse_node_link(_ss_link()), settings)
assert _inbound(config, "dns-in")["listen"] == "0.0.0.0"
assert _inbound(config, "dns-in-local")["listen"] == "127.2.0.17"
assert {"type": "field", "inboundTag": ["dns-in", "dns-in-local"], "outboundTag": "dns-out"} in config["routing"]["rules"]
def test_skip_local_dns_inbound_for_tproxy_and_tun_like_v2raya() -> None:
node = parse_node_link(_ss_link())
settings = XrayConfigSettings()
settings.transparent.mode = "proxy"
settings.transparent.type = "tproxy"
tproxy_config = generate_xray_config(node, settings)
assert "dns-in" not in {item["tag"] for item in tproxy_config["inbounds"]}
settings.transparent.type = "tun"
tun_config = generate_xray_config(node, settings)
assert "dns-in" not in {item["tag"] for item in tun_config["inbounds"]}
def test_generate_vless_reality_outbound_stream_settings() -> None:
settings = XrayConfigSettings()
settings.core.mux_enabled = True
settings.core.tcp_fast_open = "yes"
node = parse_node_link(
"vless://00000000-0000-4000-8000-000000000001@example.com:443"
"?type=tcp&security=reality&encryption=none&flow=xtls-rprx-vision"
"&sni=www.example.com&fp=chrome&pbk=public-key&sid=abcd&spx=%2F#hk"
)
outbound = _outbound(generate_xray_config(node, settings), "proxy")
assert outbound["protocol"] == "vless"
assert outbound["settings"]["vnext"][0]["users"][0]["flow"] == "xtls-rprx-vision"
assert outbound["streamSettings"]["security"] == "reality"
assert outbound["streamSettings"]["realitySettings"]["serverName"] == "www.example.com"
assert outbound["streamSettings"]["realitySettings"]["publicKey"] == "public-key"
assert outbound["streamSettings"]["sockopt"]["tcpFastOpen"] is True
assert outbound["mux"] == {"enabled": True, "concurrency": 8}
def test_generate_direct_outbound_uses_shared_sockopt_settings() -> None:
settings = XrayConfigSettings()
settings.core.tcp_fast_open = "yes"
settings.transparent.mode = "proxy"
settings.transparent.type = "redirect"
direct = _outbound(generate_xray_config(parse_node_link(_ss_link()), settings), "direct")
assert direct["streamSettings"]["sockopt"] == {"tcpFastOpen": True, "mark": 128}
def test_generate_vmess_ws_tls_outbound() -> None:
payload = {
"ps": "vmess-node",
"add": "vmess.example.net",
"port": "443",
"id": "00000000-0000-4000-8000-000000000002",
"aid": "0",
"scy": "auto",
"net": "websocket",
"type": "none",
"host": "cdn.example.net",
"path": "/ray",
"tls": "tls",
"sni": "sni.example.net",
}
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
outbound = _outbound(generate_xray_config(parse_node_link(f"vmess://{encoded}")), "proxy")
assert outbound["protocol"] == "vmess"
assert outbound["settings"]["vnext"][0]["address"] == "vmess.example.net"
assert outbound["settings"]["vnext"][0]["users"][0]["alterId"] == 0
assert outbound["streamSettings"]["network"] == "ws"
assert outbound["streamSettings"]["security"] == "tls"
assert outbound["streamSettings"]["tlsSettings"]["serverName"] == "sni.example.net"
assert outbound["streamSettings"]["wsSettings"]["headers"]["Host"] == "cdn.example.net"
assert outbound["streamSettings"]["wsSettings"]["path"] == "/ray"
def test_generate_trojan_grpc_and_trojan_go_outbound() -> None:
trojan = parse_node_link(
"trojan://secret@example.org:443?type=grpc&serviceName=svc&sni=edge.example.org#trojan"
)
trojan_go = parse_node_link(
"trojan-go://secret@example.org:443?type=ws&host=cdn.example.org&path=%2Fgo&sni=edge.example.org#tg"
)
trojan_out = _outbound(generate_xray_config(trojan), "proxy")
trojan_go_out = _outbound(generate_xray_config(trojan_go), "proxy")
assert trojan_out["protocol"] == "trojan"
assert trojan_out["settings"]["servers"][0]["password"] == "secret"
assert trojan_out["streamSettings"]["grpcSettings"]["serviceName"] == "svc"
assert trojan_out["streamSettings"]["tlsSettings"]["serverName"] == "edge.example.org"
assert trojan_go_out["protocol"] == "trojan"
assert trojan_go_out["streamSettings"]["wsSettings"]["path"] == "/go"
def test_generate_vless_ws_early_data_grpc_and_xhttp_settings() -> None:
ws = parse_node_link(
"vless://00000000-0000-4000-8000-000000000001@example.com:443"
"?type=ws&security=tls&host=cdn.example&path=%2Fws&maxEarlyData=2048"
"&earlyDataHeaderName=Sec-WebSocket-Protocol#ws"
)
grpc = parse_node_link(
"vless://00000000-0000-4000-8000-000000000001@example.com:443"
"?type=grpc&security=tls&serviceName=svc&multiMode=true&idleTimeout=60"
"&healthCheckTimeout=20&permitWithoutStream=true&initialWindowsSize=65535#grpc"
)
xhttp = parse_node_link(
"vless://00000000-0000-4000-8000-000000000001@example.com:443"
"?type=xhttp&security=tls&host=cdn.example&path=%2Fxhttp#xhttp"
)
ws_stream = _outbound(generate_xray_config(ws), "proxy")["streamSettings"]
grpc_stream = _outbound(generate_xray_config(grpc), "proxy")["streamSettings"]
xhttp_stream = _outbound(generate_xray_config(xhttp), "proxy")["streamSettings"]
assert ws_stream["wsSettings"]["maxEarlyData"] == 2048
assert ws_stream["wsSettings"]["earlyDataHeaderName"] == "Sec-WebSocket-Protocol"
assert grpc_stream["grpcSettings"]["multiMode"] is True
assert grpc_stream["grpcSettings"]["idle_timeout"] == 60
assert grpc_stream["grpcSettings"]["health_check_timeout"] == 20
assert grpc_stream["grpcSettings"]["permit_without_stream"] is True
assert grpc_stream["grpcSettings"]["initial_windows_size"] == 65535
assert xhttp_stream["xhttpSettings"] == {"path": "/xhttp", "host": "cdn.example", "mode": "auto"}
def test_generate_inbounds_custom_api_and_transparent_tproxy() -> None:
settings = XrayConfigSettings()
settings.inbounds.port_sharing = True
settings.inbounds.vmess_port = 20174
settings.inbounds.api.port = 20175
settings.inbounds.custom.append(CustomInboundSettings(tag="extra-http", protocol="http", port=20176))
settings.transparent.mode = "proxy"
settings.transparent.type = "tproxy"
config = generate_xray_config(parse_node_link(_ss_link()), settings)
assert _inbound(config, "socks")["listen"] == "0.0.0.0"
assert _inbound(config, "rule-mixed")["listen"] == "0.0.0.0"
assert _inbound(config, "rule-mixed")["port"] == 20172
assert _inbound(config, "vmess")["protocol"] == "vmess"
assert _inbound(config, "extra-http")["protocol"] == "http"
assert _inbound(config, "transparent")["streamSettings"]["sockopt"]["tproxy"] == "tproxy"
assert _inbound(config, "api-in")["port"] == 20175
assert config["api"]["services"] == ["LoggerService"]
assert {"type": "field", "inboundTag": ["transparent"], "outboundTag": "proxy"} in config["routing"]["rules"]
def test_generate_routing_modes_follow_v2raya_rules() -> None:
settings = XrayConfigSettings()
whitelist = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"]
assert {"type": "field", "inboundTag": ["rule-mixed"], "domain": ["geosite:cn"], "outboundTag": "direct"} in whitelist
assert {"type": "field", "inboundTag": ["rule-mixed"], "domain": ["geosite:google"], "outboundTag": "proxy"} in whitelist
settings.routing.mode = "gfwlist"
gfwlist = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"]
assert {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "direct"} in gfwlist
assert any("91.108.4.0/22" in rule.get("ip", []) for rule in gfwlist)
def test_generate_custom_routing_a_applies_before_proxy_mode_fallback() -> None:
settings = XrayConfigSettings()
settings.routing.mode = "proxy"
settings.routing.routing_a = "domain(keyword:google)->direct"
rules = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"]
google_rule = {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "direct", "domain": ["keyword:google"]}
fallback = {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "proxy"}
assert google_rule in rules
assert fallback in rules
assert rules.index(google_rule) < rules.index(fallback)
def test_generate_custom_text_rules_before_route_mode_and_default_proxy() -> None:
settings = XrayConfigSettings()
settings.routing.mode = "whitelist"
settings.routing.default_rule = "proxy"
settings.routing.routing_a = "domain(geosite:google, domain:example.com)->proxy\nip(geoip:cn)->direct"
rules = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"]
custom_domain = {
"type": "field",
"inboundTag": ["rule-mixed"],
"domain": ["geosite:google", "domain:example.com"],
"outboundTag": "proxy",
}
custom_ip = {
"type": "field",
"inboundTag": ["rule-mixed"],
"ip": ["geoip:cn"],
"outboundTag": "direct",
}
assert rules.index(custom_domain) < rules.index({"type": "field", "inboundTag": ["rule-mixed"], "domain": ["geosite:cn"], "outboundTag": "direct"})
assert rules.index(custom_ip) < rules.index({"type": "field", "inboundTag": ["rule-mixed"], "ip": ["geoip:private", "geoip:cn"], "outboundTag": "direct"})
assert {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "proxy"} in rules
def test_generate_dns_rules_and_dns_routing() -> None:
settings = XrayConfigSettings()
settings.dns.query_strategy = "UseIP"
settings.dns.disable_fallback = True
settings.dns.rules = [
DnsRuleSettings(server="localhost", domains="geosite:private", outbound="direct"),
DnsRuleSettings(server="223.5.5.5", domains="geosite:cn", outbound="direct"),
DnsRuleSettings(server="https://dns.google/dns-query", domains="", outbound="proxy"),
]
config = generate_xray_config(parse_node_link(_ss_link()), settings)
assert config["dns"]["queryStrategy"] == "UseIP"
assert config["dns"]["disableFallback"] is True
assert {"address": "localhost", "domains": ["geosite:private"]} in config["dns"]["servers"]
assert {"address": "223.5.5.5", "domains": ["geosite:cn"]} in config["dns"]["servers"]
assert "https://dns.google/dns-query" in config["dns"]["servers"]
assert any(rule.get("domain") == ["dns.google"] and rule.get("outboundTag") == "proxy" for rule in config["routing"]["rules"])
def test_generate_fakedns_adds_dns_server_pool_and_sniffing() -> None:
settings = XrayConfigSettings()
settings.transparent.mode = "gfwlist"
settings.transparent.type = "redirect"
settings.dns.special_mode = "fakedns"
config = generate_xray_config(parse_node_link(_ss_link()), settings)
assert config["fakedns"] == [{"ipPool": "198.18.0.0/15", "poolSize": 65535}]
assert {"address": "fakedns", "domains": ["geosite:geolocation-!cn"]} in config["dns"]["servers"]
assert "fakedns" in _inbound(config, "transparent")["sniffing"]["destOverride"]
assert "fakedns" in _inbound(config, "rule-mixed")["sniffing"]["destOverride"]
def test_generate_fakedns_uses_custom_domain_scope() -> None:
settings = XrayConfigSettings()
settings.dns.special_mode = "fakedns"
settings.dns.fakedns_domains = "geosite:gfw\nkeyword:example"
config = generate_xray_config(parse_node_link(_ss_link()), settings)
assert {"address": "fakedns", "domains": ["geosite:gfw", "keyword:example"]} in config["dns"]["servers"]
def test_validate_settings_rejects_invalid_values() -> None:
settings = XrayConfigSettings()
settings.transparent.type = "bad"
with pytest.raises(ValueError, match="transparent.type"):
validate_settings(settings)
def test_validate_settings_requires_complete_inbound_auth() -> None:
settings = XrayConfigSettings()
settings.inbounds.auth_user = "alice"
with pytest.raises(ValueError, match="auth_user"):
validate_settings(settings)
def _ss_link() -> str:
user = base64.urlsafe_b64encode(b"chacha20-ietf-poly1305:secret").decode().rstrip("=")
return f"ss://{user}@ss.example.net:8388#ss-node"
def _inbound(config: dict, tag: str) -> dict:
return next(item for item in config["inbounds"] if item.get("tag") == tag)
def _outbound(config: dict, tag: str) -> dict:
return next(item for item in config["outbounds"] if item.get("tag") == tag)