diff --git a/app/core/hosts.py b/app/core/hosts.py index f37f709b..fb721938 100644 --- a/app/core/hosts.py +++ b/app/core/hosts.py @@ -194,9 +194,19 @@ async def _prepare_subscription_inbound_data( path=path, host=host_list, mode=mode, - no_grpc_header=xs.no_grpc_header if xs else None, - sc_max_each_post_bytes=xs.sc_max_each_post_bytes if xs else None, - sc_min_posts_interval_ms=xs.sc_min_posts_interval_ms if xs else None, + no_grpc_header=xs.no_grpc_header + if xs and xs.no_grpc_header is not None + else inbound_config.get("no_grpc_header"), + sc_max_each_post_bytes=( + xs.sc_max_each_post_bytes + if xs and xs.sc_max_each_post_bytes is not None + else inbound_config.get("sc_max_each_post_bytes") + ), + sc_min_posts_interval_ms=( + xs.sc_min_posts_interval_ms + if xs and xs.sc_min_posts_interval_ms is not None + else inbound_config.get("sc_min_posts_interval_ms") + ), x_padding_bytes=xs.x_padding_bytes if xs and xs.x_padding_bytes is not None else inbound_config.get("x_padding_bytes"), @@ -251,9 +261,9 @@ async def _prepare_subscription_inbound_data( if xs and xs.uplink_chunk_size is not None else inbound_config.get("uplink_chunk_size") ), - xmux=xs.xmux.model_dump(by_alias=True, exclude_none=True) if xs and xs.xmux else None, - download_settings=down_settings if xs and down_settings else None, - http_headers=host.http_headers, + xmux=xs.xmux.model_dump(by_alias=True, exclude_none=True) if xs and xs.xmux else inbound_config.get("xmux"), + download_settings=down_settings if xs and down_settings else inbound_config.get("download_settings"), + http_headers=host.http_headers if host.http_headers is not None else inbound_config.get("http_headers"), random_user_agent=host.random_user_agent, ) elif network in ("grpc", "gun"): diff --git a/app/core/xray.py b/app/core/xray.py index 722b1497..a0ea7672 100644 --- a/app/core/xray.py +++ b/app/core/xray.py @@ -294,20 +294,23 @@ def _handle_httpupgrade_settings(self, net_settings: dict, settings: dict, inbou def _handle_xhttp_settings(self, net_settings: dict, settings: dict, inbound_tag: str = ""): """Handle XHTTP network settings.""" - extra = net_settings.get("extra", {}) - if not isinstance(extra, dict): + extra = net_settings.get("extra") + has_extra = isinstance(extra, dict) + if not has_extra: extra = {} def get_xhttp_value(key: str): - value = extra.get(key) - if value is None: - value = net_settings.get(key) - return value + if has_extra: + return extra.get(key) + return net_settings.get(key) settings["path"] = net_settings.get("path", "") host = net_settings.get("host", "") settings["host"] = [host] settings["mode"] = net_settings.get("mode", "auto") + settings["no_grpc_header"] = get_xhttp_value("noGRPCHeader") + settings["sc_max_each_post_bytes"] = get_xhttp_value("scMaxEachPostBytes") + settings["sc_min_posts_interval_ms"] = get_xhttp_value("scMinPostsIntervalMs") settings["x_padding_bytes"] = get_xhttp_value("xPaddingBytes") settings["x_padding_obfs_mode"] = get_xhttp_value("xPaddingObfsMode") settings["x_padding_key"] = get_xhttp_value("xPaddingKey") @@ -322,6 +325,12 @@ def get_xhttp_value(key: str): settings["uplink_data_placement"] = get_xhttp_value("uplinkDataPlacement") settings["uplink_data_key"] = get_xhttp_value("uplinkDataKey") settings["uplink_chunk_size"] = get_xhttp_value("uplinkChunkSize") + settings["xmux"] = get_xhttp_value("xmux") + settings["download_settings"] = get_xhttp_value("downloadSettings") + + headers = get_xhttp_value("headers") + if isinstance(headers, dict): + settings["http_headers"] = {k: v for k, v in headers.items() if isinstance(k, str) and isinstance(v, str)} def _handle_kcp_settings(self, net_settings: dict, settings: dict, inbound_tag: str = ""): """Handle KCP network settings.""" diff --git a/app/models/subscription.py b/app/models/subscription.py index 48c53b60..4fe5d183 100644 --- a/app/models/subscription.py +++ b/app/models/subscription.py @@ -104,7 +104,9 @@ class XHTTPTransportConfig(BaseTransportConfig): sc_min_posts_interval_ms: str | int | None = Field( None, serialization_alias="scMinPostsIntervalMs", pattern=r"^\d{1,16}(?:-\d{1,16})?$" ) - x_padding_bytes: str | None = Field(None, serialization_alias="xPaddingBytes") + x_padding_bytes: str | int | None = Field( + None, serialization_alias="xPaddingBytes", pattern=r"^\d{1,16}(?:-\d{1,16})?$" + ) x_padding_obfs_mode: bool | None = Field(None, serialization_alias="xPaddingObfsMode") x_padding_key: str | None = Field(None, serialization_alias="xPaddingKey") x_padding_header: str | None = Field(None, serialization_alias="xPaddingHeader") @@ -125,6 +127,19 @@ class XHTTPTransportConfig(BaseTransportConfig): http_headers: dict[str, str] | None = Field(None) random_user_agent: bool = Field(False) + @field_validator( + "sc_max_each_post_bytes", + "sc_min_posts_interval_ms", + "x_padding_bytes", + "uplink_chunk_size", + mode="before", + ) + @classmethod + def normalize_numeric_or_range_fields(cls, value): + if isinstance(value, int): + return str(value) + return value + class KCPTransportConfig(BaseTransportConfig): """KCP transport - only kcp-specific fields""" diff --git a/app/subscription/clash.py b/app/subscription/clash.py index e5f33ffe..5d6aa08b 100644 --- a/app/subscription/clash.py +++ b/app/subscription/clash.py @@ -2,6 +2,7 @@ from uuid import UUID import yaml +from pydantic import BaseModel from app.models.subscription import ( GRPCTransportConfig, @@ -153,7 +154,7 @@ def _transport_tcp(self, config: TCPTransportConfig, path: str): def _transport_xhttp(self, config: XHTTPTransportConfig, path: str, random_user_agent: bool = False): """Build XHTTP transport config for Clash Meta""" - host = config.host if isinstance(config.host, str) else "" + host = self._select_host(config.host) http_headers = {k: v for k, v in (config.http_headers or {}).items() if k not in ("Host", "host")} result = { @@ -163,15 +164,210 @@ def _transport_xhttp(self, config: XHTTPTransportConfig, path: str, random_user_ "headers": http_headers if http_headers else None, "no-grpc-header": config.no_grpc_header, "x-padding-bytes": config.x_padding_bytes, - "download-settings": config.download_settings, + "x-padding-obfs-mode": config.x_padding_obfs_mode, + "x-padding-key": config.x_padding_key, + "x-padding-header": config.x_padding_header, + "x-padding-placement": config.x_padding_placement, + "x-padding-method": config.x_padding_method, + "uplink-http-method": config.uplink_http_method, + "session-placement": config.session_placement, + "session-key": config.session_key, + "seq-placement": config.seq_placement, + "seq-key": config.seq_key, + "uplink-data-placement": config.uplink_data_placement, + "uplink-data-key": config.uplink_data_key, + "uplink-chunk-size": config.uplink_chunk_size, + "sc-max-each-post-bytes": config.sc_max_each_post_bytes, + "sc-min-posts-interval-ms": config.sc_min_posts_interval_ms, + "reuse-settings": self._mihomo_reuse_settings(config.xmux), + "download-settings": self._mihomo_download_settings(config.download_settings), } if random_user_agent: headers = result.get("headers") or {} - headers["User-Agent"] = choice(self.user_agent_list) - result["headers"] = headers + user_agents = ( + self.grpc_user_agent_data + if config.mode in ("stream-one", "stream-up") and not config.no_grpc_header + else self.user_agent_list + ) + if user_agents: + headers["User-Agent"] = choice(user_agents) + result["headers"] = headers - return self._normalize_and_remove_none_values(result) + return self._normalize_mihomo_xhttp_opts(result) + + @staticmethod + def _select_host(host: list[str] | str) -> str: + if isinstance(host, str): + return host + if host: + return host[0] + return "" + + def _mihomo_download_settings(self, download_settings: SubscriptionInboundData | dict | None) -> dict | None: + if isinstance(download_settings, SubscriptionInboundData): + return self._mihomo_download_settings_from_inbound(download_settings) + + if isinstance(download_settings, dict): + if "streamSettings" in download_settings or "address" in download_settings: + return self._mihomo_download_settings_from_xray(download_settings) + return self._normalize_mihomo_xhttp_opts(download_settings) + + return None + + def _mihomo_download_settings_from_xray(self, download_settings: dict) -> dict: + stream_settings = download_settings.get("streamSettings") or {} + if not isinstance(stream_settings, dict): + stream_settings = {} + + xhttp_settings = download_settings.get("xhttpSettings") or stream_settings.get("xhttpSettings") or {} + if not isinstance(xhttp_settings, dict): + xhttp_settings = {} + + extra = xhttp_settings.get("extra") or {} + if not isinstance(extra, dict): + extra = {} + + security = download_settings.get("security") or stream_settings.get("security") + tls_settings = download_settings.get(f"{security}Settings") or stream_settings.get(f"{security}Settings") or {} + if not isinstance(tls_settings, dict): + tls_settings = {} + + result = { + "path": xhttp_settings.get("path"), + "host": xhttp_settings.get("host"), + "headers": self._mihomo_http_headers(extra.get("headers")), + "reuse-settings": self._mihomo_reuse_settings(extra.get("xmux")), + "server": download_settings.get("address"), + "port": self._select_port(download_settings.get("port")), + "tls": True if security and security != "none" else None, + "alpn": tls_settings.get("alpn"), + "skip-cert-verify": tls_settings.get("allowInsecure"), + "servername": tls_settings.get("serverName"), + "client-fingerprint": tls_settings.get("fingerprint"), + "reality-opts": { + "public-key": tls_settings.get("publicKey"), + "short-id": tls_settings.get("shortId") or "", + "support-x25519mlkem768": bool(tls_settings.get("mldsa65Verify")), + } + if security == "reality" and tls_settings.get("publicKey") + else None, + } + + return self._normalize_mihomo_xhttp_opts(result) + + def _mihomo_download_settings_from_inbound(self, inbound: SubscriptionInboundData) -> dict: + transport_config = inbound.transport_config + result = { + "server": self._select_address(inbound.address), + "port": self._select_port(inbound.port), + } + + if inbound.network in ("xhttp", "splithttp") and isinstance(transport_config, XHTTPTransportConfig): + result.update( + { + "path": transport_config.path or "/", + "host": self._select_host(transport_config.host), + "headers": self._mihomo_http_headers(transport_config.http_headers), + "reuse-settings": self._mihomo_reuse_settings(transport_config.xmux), + } + ) + + self._apply_mihomo_download_tls(result, inbound.tls_config) + + return self._normalize_mihomo_xhttp_opts(result) + + @staticmethod + def _mihomo_http_headers(headers: dict | None) -> dict | None: + if not headers: + return None + + filtered_headers = {k: v for k, v in headers.items() if k not in ("Host", "host")} + return filtered_headers or None + + @staticmethod + def _select_address(address: list[str] | str) -> str: + if isinstance(address, str): + return address + if address: + return address[0] + return "" + + def _apply_mihomo_download_tls(self, node: dict, tls_config: TLSConfig): + if not tls_config.tls: + return + + node["tls"] = True + sni = tls_config.sni if isinstance(tls_config.sni, str) else (tls_config.sni[0] if tls_config.sni else "") + node["servername"] = sni + + if tls_config.alpn_list: + node["alpn"] = tls_config.alpn_list + + node["skip-cert-verify"] = tls_config.allowinsecure + + if tls_config.fingerprint: + node["client-fingerprint"] = tls_config.fingerprint + + if tls_config.tls == "reality" and tls_config.reality_public_key: + node["reality-opts"] = { + "public-key": tls_config.reality_public_key, + "short-id": tls_config.reality_short_id or "", + "support-x25519mlkem768": bool(tls_config.mldsa65_verify), + } + + @staticmethod + def _mihomo_reuse_settings(xmux: dict | BaseModel | None) -> dict | None: + """Convert Xray XMUX settings to Mihomo reuse-settings.""" + if not xmux: + return None + + if isinstance(xmux, BaseModel): + xmux = xmux.model_dump(by_alias=True, exclude_none=True) + + key_map = { + "maxConcurrency": "max-concurrency", + "max_concurrency": "max-concurrency", + "maxConnections": "max-connections", + "max_connections": "max-connections", + "cMaxReuseTimes": "c-max-reuse-times", + "c_max_reuse_times": "c-max-reuse-times", + "hMaxRequestTimes": "h-max-request-times", + "h_max_request_times": "h-max-request-times", + "hMaxReusableSecs": "h-max-reusable-secs", + "h_max_reusable_secs": "h-max-reusable-secs", + "hKeepAlivePeriod": "h-keep-alive-period", + "h_keep_alive_period": "h-keep-alive-period", + } + result = {key_map.get(key, key): value for key, value in xmux.items()} + + return ClashConfiguration._normalize_mihomo_xhttp_opts(result) + + @staticmethod + def _normalize_mihomo_xhttp_opts(data: dict) -> dict: + """Remove empty values while preserving explicit False and 0 values supported by Mihomo.""" + + def clean_dict(value: dict) -> dict: + cleaned = {} + for key, item in value.items(): + if item is None or item == "": + continue + if key == "headers" and isinstance(item, dict): + headers = {header_key: header_value for header_key, header_value in item.items() if header_value is not None} + if headers: + cleaned[key] = headers + continue + if isinstance(item, dict): + nested = clean_dict(item) + if nested: + cleaned[key] = nested + continue + if isinstance(item, BaseModel): + item = item.model_dump(by_alias=True, exclude_none=True) + cleaned[key] = item + return cleaned + + return clean_dict(data) def _apply_tls(self, node: dict, tls_config: TLSConfig, protocol: str): """Apply TLS settings to node""" @@ -229,6 +425,8 @@ def _apply_transport( net_opts = handler(inbound.transport_config, path, is_httpupgrade, random_user_agent) elif network == "http": net_opts = handler(inbound.transport_config, path, random_user_agent) + elif network == "xhttp": + net_opts = handler(inbound.transport_config, path, random_user_agent) else: net_opts = handler(inbound.transport_config, path) @@ -345,8 +543,14 @@ def _build_wireguard( return self._normalize_and_remove_none_values(node) @staticmethod - def _select_port(port: int | str) -> int: + def _select_port(port: int | str | list[int] | list[str] | None) -> int | None: """Normalize port values from subscription data.""" + if port is None: + return None + if isinstance(port, list): + if not port: + return None + port = port[0] if isinstance(port, str): try: return int(port) @@ -543,7 +747,9 @@ def _parse_wireguard_reserved(reserved: str | None) -> list[int] | str | None: def add(self, remark: str, address: str, inbound: SubscriptionInboundData, settings: dict): # not supported by clash-meta - if inbound.network in ("kcp"): + if inbound.network == "kcp": + return + if inbound.network in ("splithttp", "xhttp") and inbound.protocol != "vless": return # QUIC with header not supported diff --git a/app/subscription/share.py b/app/subscription/share.py index d966f2c9..7d62c071 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -370,14 +370,17 @@ def _resolve_host_xray_template_content(inbound: SubscriptionInboundData) -> str download_settings = getattr(inbound_copy.transport_config, "download_settings", None) if download_settings: - processed_download_settings = await _prepare_download_settings( - download_settings, - format_variables, - user.inbounds, - proxy_settings, - client_templates, - conf, - ) + if isinstance(download_settings, SubscriptionInboundData): + processed_download_settings = await _prepare_download_settings( + download_settings, + format_variables, + user.inbounds, + proxy_settings, + client_templates, + conf, + ) + else: + processed_download_settings = download_settings if hasattr(inbound_copy.transport_config, "download_settings"): inbound_copy.transport_config.download_settings = processed_download_settings diff --git a/app/subscription/xray.py b/app/subscription/xray.py index 789710f4..03ef2c0c 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -158,7 +158,7 @@ def _transport_xhttp(self, config: XHTTPTransportConfig, path: str) -> dict: "uplinkChunkSize": config.uplink_chunk_size, "noGRPCHeader": config.no_grpc_header, "xmux": config.xmux, - "downloadSettings": self._download_config(config.download_settings) if config.download_settings else None, + "downloadSettings": self._xhttp_download_config(config.download_settings) if config.download_settings else None, } if config.random_user_agent: @@ -170,6 +170,11 @@ def _transport_xhttp(self, config: XHTTPTransportConfig, path: str) -> dict: xhttp_settings["extra"] = extra return self._normalize_and_remove_none_values(xhttp_settings) + def _xhttp_download_config(self, download_settings: SubscriptionInboundData | dict) -> dict: + if isinstance(download_settings, dict): + return download_settings + return self._download_config(download_settings) + def _transport_grpc(self, config: GRPCTransportConfig, path: str) -> dict: """Handle GRPC transport - only gets GRPC config""" host = config.host if isinstance(config.host, str) else (config.host[0] if config.host else "") diff --git a/tests/test_subscription_clash_xhttp.py b/tests/test_subscription_clash_xhttp.py new file mode 100644 index 00000000..358a6cb9 --- /dev/null +++ b/tests/test_subscription_clash_xhttp.py @@ -0,0 +1,282 @@ +from app.core.xray import XRayConfig +from app.models.subscription import SubscriptionInboundData, TLSConfig, XHTTPTransportConfig +from app.subscription.clash import ClashConfiguration, ClashMetaConfiguration + + +USER_ID = "11111111-1111-1111-1111-111111111111" + + +def _xhttp_inbound( + *, + protocol: str = "vless", + transport_config: XHTTPTransportConfig | None = None, + tls_config: TLSConfig | None = None, + address: str = "edge.example.com", + port: int = 443, +) -> SubscriptionInboundData: + return SubscriptionInboundData( + remark="xhttp", + inbound_tag="xhttp-inbound", + protocol=protocol, + address=address, + port=port, + network="xhttp", + tls_config=tls_config or TLSConfig(tls="tls", sni="sni.example.com", fingerprint="chrome"), + transport_config=transport_config or XHTTPTransportConfig(path="/up", host="cdn.example.com", mode="stream-up"), + priority=0, + ) + + +def test_classic_clash_skips_xhttp_but_clash_meta_generates_it(): + classic = ClashConfiguration() + classic.add( + "classic xhttp", + "edge.example.com", + _xhttp_inbound(protocol="vmess"), + {"id": USER_ID}, + ) + + meta = ClashMetaConfiguration() + meta.add("meta xhttp", "edge.example.com", _xhttp_inbound(), {"id": USER_ID}) + + assert classic.data["proxies"] == [] + assert len(meta.data["proxies"]) == 1 + assert meta.data["proxies"][0]["network"] == "xhttp" + assert meta.data["proxies"][0]["xhttp-opts"]["path"] == "/up" + + +def test_clash_meta_skips_non_vless_xhttp(): + meta = ClashMetaConfiguration() + + meta.add( + "meta vmess xhttp", + "edge.example.com", + _xhttp_inbound(protocol="vmess"), + {"id": USER_ID}, + ) + + assert meta.data["proxies"] == [] + + +def test_clash_meta_xhttp_opts_include_advanced_fields_and_download_settings(): + download_settings = _xhttp_inbound( + transport_config=XHTTPTransportConfig( + path="/down", + host="download-host.example.com", + http_headers={"Host": "ignored.example.com", "X-Down": "1"}, + xmux={"maxConcurrency": "4-8", "hKeepAlivePeriod": 0}, + ), + tls_config=TLSConfig( + tls="tls", + sni="download-sni.example.com", + fingerprint="chrome", + allowinsecure=False, + alpn_list=["h2"], + ), + address="download.example.com", + port=8443, + ) + transport_config = XHTTPTransportConfig( + path="/up", + host="cdn.example.com", + mode="packet-up", + no_grpc_header=False, + sc_max_each_post_bytes="1000000", + sc_min_posts_interval_ms="30", + x_padding_bytes="100-1000", + x_padding_obfs_mode=False, + x_padding_key="x_padding", + x_padding_header="Referer", + x_padding_placement="queryInHeader", + x_padding_method="tokenish", + uplink_http_method="PATCH", + session_placement="query", + session_key="sid", + seq_placement="header", + seq_key="seq", + uplink_data_placement="cookie", + uplink_data_key="data", + uplink_chunk_size="3072", + xmux={"maxConcurrency": "16-32", "c_max_reuse_times": "2", "hKeepAlivePeriod": 0}, + download_settings=download_settings, + http_headers={"Host": "ignored.example.com", "X-Test": "1", "X-Forwarded-For": ""}, + ) + + meta = ClashMetaConfiguration() + meta.add("meta xhttp", "edge.example.com", _xhttp_inbound(transport_config=transport_config), {"id": USER_ID}) + + opts = meta.data["proxies"][0]["xhttp-opts"] + assert opts["headers"] == {"X-Test": "1", "X-Forwarded-For": ""} + assert opts["no-grpc-header"] is False + assert opts["x-padding-obfs-mode"] is False + assert opts["x-padding-key"] == "x_padding" + assert opts["x-padding-header"] == "Referer" + assert opts["x-padding-placement"] == "queryInHeader" + assert opts["x-padding-method"] == "tokenish" + assert opts["uplink-http-method"] == "PATCH" + assert opts["session-placement"] == "query" + assert opts["session-key"] == "sid" + assert opts["seq-placement"] == "header" + assert opts["seq-key"] == "seq" + assert opts["uplink-data-placement"] == "cookie" + assert opts["uplink-data-key"] == "data" + assert opts["uplink-chunk-size"] == "3072" + assert opts["sc-max-each-post-bytes"] == "1000000" + assert opts["sc-min-posts-interval-ms"] == "30" + assert opts["reuse-settings"] == { + "max-concurrency": "16-32", + "c-max-reuse-times": "2", + "h-keep-alive-period": 0, + } + + download_opts = opts["download-settings"] + assert download_opts["server"] == "download.example.com" + assert download_opts["port"] == 8443 + assert download_opts["path"] == "/down" + assert download_opts["host"] == "download-host.example.com" + assert download_opts["headers"] == {"X-Down": "1"} + assert download_opts["reuse-settings"] == {"max-concurrency": "4-8", "h-keep-alive-period": 0} + assert download_opts["tls"] is True + assert download_opts["servername"] == "download-sni.example.com" + assert download_opts["skip-cert-verify"] is False + assert download_opts["client-fingerprint"] == "chrome" + assert download_opts["alpn"] == ["h2"] + + +def test_clash_meta_xhttp_serializes_raw_xray_download_settings_dict_safely(): + transport_config = XHTTPTransportConfig( + path="/up", + host="cdn.example.com", + download_settings={ + "address": "download.example.com", + "port": 8443, + "streamSettings": { + "network": "xhttp", + "security": "tls", + "tlsSettings": { + "serverName": "download-sni.example.com", + "fingerprint": "chrome", + "allowInsecure": False, + "alpn": ["h2"], + }, + "xhttpSettings": { + "path": "/raw-down", + "host": "download-host.example.com", + "extra": { + "headers": {"Host": "ignored.example.com", "X-Raw": "1", "X-Empty": ""}, + "xmux": {"maxConcurrency": "2-4"}, + }, + }, + }, + }, + ) + + meta = ClashMetaConfiguration() + meta.add("meta xhttp", "edge.example.com", _xhttp_inbound(transport_config=transport_config), {"id": USER_ID}) + + download_opts = meta.data["proxies"][0]["xhttp-opts"]["download-settings"] + assert "address" not in download_opts + assert "streamSettings" not in download_opts + assert download_opts == { + "path": "/raw-down", + "host": "download-host.example.com", + "headers": {"X-Raw": "1", "X-Empty": ""}, + "reuse-settings": {"max-concurrency": "2-4"}, + "server": "download.example.com", + "port": 8443, + "tls": True, + "alpn": ["h2"], + "skip-cert-verify": False, + "servername": "download-sni.example.com", + "client-fingerprint": "chrome", + } + + +def test_xray_parser_reads_xhttp_extra_advanced_fields(): + parsed = XRayConfig( + { + "inbounds": [ + { + "tag": "vless-xhttp", + "port": 443, + "protocol": "vless", + "settings": {"clients": [], "decryption": "none"}, + "streamSettings": { + "network": "xhttp", + "xhttpSettings": { + "path": "/up", + "host": "cdn.example.com", + "mode": "packet-up", + "extra": { + "headers": {"X-Test": "1"}, + "noGRPCHeader": True, + "scMaxEachPostBytes": "1000000", + "scMinPostsIntervalMs": "30", + "xPaddingObfsMode": True, + "uplinkHTTPMethod": "PATCH", + "sessionPlacement": "query", + "sessionKey": "sid", + "seqPlacement": "header", + "seqKey": "seq", + "uplinkDataPlacement": "cookie", + "uplinkDataKey": "data", + "uplinkChunkSize": "3072", + "xmux": {"maxConcurrency": "16-32", "hKeepAlivePeriod": 0}, + "downloadSettings": {"address": "download.example.com"}, + }, + }, + }, + } + ], + "outbounds": [{"tag": "direct", "protocol": "freedom"}], + } + ) + + inbound = parsed.inbounds_by_tag["vless-xhttp"] + assert inbound["http_headers"] == {"X-Test": "1"} + assert inbound["no_grpc_header"] is True + assert inbound["sc_max_each_post_bytes"] == "1000000" + assert inbound["sc_min_posts_interval_ms"] == "30" + assert inbound["x_padding_obfs_mode"] is True + assert inbound["uplink_http_method"] == "PATCH" + assert inbound["session_placement"] == "query" + assert inbound["session_key"] == "sid" + assert inbound["seq_placement"] == "header" + assert inbound["seq_key"] == "seq" + assert inbound["uplink_data_placement"] == "cookie" + assert inbound["uplink_data_key"] == "data" + assert inbound["uplink_chunk_size"] == "3072" + assert inbound["xmux"] == {"maxConcurrency": "16-32", "hKeepAlivePeriod": 0} + assert inbound["download_settings"] == {"address": "download.example.com"} + + +def test_xray_parser_does_not_mix_top_level_advanced_fields_when_extra_exists(): + parsed = XRayConfig( + { + "inbounds": [ + { + "tag": "vless-xhttp", + "port": 443, + "protocol": "vless", + "settings": {"clients": [], "decryption": "none"}, + "streamSettings": { + "network": "xhttp", + "xhttpSettings": { + "path": "/up", + "host": "cdn.example.com", + "mode": "packet-up", + "xPaddingObfsMode": True, + "uplinkHTTPMethod": "PATCH", + "extra": {"sessionKey": "sid"}, + }, + }, + } + ], + "outbounds": [{"tag": "direct", "protocol": "freedom"}], + } + ) + + inbound = parsed.inbounds_by_tag["vless-xhttp"] + assert inbound["session_key"] == "sid" + assert inbound["x_padding_obfs_mode"] is None + assert inbound["uplink_http_method"] is None