Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions app/core/hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
random_user_agent=host.random_user_agent,
)
elif network in ("grpc", "gun"):
Expand Down
21 changes: 15 additions & 6 deletions app/core/xray.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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."""
Expand Down
17 changes: 16 additions & 1 deletion app/models/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"""
Expand Down
220 changes: 213 additions & 7 deletions app/subscription/clash.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from uuid import UUID

import yaml
from pydantic import BaseModel

from app.models.subscription import (
GRPCTransportConfig,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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")),
Comment thread
Rerowros marked this conversation as resolved.
"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"""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading