diff --git a/app/core/hosts.py b/app/core/hosts.py index 2db1a8f1d..a06b4ba90 100644 --- a/app/core/hosts.py +++ b/app/core/hosts.py @@ -12,6 +12,7 @@ from app.core.manager import core_manager from app.db import GetDB from app.db.crud.host import get_host_by_id, get_hosts, upsert_inbounds +from app.db.crud.node import get_xray_version_by_core_id from app.db.models import ProxyHostSecurity from app.models.host import BaseHost, TransportSettings, WireGuardHostOverrides from app.models.subscription import ( @@ -49,6 +50,7 @@ def _string_list(value) -> list[str]: async def _prepare_subscription_inbound_data( host: BaseHost, down_settings: SubscriptionInboundData | None = None, + db: AsyncSession | None = None, ) -> SubscriptionInboundData: """ Prepare host data - creates small config instances ONCE. @@ -57,6 +59,10 @@ async def _prepare_subscription_inbound_data( """ # Get inbound configuration inbound_config = await core_manager.get_inbound_by_tag(host.inbound_tag) + core_id = await core_manager.get_core_id_by_tag(host.inbound_tag) + xray_version = None + if core_id and db: + xray_version = await get_xray_version_by_core_id(db, core_id) protocol = inbound_config["protocol"] ts = host.transport_settings @@ -260,6 +266,16 @@ async def _prepare_subscription_inbound_data( else inbound_config.get("session_placement") ), session_key=xs.session_key if xs and xs.session_key is not None else inbound_config.get("session_key"), + session_id_table=( + xs.session_id_table + if xs and xs.session_id_table is not None + else inbound_config.get("session_id_table") + ), + session_id_length=( + xs.session_id_length + if xs and xs.session_id_length is not None + else inbound_config.get("session_id_length") + ), seq_placement=( xs.seq_placement if xs and xs.seq_placement is not None else inbound_config.get("seq_placement") ), @@ -281,6 +297,7 @@ async def _prepare_subscription_inbound_data( 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, + core_version=xray_version, ) elif network in ("grpc", "gun"): gs = ts.grpc_settings if ts else None @@ -540,8 +557,8 @@ async def _prepare_host_entry( downstream = await get_host_by_id(db, ds_host) if downstream: downstream_base = BaseHost.model_validate(downstream) - downstream_data: SubscriptionInboundData = await _prepare_subscription_inbound_data(downstream_base) - subscription_data = await _prepare_subscription_inbound_data(host, downstream_data) + downstream_data = await _prepare_subscription_inbound_data(downstream_base, db=db) + subscription_data = await _prepare_subscription_inbound_data(host, downstream_data, db=db) # Return subscription data directly return host.id, subscription_data diff --git a/app/core/manager.py b/app/core/manager.py index eea209617..4edddec21 100644 --- a/app/core/manager.py +++ b/app/core/manager.py @@ -36,6 +36,7 @@ def __init__(self): self._lock = Lock() self._inbounds: list[str] = [] self._inbounds_by_tag = {} + self._tag_to_core_id: dict[str, int] = {} self._nats_enabled = is_nats_enabled() self._multi_worker = runtime_settings.role.requires_nats self._nc: nats.NATS | None = None @@ -222,9 +223,13 @@ async def initialize(self, db): async def update_inbounds(self): async with self._lock: new_inbounds = {} - for core in self._cores.values(): + new_tag_to_core_id = {} + for core_id, core in self._cores.items(): new_inbounds.update(core.inbounds_by_tag) + for tag in core.inbounds_by_tag: + new_tag_to_core_id[tag] = core_id + self._tag_to_core_id = new_tag_to_core_id self._inbounds_by_tag = new_inbounds self._inbounds = list(self._inbounds_by_tag.keys()) @@ -313,6 +318,9 @@ async def get_inbound_by_tag(self, tag) -> dict: return None return deepcopy(inbound) + async def get_core_id_by_tag(self, tag: str) -> int | None: + async with self._lock: + return self._tag_to_core_id.get(tag) core_manager = CoreManager() diff --git a/app/core/xray.py b/app/core/xray.py index ca1986e8a..45b4c4658 100644 --- a/app/core/xray.py +++ b/app/core/xray.py @@ -20,6 +20,17 @@ def _protocols_from_inbounds_by_tag(inbounds_by_tag: dict[str, dict]) -> frozens if (protocol := ProxyProtocol.from_value(inbound["protocol"])) is not None ) +def rename_xhttp_session_keys(obj): + if isinstance(obj, dict): + if "sessionPlacement" in obj: + obj["sessionIDPlacement"] = obj.pop("sessionPlacement") + if "sessionKey" in obj: + obj["sessionIDKey"] = obj.pop("sessionKey") + for value in obj.values(): + rename_xhttp_session_keys(value) + elif isinstance(obj, list): + for item in obj: + rename_xhttp_session_keys(item) class XRayConfig(dict): def __init__( @@ -329,8 +340,10 @@ def get_xhttp_value(key: str): settings["x_padding_placement"] = get_xhttp_value("xPaddingPlacement") settings["x_padding_method"] = get_xhttp_value("xPaddingMethod") settings["uplink_http_method"] = get_xhttp_value("uplinkHTTPMethod") - settings["session_placement"] = get_xhttp_value("sessionPlacement") - settings["session_key"] = get_xhttp_value("sessionKey") + settings["session_placement"] = get_xhttp_value("sessionIDPlacement") or get_xhttp_value("sessionPlacement") + settings["session_key"] = get_xhttp_value("sessionIDKey") or get_xhttp_value("sessionKey") + settings["session_id_table"] = get_xhttp_value("sessionIDTable") + settings["session_id_length"] = get_xhttp_value("sessionIDLength") settings["seq_placement"] = get_xhttp_value("seqPlacement") settings["seq_key"] = get_xhttp_value("seqKey") settings["uplink_data_placement"] = get_xhttp_value("uplinkDataPlacement") diff --git a/app/db/crud/node.py b/app/db/crud/node.py index a3083e376..be3e01a10 100644 --- a/app/db/crud/node.py +++ b/app/db/crud/node.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone from typing import Optional +from packaging.version import InvalidVersion, Version from sqlalchemy import and_, bindparam, case, delete, func, literal_column, or_, select, update from sqlalchemy.exc import InvalidRequestError from sqlalchemy.ext.asyncio import AsyncSession @@ -86,6 +87,25 @@ async def get_node_by_id(db: AsyncSession, node_id: int) -> Optional[Node]: await load_node_attrs(node) return node +async def get_xray_version_by_core_id(db: AsyncSession, core_config_id: int) -> str | None: + """Returns the highest reported xray_version among nodes on this core config.""" + versions = ( + await db.execute( + select(Node.xray_version).where(Node.core_config_id == core_config_id).where(Node.xray_version.isnot(None)) + ) + ).scalars().all() + if not versions: + return None + + parsed: list[tuple[Version, str]] = [] + for raw in versions: + try: + parsed.append((Version(raw.lstrip("v")), raw)) + except InvalidVersion: + continue + if not parsed: + return versions[0] + return max(parsed, key=lambda item: item[0])[1] async def get_nodes( db: AsyncSession, @@ -427,6 +447,9 @@ async def remove_node(db: AsyncSession, db_node: Node) -> None: await db.commit() +CONNECTION_IDENTITY_FIELDS = ("address", "port", "server_ca", "connection_type", "api_key") + + async def modify_node(db: AsyncSession, db_node: Node, modify: NodeModify) -> Node: """ modify an existing node with new information. @@ -444,12 +467,17 @@ async def modify_node(db: AsyncSession, db_node: Node, modify: NodeModify) -> No if "proxy_url" in modify.model_fields_set and modify.proxy_url is None: node_data["proxy_url"] = None + connection_identity_changed = any( + field in node_data and getattr(db_node, field) != node_data[field] for field in CONNECTION_IDENTITY_FIELDS + ) + for key, value in node_data.items(): setattr(db_node, key, value) - db_node.xray_version = None db_node.message = None - db_node.node_version = None + if connection_identity_changed: + db_node.xray_version = None + db_node.node_version = None if db_node.is_limited: db_node.status = NodeStatus.limited @@ -485,17 +513,17 @@ async def update_node_status( Returns: Node: The updated Node object. """ - stmt = ( - update(Node) - .where(Node.id == db_node.id) - .values( - status=status, - message=message, - xray_version=xray_version, - node_version=node_version, - last_status_change=datetime.now(timezone.utc), - ) - ) + values: dict = { + "status": status, + "message": message, + "last_status_change": datetime.now(timezone.utc), + } + if xray_version: + values["xray_version"] = xray_version + if node_version: + values["node_version"] = node_version + + stmt = update(Node).where(Node.id == db_node.id).values(**values) await db.execute(stmt) await db.commit() @@ -544,8 +572,14 @@ async def bulk_update_node_status( .values( status=bindparam("status"), message=bindparam("message"), - xray_version=bindparam("xray_version"), - node_version=bindparam("node_version"), + xray_version=case( + (bindparam("xray_version") != "", bindparam("xray_version")), + else_=Node.xray_version, + ), + node_version=case( + (bindparam("node_version") != "", bindparam("node_version")), + else_=Node.node_version, + ), last_status_change=bindparam("now"), ) ) diff --git a/app/models/core.py b/app/models/core.py index 63474768e..f14fea31c 100644 --- a/app/models/core.py +++ b/app/models/core.py @@ -49,6 +49,7 @@ def validate_sets(cls, v: set): class CoreResponse(CoreBase): id: int created_at: dt + xray_version: str | None = Field(default=None) model_config = ConfigDict(from_attributes=True) diff --git a/app/models/host.py b/app/models/host.py index fd091a9b8..3fbf1b57c 100644 --- a/app/models/host.py +++ b/app/models/host.py @@ -101,6 +101,8 @@ class XHttpSettings(BaseModel): uplink_http_method: str | None = Field(default=None) session_placement: str | None = Field(default=None, pattern=r"^$|^(path|cookie|header|query)$") session_key: str | None = Field(default=None) + session_id_table: str | None = Field(default=None, pattern=r"^[\x20-\x7E]*$") + session_id_length: str | None = Field(default=None, pattern=r"^\d{1,16}(-\d{1,16})?$") seq_placement: str | None = Field(default=None, pattern=r"^$|^(path|cookie|header|query)$") seq_key: str | None = Field(default=None) uplink_data_placement: str | None = Field(default=None, pattern=r"^$|^(body|cookie|header)$") diff --git a/app/models/node.py b/app/models/node.py index fd3c2c442..a39341897 100644 --- a/app/models/node.py +++ b/app/models/node.py @@ -366,7 +366,7 @@ class UserIPListAll(BaseModel): class NodeCoreUpdate(BaseModel): core_version: str = Field(default="latest", pattern=r"^(latest|v?\d+\.\d+\.\d+)$", examples=["v25.8.31"]) - + confirm: bool = Field(default=False) class NodeGeoFilesUpdate(BaseModel): region: GeoFilseRegion = Field(default=GeoFilseRegion.iran, examples=["iran"]) diff --git a/app/models/subscription.py b/app/models/subscription.py index 2008934d3..eb3611de3 100644 --- a/app/models/subscription.py +++ b/app/models/subscription.py @@ -113,6 +113,8 @@ class XHTTPTransportConfig(BaseTransportConfig): uplink_http_method: str | None = Field(None, serialization_alias="uplinkHTTPMethod") session_placement: str | None = Field(None, serialization_alias="sessionPlacement") session_key: str | None = Field(None, serialization_alias="sessionKey") + session_id_table: str | None = Field(None, serialization_alias="sessionIDTable") + session_id_length: str | None = Field(None, serialization_alias="sessionIDLength") seq_placement: str | None = Field(None, serialization_alias="seqPlacement") seq_key: str | None = Field(None, serialization_alias="seqKey") uplink_data_placement: str | None = Field(None, serialization_alias="uplinkDataPlacement") @@ -124,7 +126,8 @@ class XHTTPTransportConfig(BaseTransportConfig): download_settings: SubscriptionInboundData | dict | None = Field(None, serialization_alias="downloadSettings") http_headers: dict[str, str] | None = Field(None) random_user_agent: bool = Field(False) - + core_version: str | None = Field(None, exclude=True) + @field_validator( "sc_max_each_post_bytes", "sc_min_posts_interval_ms", diff --git a/app/operation/node.py b/app/operation/node.py index cfc24f5ea..dc1f19d9c 100644 --- a/app/operation/node.py +++ b/app/operation/node.py @@ -1,4 +1,6 @@ import asyncio +import json +from copy import deepcopy from typing import AsyncIterator, Callable from fastapi import HTTPException @@ -65,6 +67,8 @@ from app.operation import BaseOperation, OperatorType from app.utils.logger import get_logger from config import runtime_settings +from app.core.xray import rename_xhttp_session_keys +from app.subscription.base import is_new_xray MAX_MESSAGE_LENGTH = 128 @@ -240,14 +244,19 @@ async def connect_node(db_node: Node, core, users: list) -> dict | None: return None if core is None: return None - + if core.type == CoreType.xray and (is_new_xray(db_node.xray_version)): + copied = deepcopy(dict(core)) + rename_xhttp_session_keys(copied) + config_str = json.dumps(copied) + else: + config_str = core.to_str() old_status = db_node.status logger.info(f'Connecting to "{db_node.name}" node') type = service.BackendType.WIREGUARD if core.type == CoreType.wg else service.BackendType.XRAY try: start_kwargs = { - "config": core.to_str(), + "config": config_str, "backend_type": type, "users": users, "keep_alive": db_node.keep_alive, @@ -544,9 +553,19 @@ async def update_node(self, db: AsyncSession, node_id: int) -> dict: return await self._update_node_api_impl(node_id) async def update_core(self, db: AsyncSession, node_id: int, node_core_update: NodeCoreUpdate) -> dict: - await self.get_validated_node(db, node_id) + db_node = await self.get_validated_node(db, node_id) + + current = db_node.xray_version + target = node_core_update.core_version + if current and not is_new_xray(current) and is_new_xray(target) and not node_core_update.confirm: + raise HTTPException( + status_code=400, + detail="Upgrading to Xray 26.6.22+ renames xhttp session parameters (breaking change). Set confirm=true to proceed.", + ) + return await self._update_core_impl(node_id, node_core_update) + async def update_geofiles(self, db: AsyncSession, node_id: int, node_geofiles_update: NodeGeoFilesUpdate) -> dict: await self.get_validated_node(db, node_id) return await self._update_geofiles_impl(node_id, node_geofiles_update) diff --git a/app/routers/core.py b/app/routers/core.py index 26f3100da..4ed1ddad1 100644 --- a/app/routers/core.py +++ b/app/routers/core.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, status from app.db import AsyncSession, get_db +from app.db.crud.node import get_xray_version_by_core_id from app.models.admin import AdminDetails from app.models.core import ( BulkCoreSelection, @@ -36,9 +37,12 @@ async def create_core_config( @router.get("/{core_id}", response_model=CoreResponse) async def get_core_config( core_id: int, _: AdminDetails = Depends(require_permission("cores", "read")), db: AsyncSession = Depends(get_db) -) -> dict: +) -> CoreResponse: """Get a core configuration by its ID.""" - return await core_operator.get_validated_core_config(db, core_id) + db_core_config = await core_operator.get_validated_core_config(db, core_id) + xray_version = await get_xray_version_by_core_id(db, core_id) + core = CoreResponse.model_validate(db_core_config) + return core.model_copy(update={"xray_version": xray_version}) @router.put("/{core_id}", response_model=CoreResponse) diff --git a/app/subscription/base.py b/app/subscription/base.py index b7ea818ea..e248dae78 100644 --- a/app/subscription/base.py +++ b/app/subscription/base.py @@ -7,7 +7,15 @@ from urllib.parse import quote, urlencode from app.models.subscription import SubscriptionInboundData - +from packaging.version import Version + +def is_new_xray(version: str | None) -> bool: + if not version: + return False + try: + return Version(version.lstrip("v")) >= Version("26.6.22") + except Exception: + return False class BaseSubscription: def __init__( diff --git a/app/subscription/links.py b/app/subscription/links.py index 187577cc8..d21bf585b 100644 --- a/app/subscription/links.py +++ b/app/subscription/links.py @@ -17,7 +17,7 @@ from config import subscription_env_settings from . import BaseSubscription - +from .base import is_new_xray class StandardLinks(BaseSubscription): def __init__( @@ -96,6 +96,7 @@ def _transport_grpc(self, payload: dict, protocol: str, config: GRPCTransportCon def _transport_xhttp(self, payload: dict, protocol: str, config: XHTTPTransportConfig, path: str): """Handle splithttp/xhttp transport - only gets xHTTP config""" host = config.host if isinstance(config.host, str) else "" + is_new = is_new_xray(config.core_version) payload["path"] = path payload["host"] = host @@ -114,8 +115,9 @@ def _transport_xhttp(self, payload: dict, protocol: str, config: XHTTPTransportC "xPaddingPlacement": config.x_padding_placement, "xPaddingMethod": config.x_padding_method, "uplinkHTTPMethod": config.uplink_http_method, - "sessionPlacement": config.session_placement, - "sessionKey": config.session_key, + ("sessionIDPlacement" if is_new else "sessionPlacement"): config.session_placement, + ("sessionIDKey" if is_new else "sessionKey"): config.session_key, + **({"sessionIDTable": config.session_id_table, "sessionIDLength": config.session_id_length} if is_new else {}), "seqPlacement": config.seq_placement, "seqKey": config.seq_key, "uplinkDataPlacement": config.uplink_data_placement, diff --git a/app/subscription/xray.py b/app/subscription/xray.py index f21740614..2b8cb472f 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -14,8 +14,7 @@ from app.utils.helpers import UUIDEncoder from . import BaseSubscription - - +from .base import is_new_xray class XrayConfiguration(BaseSubscription): def __init__( self, @@ -130,6 +129,7 @@ def _transport_httpupgrade(self, config: WebSocketTransportConfig, path: str) -> def _transport_xhttp(self, config: XHTTPTransportConfig, path: str) -> dict: """Handle xHTTP/SplitHTTP transport - only gets xHTTP config""" host = config.host if isinstance(config.host, str) else (config.host[0] if config.host else "") + is_new = is_new_xray(config.core_version) xhttp_settings = { "mode": config.mode, @@ -148,8 +148,9 @@ def _transport_xhttp(self, config: XHTTPTransportConfig, path: str) -> dict: "xPaddingPlacement": config.x_padding_placement, "xPaddingMethod": config.x_padding_method, "uplinkHTTPMethod": config.uplink_http_method, - "sessionPlacement": config.session_placement, - "sessionKey": config.session_key, + ("sessionIDPlacement" if is_new else "sessionPlacement"): config.session_placement, + ("sessionIDKey" if is_new else "sessionKey"): config.session_key, + **({"sessionIDTable": config.session_id_table, "sessionIDLength": config.session_id_length} if is_new else {}), "seqPlacement": config.seq_placement, "seqKey": config.seq_key, "uplinkDataPlacement": config.uplink_data_placement, diff --git a/dashboard/bun.lock b/dashboard/bun.lock index 640301d4d..427f41c27 100644 --- a/dashboard/bun.lock +++ b/dashboard/bun.lock @@ -11,7 +11,7 @@ "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@noble/post-quantum": "^0.5.4", - "@pasarguard/core-kit": "^0.2.4", + "@pasarguard/core-kit": "npm:@funlay/core-kit-beta@0.3.0-beta.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", @@ -105,6 +105,7 @@ "vite": "^8.0.10", "vite-plugin-pwa": "^1.2.0", "vite-plugin-svgr": "^4.5.0", + "vitest": "^4.1.9", "wait-port": "^1.1.0", }, }, @@ -444,7 +445,7 @@ "@jridgewell/source-map": ["@jridgewell/source-map@0.3.10", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], @@ -496,11 +497,11 @@ "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], - "@pasarguard/core-kit": ["@pasarguard/core-kit@0.2.4", "", { "dependencies": { "@pasarguard/wireguard-config-kit": "0.1.0", "@pasarguard/xray-config-kit": "0.3.4" } }, "sha512-TBm9ZPYbSO+4Z/59aJML7z34uk8Wri0yRixSGbwCU1Vrn4s48sCL6ju9SDf6nrj3YfR1EcF6dbONUkJsPh7oug=="], + "@pasarguard/core-kit": ["@funlay/core-kit-beta@0.3.0-beta.1", "", { "dependencies": { "@pasarguard/wireguard-config-kit": "0.1.0", "@pasarguard/xray-config-kit": "npm:@funlay/xray-config-kit-beta@0.4.0-beta.1" } }, "sha512-5du4sdDk8kEBYLCpsJd5pUY9vt2mJpaChgJ9dQrfFWG4w7lZRYnHHk/ze18wdJBNLcGfxBCelFZGrwAhdfs5rg=="], "@pasarguard/wireguard-config-kit": ["@pasarguard/wireguard-config-kit@0.1.0", "", { "dependencies": { "@stablelib/x25519": "^2.0.1", "zod": "^3.25.76" } }, "sha512-vC4ULRfrgsN+W9mFLHFkr74G+6yPmMtxj1/1ZjIwulHPeQ5qHVGA0ed1vSMxL9f722rmV3jj/eMeRQCasGJsrw=="], - "@pasarguard/xray-config-kit": ["@pasarguard/xray-config-kit@0.3.4", "", { "dependencies": { "@noble/post-quantum": "^0.5.4", "@stablelib/x25519": "^2.0.1", "mlkem": "^2.7.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.25.2" } }, "sha512-oDdPyNUrfnsGBSVw7KsTjWdZs0aRz6wRDRk7+G1zimIp6KQvYkZwDLjVwJnh+Brf8twxBxKsZUxegOx8e7csug=="], + "@pasarguard/xray-config-kit": ["@funlay/xray-config-kit-beta@0.4.0-beta.1", "", { "dependencies": { "@noble/post-quantum": "^0.5.4", "@stablelib/x25519": "^2.0.1", "mlkem": "^2.7.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.25.2" } }, "sha512-PZsOWdWO/ngK2CuuWlIvCMYdSRLkpq0aTb2LfhPzN9WPHBX8MNjuSu8pVHmsjCEdKQfajxAV4OhkC2SdRQNQ/A=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -722,6 +723,8 @@ "@stablelib/x25519": ["@stablelib/x25519@2.0.1", "", { "dependencies": { "@stablelib/keyagreement": "^2.0.1", "@stablelib/random": "^2.0.1", "@stablelib/wipe": "^2.0.1" } }, "sha512-qi04HS2puHaBf50kM/kes5QcZFGsx8yF0YmCjLCOa/LPmnBaKEKX9ZR82OnnCwMn72YH13R/bBZgr/UP0aPFfA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@stoplight/better-ajv-errors": ["@stoplight/better-ajv-errors@1.0.3", "", { "dependencies": { "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA=="], @@ -850,6 +853,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], @@ -868,6 +873,8 @@ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/es-aggregate-error": ["@types/es-aggregate-error@1.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -920,6 +927,20 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "ace-builds": ["ace-builds@1.43.6", "", {}, "sha512-L1ddibQ7F3vyXR2k2fg+I8TQTPWVA6CKeDQr/h2+8CeyTp3W6EQL8xNFZRTztuP8xNOAqL3IYPqdzs31GCjDvg=="], @@ -954,6 +975,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -1004,6 +1027,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -1226,6 +1251,8 @@ "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "fancy-ansi": ["fancy-ansi@0.1.3", "", { "dependencies": { "escape-html": "^1.0.3" } }, "sha512-tRQVTo5jjdSIiydqgzIIEZpKddzSsfGLsSVt6vWdjVm7fbvDTiQkyoPu6Z3dIPlAM4OZk0jP5jmTCX4G8WGgBw=="], @@ -1620,6 +1647,8 @@ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -1822,6 +1851,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-eval": ["simple-eval@1.0.1", "", { "dependencies": { "jsep": "^1.3.6" } }, "sha512-LH7FpTAkeD+y5xQC4fzS+tFtaNlvt3Ib1zKzvhjv/Y+cioV4zIuw4IZr2yhRLu67CWL7FR9/6KXKnjRoZTvGGQ=="], @@ -1844,8 +1875,12 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], @@ -1898,8 +1933,14 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], @@ -2000,6 +2041,8 @@ "vite-plugin-svgr": ["vite-plugin-svgr@4.5.0", "", { "dependencies": { "@rollup/pluginutils": "^5.2.0", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0" }, "peerDependencies": { "vite": ">=2.6.0" } }, "sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA=="], + "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], "wait-port": ["wait-port@1.1.0", "", { "dependencies": { "chalk": "^4.1.2", "commander": "^9.3.0", "debug": "^4.3.4" }, "bin": { "wait-port": "bin/wait-port.js" } }, "sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q=="], @@ -2018,6 +2061,8 @@ "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "workbox-background-sync": ["workbox-background-sync@7.4.0", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.0" } }, "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w=="], @@ -2100,6 +2145,10 @@ "@ibm-cloud/openapi-ruleset/minimatch": ["minimatch@6.2.0", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-sauLxniAmvnhhRjFwPNnJKaPFYyddAgbYdeUpHULtCT/GhzdCx/MDNy+Y40lBxTQUrMzDE8e0S43Z5uqfO0REg=="], + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + + "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@orval/core/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "@orval/core/openapi3-ts": ["openapi3-ts@4.4.0", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw=="], @@ -2202,6 +2251,12 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "@vitest/runner/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "@vitest/snapshot/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2228,8 +2283,6 @@ "jake/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -2272,6 +2325,12 @@ "vite-node/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], + "vitest/es-module-lexer": ["es-module-lexer@2.2.0", "", {}, "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ=="], + + "vitest/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "vitest/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "workbox-build/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "workbox-build/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], diff --git a/dashboard/package.json b/dashboard/package.json index ffa37da85..6e16fbf17 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -8,7 +8,8 @@ "preview": "vite preview", "gen:api": "orval", "wait-port": "wait-port http://localhost:$UVICORN_PORT/openapi.json", - "wait-port-gen-api": "bun run wait-port && bun run gen:api" + "wait-port-gen-api": "bun run wait-port && bun run gen:api", + "test": "vitest run" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -17,7 +18,7 @@ "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@noble/post-quantum": "^0.5.4", - "@pasarguard/core-kit": "^0.2.4", + "@pasarguard/core-kit": "npm:@funlay/core-kit-beta@0.3.0-beta.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", @@ -111,6 +112,7 @@ "vite": "^8.0.10", "vite-plugin-pwa": "^1.2.0", "vite-plugin-svgr": "^4.5.0", + "vitest": "^4.1.9", "wait-port": "^1.1.0" }, "packageManager": "bun@1.0.0" diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 18c60a682..b7522aee9 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -1289,6 +1289,12 @@ "uplinkHttpMethod": "Uplink HTTP Method", "sessionPlacement": "Session Placement", "sessionKey": "Session Key", + "sessionIdTable": "Session ID Table", + "sessionIdTablePlaceholder": "e.g. Base62, HEX, or custom characters", + "sessionIdLength": "Session ID Length", + "sessionIdLengthPlaceholder": "e.g. 8 or 8-16", + "customValue": "Custom", + "sessionIdTableCustomPlaceholder": "Enter custom characters (ASCII)", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", @@ -2059,7 +2065,11 @@ "updating": "Updating...", "updateAvailable": "Update Available", "prerelease": "Pre-release", - "loadingReleases": "Loading releases..." + "loadingReleases": "Loading releases...", + "breakingChangeConfirm": "I understand, update anyway", + "prereleaseWarningTitle": "Pre-release version selected", + "prereleaseWarningMessage": "This version has not been promoted to a stable release yet. Some parameters may change or behave differently once it reaches a stable release — avoid this on production nodes unless you understand the risk.", + "coreVersionMismatchWarning": "Other nodes already using this core config are running a different Xray version. This core is pushed to every node unmodified (except for a few fields the panel safely adapts per node's version) — some of the remaining settings may not work as expected." }, "theme": { "title": "Theme", @@ -2535,6 +2545,13 @@ }, "tlsCertificateServeOnNode": "Serve on node", "tlsCertificateServeOnNodeHint": "Certificate files are only used on the node; the server will not read them for SNI.", + "tls": { + "allowInsecureRemoved": "This node's Xray-core no longer builds configs with allowInsecure enabled — it will refuse to start." + }, + "xhttp": { + "sessionIdLengthNotPositive": "sessionIDLength must be greater than 0.", + "sessionIdRoomTooSmall": "Too few possible session IDs (must be at least ~2.1 billion). Increase the length range or use a larger character table." + }, "tlsCertificates": { "sectionLabel": "Certificates", "emptyHint": "No certificates yet. Click + to add one.", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 136f58b88..bd38b84da 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -1141,6 +1141,12 @@ "uplinkHttpMethod": "Uplink HTTP Method", "sessionPlacement": "Session Placement", "sessionKey": "Session Key", + "sessionIdTable": "جدول Session ID", + "sessionIdTablePlaceholder": "مثلاً Base62، HEX یا کاراکترهای دلخواه", + "sessionIdLength": "طول Session ID", + "sessionIdLengthPlaceholder": "مثلاً ۸ یا ۸-۱۶", + "customValue": "سفارشی", + "sessionIdTableCustomPlaceholder": "کاراکترهای دلخواه را وارد کنید (ASCII)", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", @@ -1871,7 +1877,11 @@ "updating": "در حال به‌روزرسانی...", "updateAvailable": "به‌روزرسانی موجود است", "prerelease": "نسخه پیش‌انتشار", - "loadingReleases": "در حال بارگذاری نسخه‌ها..." + "loadingReleases": "در حال بارگذاری نسخه‌ها...", + "breakingChangeConfirm": "متوجه شدم، در هر صورت به‌روزرسانی شود", + "prereleaseWarningTitle": "نسخه پیش‌انتشار انتخاب شد", + "prereleaseWarningMessage": "این نسخه هنوز به یک نسخه پایدار ارتقا نیافته است. برخی پارامترها ممکن است پس از رسیدن به نسخه پایدار تغییر کنند یا رفتار متفاوتی داشته باشند — تا زمانی که ریسک آن را درک نکرده‌اید، از آن روی نودهای تولیدی استفاده نکنید.", + "coreVersionMismatchWarning": "سایر نودهایی که از این core config استفاده می‌کنند نسخه متفاوتی از Xray را اجرا می‌کنند. این core به‌جز چند فیلد که پنل به‌صورت ایمن متناسب با نسخه هر نود تطبیق می‌دهد، بدون تغییر به همه نودها ارسال می‌شود — ممکن است برخی از تنظیمات باقی‌مانده آن‌طور که انتظار می‌رود کار نکنند." }, "nodes": { "title": "گره‌ها", @@ -2448,6 +2458,13 @@ }, "tlsCertificateServeOnNode": "سرو روی نود", "tlsCertificateServeOnNodeHint": "فایل‌های گواهی فقط روی نود استفاده می‌شوند؛ سرور آن‌ها را برای SNI نمی‌خواند.", + "tls": { + "allowInsecureRemoved": "Xray-core این نود دیگر پیکربندی‌هایی با allowInsecure فعال نمی‌سازد — اجرا نخواهد شد." + }, + "xhttp": { + "sessionIdLengthNotPositive": "sessionIDLength باید بزرگ‌تر از ۰ باشد.", + "sessionIdRoomTooSmall": "تعداد session ID های ممکن خیلی کم است (باید حداقل حدود ۲.۱ میلیارد باشد). محدوده طول را افزایش دهید یا از جدول کاراکتری بزرگ‌تر استفاده کنید." + }, "tlsCertificates": { "sectionLabel": "گواهی‌ها", "emptyHint": "هنوز گواهی‌ای نیست. برای افزودن روی + کلیک کنید.", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 537e29c17..5ad01e4e2 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1500,6 +1500,12 @@ "uplinkHttpMethod": "Uplink HTTP Method", "sessionPlacement": "Session Placement", "sessionKey": "Session Key", + "sessionIdTable": "Таблица Session ID", + "sessionIdTablePlaceholder": "напр. Base62, HEX или свой набор символов", + "sessionIdLength": "Длина Session ID", + "sessionIdLengthPlaceholder": "напр. 8 или 8-16", + "customValue": "Своё значение", + "sessionIdTableCustomPlaceholder": "Введите свой набор символов (ASCII)", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", @@ -1947,7 +1953,11 @@ "updating": "Обновление...", "updateAvailable": "Доступно обновление", "prerelease": "Предварительная версия", - "loadingReleases": "Загрузка релизов..." + "loadingReleases": "Загрузка релизов...", + "breakingChangeConfirm": "Понимаю, всё равно обновить", + "prereleaseWarningTitle": "Выбрана pre-release версия", + "prereleaseWarningMessage": "Эта версия ещё не стала стабильным релизом. Некоторые параметры могут измениться или начать работать иначе, когда она станет стабильной — не используйте её на продакшен-нодах, если не понимаете риски.", + "coreVersionMismatchWarning": "Другие ноды, уже использующие этот core config, работают на другой версии Xray. Этот core пушится на все ноды без изменений (кроме нескольких полей, которые панель безопасно адаптирует под версию каждой ноды) — часть остальных настроек может работать не так, как ожидается." }, "theme": { "title": "Тема", @@ -2421,6 +2431,13 @@ }, "tlsCertificateServeOnNode": "Использовать на ноде", "tlsCertificateServeOnNodeHint": "Файлы сертификата используются только на ноде; сервер не читает их для SNI.", + "tls": { + "allowInsecureRemoved": "Xray-core этой ноды больше не собирает конфиги с включённым allowInsecure — запуск будет отклонён." + }, + "xhttp": { + "sessionIdLengthNotPositive": "sessionIDLength должен быть больше 0.", + "sessionIdRoomTooSmall": "Слишком мало возможных session ID (нужно как минимум ~2,1 миллиарда). Увеличьте диапазон длины или используйте таблицу символов побольше." + }, "tlsCertificates": { "sectionLabel": "Сертификаты", "emptyHint": "Сертификатов пока нет. Нажмите +, чтобы добавить.", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 856d09f6c..72d2317e0 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -1262,6 +1262,12 @@ "uplinkHttpMethod": "Uplink HTTP Method", "sessionPlacement": "Session Placement", "sessionKey": "Session Key", + "sessionIdTable": "Session ID 字符表", + "sessionIdTablePlaceholder": "例如 Base62、HEX 或自定义字符", + "sessionIdLength": "Session ID 长度", + "sessionIdLengthPlaceholder": "例如 8 或 8-16", + "customValue": "自定义", + "sessionIdTableCustomPlaceholder": "输入自定义字符 (ASCII)", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", @@ -2018,7 +2024,11 @@ "updating": "更新中...", "updateAvailable": "有更新可用", "prerelease": "预发布版本", - "loadingReleases": "正在加载版本..." + "loadingReleases": "正在加载版本...", + "breakingChangeConfirm": "我已了解,仍然更新", + "prereleaseWarningTitle": "已选择预发布版本", + "prereleaseWarningMessage": "该版本尚未成为稳定发行版。待其成为稳定版本时,部分参数可能会更改或表现不同——除非您了解其中风险,否则不要在生产节点上使用。", + "coreVersionMismatchWarning": "已使用此核心配置的其他节点运行的是不同的 Xray 版本。除少数由面板根据每个节点版本安全适配的字段外,该核心配置会原样推送到所有节点——其余部分设置可能无法按预期工作。" }, "theme": { "title": "主题", @@ -2492,6 +2502,13 @@ }, "tlsCertificateServeOnNode": "在节点提供服务", "tlsCertificateServeOnNodeHint": "证书文件仅在节点上使用;服务器不会为 SNI 读取这些文件。", + "tls": { + "allowInsecureRemoved": "该节点的 Xray-core 已不再构建启用 allowInsecure 的配置——它将拒绝启动。" + }, + "xhttp": { + "sessionIdLengthNotPositive": "sessionIDLength 必须大于 0。", + "sessionIdRoomTooSmall": "可能的 session ID 数量过少(至少需要约 21 亿个)。请增大长度范围或使用更大的字符表。" + }, "tlsCertificates": { "sectionLabel": "证书", "emptyHint": "暂无证书。点击 + 添加。", diff --git a/dashboard/src/features/core-editor/components/xray/xray-advanced-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-advanced-section.tsx index c43ace81c..c06f018b9 100644 --- a/dashboard/src/features/core-editor/components/xray/xray-advanced-section.tsx +++ b/dashboard/src/features/core-editor/components/xray/xray-advanced-section.tsx @@ -75,6 +75,7 @@ export function XrayAdvancedSection() { const xrayBaseline = useCoreEditorStore(s => s.xrayBaseline) const wgDraft = useCoreEditorStore(s => s.wgDraft) const wgBaseline = useCoreEditorStore(s => s.wgBaseline) + const coreXrayVersion = useCoreEditorStore(s => s.coreXrayVersion) const [showDiff, setShowDiff] = useState(false) const [activeTab, setActiveTab] = useState('all') const [tabDraft, setTabDraft] = useState('') @@ -167,27 +168,27 @@ export function XrayAdvancedSection() { try { let full: unknown if (kind === 'wg' && wgBaseline) full = draftToPersistedConfig(wgBaseline) - else if (kind === 'xray' && xrayBaseline) full = profileToPersistedConfig(xrayBaseline) + else if (kind === 'xray' && xrayBaseline) full = profileToPersistedConfig(xrayBaseline, coreXrayVersion) else return {} as JsonValue const cloned = JSON.parse(JSON.stringify(full)) as Record return (projectForTab(cloned, effectiveTab) ?? {}) as JsonValue } catch { return {} as JsonValue } - }, [kind, wgBaseline, xrayBaseline, effectiveTab]) + }, [kind, wgBaseline, xrayBaseline, effectiveTab, coreXrayVersion]) const afterJson = useMemo(() => { try { let full: unknown if (kind === 'wg' && wgDraft) full = draftToPersistedConfig(wgDraft) - else if (kind === 'xray' && xrayProfile) full = profileToPersistedConfig(xrayProfile) + else if (kind === 'xray' && xrayProfile) full = profileToPersistedConfig(xrayProfile, coreXrayVersion) else return {} as JsonValue const cloned = JSON.parse(JSON.stringify(full)) as Record return (projectForTab(cloned, effectiveTab) ?? {}) as JsonValue } catch { return {} as JsonValue } - }, [kind, wgDraft, xrayProfile, effectiveTab]) + }, [kind, wgDraft, xrayProfile, effectiveTab, coreXrayVersion]) const editorValue = effectiveTab === 'all' ? monacoJson : tabDraft const editorOnChange = effectiveTab === 'all' ? handleAllJsonChange : handleTabJsonChange diff --git a/dashboard/src/features/core-editor/components/xray/xray-balancers-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-balancers-section.tsx index c71dffed1..a9f54f8ba 100644 --- a/dashboard/src/features/core-editor/components/xray/xray-balancers-section.tsx +++ b/dashboard/src/features/core-editor/components/xray/xray-balancers-section.tsx @@ -237,6 +237,7 @@ export function XrayBalancersSection({ headerAddPulse, headerAddEpoch }: XrayBal const { t } = useTranslation() const dir = useDirDetection() const profile = useCoreEditorStore(s => s.xrayProfile) + const coreXrayVersion = useCoreEditorStore(s => s.coreXrayVersion) const updateXrayProfile = useCoreEditorStore(s => s.updateXrayProfile) const { assertNoPersistBlockingErrors } = useXrayPersistModifyGuard() const profileTagOptions = useMemo( @@ -263,7 +264,7 @@ export function XrayBalancersSection({ headerAddPulse, headerAddEpoch }: XrayBal return balancers[selected] }, [dialogMode, draftBalancer, balancers, selected]) - const balancerParityFields = useMemo(() => getGeneratedRoutingBalancerFields(), []) + const balancerParityFields = useMemo(() => getGeneratedRoutingBalancerFields({ xrayVersion: coreXrayVersion ?? undefined }), [coreXrayVersion]) const strategyTypeLabel = useMemo(() => { return t('coreEditor.balancer.strategy', { defaultValue: 'Strategy' }) }, [t]) diff --git a/dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx index 89eb4bb75..7efe73900 100644 --- a/dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx +++ b/dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx @@ -28,6 +28,8 @@ import { remapIndexAfterArrayMove } from '@/features/core-editor/kit/remap-index import { isPlaceholderTunnelRewriteAddress, normalizeTunnelNetworkForKit } from '@/features/core-editor/kit/sanitize-inbound' import { inferParityFieldMode, outboundSettingToString, parseOutboundSettingValue, stringifyJsonFormRecord } from '@/features/core-editor/kit/xray-parity-value' import { useCoreEditorStore } from '@/features/core-editor/state/core-editor-store' +import { isXrayVersionAtLeast, parseXrayVersion, XRAY_FEATURE_GATES } from '@/lib/xray-version-gates' +import { checkSessionIdRoomSize } from '@/lib/xray-session-id-room-size' import useDirDetection from '@/hooks/use-dir-detection' import { cn } from '@/lib/utils' import { @@ -159,6 +161,9 @@ function getXhttpExtraRecord(transport: Record | null): Record< return extra as Record } +// Predefined sessionIDTable aliases recognized by Xray 26.6.22+. +const SESSION_ID_TABLE_PRESETS = ['ALPHABET', 'Alphabet', 'BASE36', 'Base62', 'HEX', 'alphabet', 'base36', 'hex', 'number'] + const XHTTP_EXTRA_META_KEYS = new Set([ 'headers', 'xpaddingbytes', @@ -170,6 +175,10 @@ const XHTTP_EXTRA_META_KEYS = new Set([ 'uplinkhttpmethod', 'sessionplacement', 'sessionkey', + 'sessionidplacement', + 'sessionidkey', + 'sessionidtable', + 'sessionidlength', 'seqplacement', 'seqkey', 'uplinkdataplacement', @@ -893,6 +902,10 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo const dir = useDirDetection() const profile = useCoreEditorStore(s => s.xrayProfile) const updateXrayProfile = useCoreEditorStore(s => s.updateXrayProfile) + const coreXrayVersion = useCoreEditorStore(s => s.coreXrayVersion) + const isAllowInsecureHardBlocked = isXrayVersionAtLeast(coreXrayVersion, XRAY_FEATURE_GATES.allowInsecureHardError) + const isSessionIdFieldsGated = isXrayVersionAtLeast(coreXrayVersion, XRAY_FEATURE_GATES.sessionIdFieldsRenamed) + const isSessionIdTableConfirmedUnsupported = coreXrayVersion !== null && !isSessionIdFieldsGated && parseXrayVersion(coreXrayVersion) !== null const { assertNoPersistBlockingErrors } = useXrayPersistModifyGuard() const [selected, setSelected] = useState(0) const [detailOpen, setDetailOpen] = useState(false) @@ -908,6 +921,7 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo const [isGeneratingShadowsocksPassword, setIsGeneratingShadowsocksPassword] = useState(false) const [shadowsocksPasswordJustGenerated, setShadowsocksPasswordJustGenerated] = useState(false) const [vlessDecryptionJustGenerated, setVlessDecryptionJustGenerated] = useState(false) + const [sessionIdTableCustomMode, setSessionIdTableCustomMode] = useState(false) const [vlessAdvancedOpen, setVlessAdvancedOpen] = useState(false) const [vlessAdvancedSeed, setVlessAdvancedSeed] = useState(undefined) const [isTagAutoGenerated, setIsTagAutoGenerated] = useState(true) @@ -929,7 +943,10 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo }, [profile, dialogMode, draftInbound, selected]) const visibility = useMemo(() => (inbound ? getInboundFieldVisibility(inbound) : null), [inbound]) - const caps = useMemo(() => getInboundFormCapabilities(), []) + const caps = useMemo(() => getInboundFormCapabilities({ xrayVersion: coreXrayVersion ?? undefined }), [coreXrayVersion]) + // echForceQuery is a fully-removed struct field on 26.6.22+ (not a Build()-time check), so this + // is sourced from xray-config-kit's own per-release parity data instead of a hand-maintained cutoff. + const isEchForceQueryGated = !caps.securityFields.tls?.echForceQuery const protocolSelectOptions = useMemo(() => { const visibleProtocols = caps.protocolOrder.filter(p => caps.protocols[p] && p !== 'dokodemo-door') // Keep legacy value selectable when editing an existing legacy inbound. @@ -1397,6 +1414,12 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo xpaddingheader: 'xPaddingHeader', xpaddingplacement: 'xPaddingPlacement', xpaddingmethod: 'xPaddingMethod', + sessionidtable: 'sessionIDTable', + sessionidlength: 'sessionIDLength', + sessionplacement: 'sessionPlacement', + sessionkey: 'sessionKey', + sessionidplacement: 'sessionIDPlacement', + sessionidkey: 'sessionIDKey', } const nextExtra = { ...(xhttpExtra ?? {}) } const fallback = fallbackByKey[normalizedKey] ?? normalizedKey @@ -1428,6 +1451,12 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo xpaddingheader: 'xPaddingHeader', xpaddingplacement: 'xPaddingPlacement', xpaddingmethod: 'xPaddingMethod', + sessionidtable: 'sessionIDTable', + sessionidlength: 'sessionIDLength', + sessionplacement: 'sessionPlacement', + sessionkey: 'sessionKey', + sessionidplacement: 'sessionIDPlacement', + sessionidkey: 'sessionIDKey', } const nextExtra = { ...(xhttpExtra ?? {}) } for (const [normalizedKey, value] of Object.entries(updates)) { @@ -1468,6 +1497,10 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo uplinkhttpmethod: 'uplinkHTTPMethod', sessionplacement: 'sessionPlacement', sessionkey: 'sessionKey', + sessionidplacement: 'sessionIDPlacement', + sessionidkey: 'sessionIDKey', + sessionidtable: 'sessionIDTable', + sessionidlength: 'sessionIDLength', seqplacement: 'seqPlacement', seqkey: 'seqKey', uplinkdataplacement: 'uplinkDataPlacement', @@ -2365,6 +2398,13 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo const generatedEchConfig = mutateBase64Seed(DEFAULT_ECH_CONFIG) form.setValue(securityFieldName('echServerKeys'), generatedEchServerKey) form.setValue(securityFieldName('echConfigList'), generatedEchConfig) + if (isEchForceQueryGated) { + applyTlsEchPatch({ + echServerKeys: generatedEchServerKey, + echConfigList: generatedEchConfig, + }) + return + } if (echUsageOption === 'required') { form.setValue(securityFieldName('echForceQuery'), 'full') applyTlsEchPatch({ @@ -3252,6 +3292,148 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo )} + {inboundTransportType === 'xhttp' && + (() => { + const sessionPlacementValue = String( + (isSessionIdFieldsGated + ? (getTransportMetaValue(xhttpExtra, 'sessionidplacement') ?? getTransportMetaValue(xhttpExtra, 'sessionplacement')) + : (getTransportMetaValue(xhttpExtra, 'sessionplacement') ?? getTransportMetaValue(xhttpExtra, 'sessionidplacement'))) ?? '', + ) + const sessionKeyValue = String( + (isSessionIdFieldsGated + ? (getTransportMetaValue(xhttpExtra, 'sessionidkey') ?? getTransportMetaValue(xhttpExtra, 'sessionkey')) + : (getTransportMetaValue(xhttpExtra, 'sessionkey') ?? getTransportMetaValue(xhttpExtra, 'sessionidkey'))) ?? '', + ) + const placementKey = isSessionIdFieldsGated ? 'sessionidplacement' : 'sessionplacement' + const placementOtherKey = isSessionIdFieldsGated ? 'sessionplacement' : 'sessionidplacement' + const keyKey = isSessionIdFieldsGated ? 'sessionidkey' : 'sessionkey' + const keyOtherKey = isSessionIdFieldsGated ? 'sessionkey' : 'sessionidkey' + return ( +
+ + {t('hostsDialog.xhttp.sessionPlacement', { defaultValue: 'Session Placement' })} + + + + + {t('hostsDialog.xhttp.sessionKey', { defaultValue: 'Session Key' })} + + updateXhttpMetaBatch({ [keyKey]: e.target.value, [keyOtherKey]: undefined })} + /> + + +
+ ) + })()} + + {inboundTransportType === 'xhttp' && !isSessionIdTableConfirmedUnsupported && + (() => { + const sessionIdTableValue = String(getTransportMetaValue(xhttpExtra, 'sessionidtable') ?? '') + const isPresetTable = SESSION_ID_TABLE_PRESETS.includes(sessionIdTableValue) + const showCustomTable = sessionIdTableCustomMode || (sessionIdTableValue !== '' && !isPresetTable) + const tableSelectValue = showCustomTable ? '__custom' : sessionIdTableValue === '' ? '__default' : sessionIdTableValue + const sessionIdLengthValue = String(getTransportMetaValue(xhttpExtra, 'sessionidlength') ?? '') + const sessionIdRoomSizeProblem = checkSessionIdRoomSize(sessionIdTableValue, sessionIdLengthValue) + return ( +
+
+ + {t('hostsDialog.xhttp.sessionIdTable', { defaultValue: 'Session ID Table' })} + + {showCustomTable && ( + + updateXhttpMeta('sessionidtable', e.target.value)} + placeholder={t('hostsDialog.xhttp.sessionIdTableCustomPlaceholder', { defaultValue: 'Enter custom characters (ASCII)' })} + /> + + )} + + + + {t('hostsDialog.xhttp.sessionIdLength', { defaultValue: 'Session ID Length' })} + + updateXhttpMeta('sessionidlength', e.target.value)} + placeholder={t('hostsDialog.xhttp.sessionIdLengthPlaceholder', { defaultValue: 'e.g. 22-22' })} + /> + + +
+ {sessionIdRoomSizeProblem && ( +

+ {sessionIdRoomSizeProblem === 'length-not-positive' + ? t('coreEditor.inbound.xhttp.sessionIdLengthNotPositive', { + defaultValue: 'sessionIDLength must be greater than 0.', + }) + : t('coreEditor.inbound.xhttp.sessionIdRoomTooSmall', { + defaultValue: + 'Too few possible session IDs (must be at least ~2.1 billion). Increase the length range or use a larger character table.', + })} +

+ )} +
+ ) + })()} + {transportSettingsFieldOrder.map((jsonKey: string) => { const def = caps.transportSettingsFieldDefinitions[inboundTransportType]?.[jsonKey] if (!def) return null @@ -3269,6 +3451,12 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo normalizedTransportKey === 'xpaddingheader' || normalizedTransportKey === 'xpaddingplacement' || normalizedTransportKey === 'xpaddingmethod' || + normalizedTransportKey === 'sessionplacement' || + normalizedTransportKey === 'sessionkey' || + normalizedTransportKey === 'sessionidplacement' || + normalizedTransportKey === 'sessionidkey' || + normalizedTransportKey === 'sessionidtable' || + normalizedTransportKey === 'sessionidlength' || normalizedTransportKey === 'extra' if (inboundTransportType === 'xhttp' && isXhttpCustomManagedField) return null const isXPaddingAdvancedField = @@ -3869,36 +4057,38 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo - ( - - {t('coreEditor.inbound.ech.forceQuery', { defaultValue: 'ECH force query' })} - - - - )} - /> + {!isEchForceQueryGated && ( + ( + + {t('coreEditor.inbound.ech.forceQuery', { defaultValue: 'ECH force query' })} + + + + )} + /> + )} + {jsonKey === 'allowInsecure' && isAllowInsecureHardBlocked && String(field.value) === 'true' && ( +

+ {t('coreEditor.inbound.tls.allowInsecureRemoved', { + defaultValue: "This node's Xray-core no longer builds configs with allowInsecure enabled — it will refuse to start.", + })} +

+ )} )} diff --git a/dashboard/src/features/core-editor/components/xray/xray-outbounds-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-outbounds-section.tsx index cede21c68..bf628faf3 100644 --- a/dashboard/src/features/core-editor/components/xray/xray-outbounds-section.tsx +++ b/dashboard/src/features/core-editor/components/xray/xray-outbounds-section.tsx @@ -511,6 +511,7 @@ export function XrayOutboundsSection({ headerAddPulse, headerAddEpoch }: XrayOut const dir = useDirDetection() const profile = useCoreEditorStore(s => s.xrayProfile) const coreId = useCoreEditorStore(s => s.coreId) + const coreXrayVersion = useCoreEditorStore(s => s.coreXrayVersion) const updateXrayProfile = useCoreEditorStore(s => s.updateXrayProfile) const { assertNoPersistBlockingErrors } = useXrayPersistModifyGuard() @@ -536,7 +537,7 @@ export function XrayOutboundsSection({ headerAddPulse, headerAddEpoch }: XrayOut return outbounds[selected] }, [profile, dialogMode, draftOutbound, outbounds, selected]) - const outboundCaps = useMemo(() => getOutboundFormCapabilities(), []) + const outboundCaps = useMemo(() => getOutboundFormCapabilities({ xrayVersion: coreXrayVersion ?? undefined }), [coreXrayVersion]) const outboundCapsRef = useRef(outboundCaps) outboundCapsRef.current = outboundCaps diff --git a/dashboard/src/features/core-editor/components/xray/xray-routing-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-routing-section.tsx index 1d53ae8f1..4f498eb69 100644 --- a/dashboard/src/features/core-editor/components/xray/xray-routing-section.tsx +++ b/dashboard/src/features/core-editor/components/xray/xray-routing-section.tsx @@ -204,6 +204,7 @@ export function XrayRoutingSection({ headerAddPulse, headerAddEpoch }: XrayRouti const { t } = useTranslation() const dir = useDirDetection() const profile = useCoreEditorStore(s => s.xrayProfile) + const coreXrayVersion = useCoreEditorStore(s => s.coreXrayVersion) const updateXrayProfile = useCoreEditorStore(s => s.updateXrayProfile) const { assertNoPersistBlockingErrors } = useXrayPersistModifyGuard() const [selected, setSelected] = useState(0) @@ -221,7 +222,7 @@ export function XrayRoutingSection({ headerAddPulse, headerAddEpoch }: XrayRouti }, [dialogMode, draftRule, rules, selected]) const routingCaps = useMemo(() => { - const caps = getRoutingRuleFormCapabilities(profile ? { profile } : undefined) + const caps = getRoutingRuleFormCapabilities({ ...(profile ? { profile } : {}), xrayVersion: coreXrayVersion ?? undefined }) const sorted = [...caps.fieldOrder].sort((a, b) => { const defA = caps.fieldDefinitions[a] const defB = caps.fieldDefinitions[b] @@ -233,7 +234,7 @@ export function XrayRoutingSection({ headerAddPulse, headerAddEpoch }: XrayRouti }) const fieldOrder = mergeCriticalRoutingKeys(sorted, caps.fieldDefinitions) return { ...caps, fieldOrder } - }, [profile]) + }, [profile, coreXrayVersion]) const profileTagOptions = useMemo( () => ({ @@ -383,6 +384,7 @@ export function XrayRoutingSection({ headerAddPulse, headerAddEpoch }: XrayRouti outboundTags: routingCaps.outboundTags, balancerTags: routingCaps.balancerTags, t, + xrayVersion: coreXrayVersion, }) if (routingRuleDialogHasBlockingErrors(issues)) { setRuleDialogIssues(issues) @@ -409,6 +411,7 @@ export function XrayRoutingSection({ headerAddPulse, headerAddEpoch }: XrayRouti outboundTags: routingCaps.outboundTags, balancerTags: routingCaps.balancerTags, t, + xrayVersion: coreXrayVersion, }) if (routingRuleDialogHasBlockingErrors(issues)) { setRuleDialogIssues(issues) diff --git a/dashboard/src/features/core-editor/hooks/use-xray-persist-validation-items.ts b/dashboard/src/features/core-editor/hooks/use-xray-persist-validation-items.ts index 714812fa6..e1bb3c7f4 100644 --- a/dashboard/src/features/core-editor/hooks/use-xray-persist-validation-items.ts +++ b/dashboard/src/features/core-editor/hooks/use-xray-persist-validation-items.ts @@ -8,15 +8,16 @@ export function useXrayPersistValidationItems(): ValidationListItem[] { const hydrated = useCoreEditorStore(s => s.hydrated) const kind = useCoreEditorStore(s => s.kind) const profile = useCoreEditorStore(s => s.xrayProfile) + const coreXrayVersion = useCoreEditorStore(s => s.coreXrayVersion) return useMemo(() => { if (!hydrated || kind !== 'xray' || !profile) return [] - const r = validateProfileForPersist(profile) + const r = validateProfileForPersist(profile, coreXrayVersion) if (r.ok) return [] const items: ValidationListItem[] = r.strictBlockers.map(issue => ({ source: 'xray' as const, issue })) for (const issue of r.coreKitIssues) { items.push({ source: 'core-kit' as const, issue }) } return items - }, [hydrated, kind, profile]) + }, [hydrated, kind, profile, coreXrayVersion]) } diff --git a/dashboard/src/features/core-editor/kit/core-editor-change-state.ts b/dashboard/src/features/core-editor/kit/core-editor-change-state.ts index 83b2ff859..295b5a62d 100644 --- a/dashboard/src/features/core-editor/kit/core-editor-change-state.ts +++ b/dashboard/src/features/core-editor/kit/core-editor-change-state.ts @@ -37,7 +37,7 @@ function currentConfigString(s: CoreEditorStoreState): string { } if (s.kind === 'xray' && s.xrayProfile) { const profile = s.xrayProfile - return safeConfigString('xray_current_config', () => profileToPersistedConfig(profile)) + return safeConfigString('xray_current_config', () => profileToPersistedConfig(profile, s.coreXrayVersion)) } return '' } @@ -49,7 +49,7 @@ function baselineConfigString(s: CoreEditorStoreState): string { } if (s.kind === 'xray' && s.xrayBaseline) { const profile = s.xrayBaseline - return safeConfigString('xray_baseline_config', () => profileToPersistedConfig(profile)) + return safeConfigString('xray_baseline_config', () => profileToPersistedConfig(profile, s.coreXrayVersion)) } return '' } diff --git a/dashboard/src/features/core-editor/kit/routing-rule-dialog-validation.ts b/dashboard/src/features/core-editor/kit/routing-rule-dialog-validation.ts index 2b1d14312..958af43c2 100644 --- a/dashboard/src/features/core-editor/kit/routing-rule-dialog-validation.ts +++ b/dashboard/src/features/core-editor/kit/routing-rule-dialog-validation.ts @@ -60,9 +60,10 @@ export function collectRoutingRuleDialogIssues( readonly outboundTags: readonly string[] readonly balancerTags: readonly string[] readonly t: TFunction + readonly xrayVersion?: string | null }, ): Issue[] { - const issues: Issue[] = [...validateRoutingRuleDraft(rule)] + const issues: Issue[] = [...validateRoutingRuleDraft(rule, { xrayVersion: ctx.xrayVersion ?? undefined })] const outbound = String(rule.outboundTag ?? '').trim() const balancer = String(rule.balancerTag ?? '').trim() diff --git a/dashboard/src/features/core-editor/kit/xray-adapter.ts b/dashboard/src/features/core-editor/kit/xray-adapter.ts index 70e15323b..5126b92f8 100644 --- a/dashboard/src/features/core-editor/kit/xray-adapter.ts +++ b/dashboard/src/features/core-editor/kit/xray-adapter.ts @@ -1,4 +1,5 @@ import { DEFAULT_XRAY_CORE_CONFIG } from '@/lib/default-xray-core-config' +import { checkSessionIdRoomSize } from '@/lib/xray-session-id-room-size' import { buildXrayConfig, importXrayConfig, normalizeProfile } from '@pasarguard/xray-config-kit' import type { Issue, JsonValue, Profile } from '@pasarguard/xray-config-kit' import type { CoreKitValidationIssue } from '@pasarguard/core-kit' @@ -373,13 +374,47 @@ function applyHysteriaTransportUdpmasksToCompiledConfig(profile: Profile, config } /** - * Issues from {@link buildXrayConfig} in strict mode when the profile does not compile (schema / semantic / unsafe patches, …). + * `sessionIDTable`/`sessionIDLength` (XHTTP transport) survive schema validation on any xray-config-kit + * version — the field is present, just not semantically checked. Xray-core's own Build() hard-fails when + * the table/length combination can't produce ~2.1B distinct session IDs, so this is checked here directly + * rather than relying on the kit (same category as allowInsecure: a Build()-time value check, not a + * schema fact). Runs over every inbound regardless of how it was edited — typed dialog or raw JSON — + * since both end up as the same `Profile.inbounds` shape before persist. */ -export function getXrayStrictCompileBlockers(profile: Profile): Issue[] { - const { config, issues } = buildXrayConfig(prepareProfileForKit(profile), { mode: 'strict' }) - if (!isEmptyCompiledConfig(config)) return [] +function getXhttpSessionIdRoomSizeIssues(profile: Profile): Issue[] { + const issues: Issue[] = [] + profile.inbounds.forEach((inbound, index) => { + const transport = 'transport' in inbound ? (inbound.transport as { type?: string; extra?: Record } | undefined) : undefined + if (transport?.type !== 'xhttp') return + const table = transport.extra?.sessionIDTable + const length = transport.extra?.sessionIDLength + if (typeof table !== 'string' || !table) return + const problem = checkSessionIdRoomSize(table, typeof length === 'string' ? length : '') + if (!problem) return + issues.push({ + code: problem === 'length-not-positive' ? 'XCK_XHTTP_SESSION_ID_LENGTH_NOT_POSITIVE' : 'XCK_XHTTP_SESSION_ID_ROOM_TOO_SMALL', + severity: 'error', + category: 'semantic', + path: `/inbounds/${index + 1}/transport/extra/sessionIDLength`, + message: + problem === 'length-not-positive' + ? 'sessionIDLength must be greater than 0.' + : 'Too few possible session IDs (must be at least ~2.1 billion). Increase the length range or use a larger character table.', + }) + }) + return issues +} + +/** + * Issues from {@link buildXrayConfig} in strict mode when the profile does not compile (schema / semantic / unsafe patches, …), + * plus the sessionIDTable/sessionIDLength room-size check (not covered by the kit — see {@link getXhttpSessionIdRoomSizeIssues}). + */ +export function getXrayStrictCompileBlockers(profile: Profile, xrayVersion?: string | null): Issue[] { + const roomSizeIssues = getXhttpSessionIdRoomSizeIssues(profile) + const { config, issues } = buildXrayConfig(prepareProfileForKit(profile), { mode: 'strict', xrayVersion: xrayVersion ?? undefined }) + if (!isEmptyCompiledConfig(config)) return roomSizeIssues const errors = issues.filter(i => i.severity === 'error') - return errors.length > 0 ? errors : issues + return errors.length > 0 ? [...errors, ...roomSizeIssues] : [...issues, ...roomSizeIssues] } export type XrayPersistValidationResult = { ok: true; config: Record } | { ok: false; strictBlockers: Issue[]; coreKitIssues: CoreKitValidationIssue[] } @@ -401,9 +436,9 @@ export function importRawToProfile(raw: unknown): { profile: Profile; issues: Is return { profile, issues: [...imported.issues] } } -export function profileToPersistedConfig(profile: Profile): Record { +export function profileToPersistedConfig(profile: Profile, xrayVersion?: string | null): Record { const prepared = prepareProfileForKit(profile) - const { config } = buildXrayConfig(prepared, { mode: 'permissive' }) + const { config } = buildXrayConfig(prepared, { mode: 'permissive', xrayVersion: xrayVersion ?? undefined }) const result = normalizeHysteriaSettingsForCore( applyHysteriaTransportUdpmasksToCompiledConfig( prepared, @@ -414,19 +449,19 @@ export function profileToPersistedConfig(profile: Profile): Record i.severity !== 'warning' && i.severity !== 'info') diff --git a/dashboard/src/features/core-editor/routes/core-editor-page.tsx b/dashboard/src/features/core-editor/routes/core-editor-page.tsx index 612906220..3ac54ec29 100644 --- a/dashboard/src/features/core-editor/routes/core-editor-page.tsx +++ b/dashboard/src/features/core-editor/routes/core-editor-page.tsx @@ -189,6 +189,7 @@ export default function CoreEditorPage() { const fallbacksInboundTags = useCoreEditorStore(s => s.fallbacksInboundTags) const excludeInboundTags = useCoreEditorStore(s => s.excludeInboundTags) const xrayProfile = useCoreEditorStore(s => s.xrayProfile) + const coreXrayVersion = useCoreEditorStore(s => s.coreXrayVersion) const wgDraft = useCoreEditorStore(s => s.wgDraft) const xrayImportWarnings = useCoreEditorStore(s => s.xrayImportWarnings) const activeSection = useCoreEditorStore(s => s.activeSection) @@ -323,7 +324,7 @@ export default function CoreEditorPage() { } if (kind === 'xray' && xrayProfile) { - const cfg = profileToPersistedConfig(xrayProfile) + const cfg = profileToPersistedConfig(xrayProfile, coreXrayVersion) if (isNew) { const res = await createMutation.mutateAsync({ data: { @@ -371,6 +372,7 @@ export default function CoreEditorPage() { kind, wgDraft, xrayProfile, + coreXrayVersion, preSaveIssues.length, isNew, validId, diff --git a/dashboard/src/features/core-editor/state/core-editor-store.ts b/dashboard/src/features/core-editor/state/core-editor-store.ts index ca149bb3e..bee368421 100644 --- a/dashboard/src/features/core-editor/state/core-editor-store.ts +++ b/dashboard/src/features/core-editor/state/core-editor-store.ts @@ -102,6 +102,7 @@ export interface CoreEditorStoreState { hydrated: boolean isNew: boolean coreId: number | null + coreXrayVersion: string | null coreName: string kind: CoreKind restartNodes: boolean @@ -146,6 +147,7 @@ export const useCoreEditorStore = create((set, get) => ({ hydrated: false, isNew: false, coreId: null, + coreXrayVersion: null, coreName: '', kind: 'xray', restartNodes: true, @@ -180,6 +182,7 @@ export const useCoreEditorStore = create((set, get) => ({ hydrated: true, isNew: false, coreId: core.id, + coreXrayVersion: core.xray_version ?? null, coreName: core.name, kind, restartNodes: nav.restartNodes, @@ -204,6 +207,7 @@ export const useCoreEditorStore = create((set, get) => ({ hydrated: true, isNew: false, coreId: core.id, + coreXrayVersion: core.xray_version ?? null, coreName: core.name, kind, restartNodes: nav.restartNodes, @@ -225,10 +229,12 @@ export const useCoreEditorStore = create((set, get) => ({ } const { profile, issues } = importRawToProfile(core.config) const p = cloneProfile(profile) + const xrayVersion = core.xray_version ?? null set({ hydrated: true, isNew: false, coreId: core.id, + coreXrayVersion: xrayVersion, coreName: core.name, kind, restartNodes: nav.restartNodes, @@ -240,7 +246,7 @@ export const useCoreEditorStore = create((set, get) => ({ wgBaseline: null, activeSection: nav.activeSection, dirty: false, - monacoJson: JSON.stringify(profileToPersistedConfig(p), null, 2), + monacoJson: JSON.stringify(profileToPersistedConfig(p, xrayVersion), null, 2), monacoDirty: false, xrayImportWarnings: issues.filter(i => i.severity !== 'error').map(i => i.message), serverHydratedConfigJson: serverJson, @@ -255,6 +261,7 @@ export const useCoreEditorStore = create((set, get) => ({ hydrated: true, isNew: true, coreId: null, + coreXrayVersion: null, coreName: name, kind, restartNodes: true, @@ -279,6 +286,7 @@ export const useCoreEditorStore = create((set, get) => ({ hydrated: true, isNew: true, coreId: null, + coreXrayVersion: null, coreName: name, kind, restartNodes: true, @@ -303,6 +311,7 @@ export const useCoreEditorStore = create((set, get) => ({ hydrated: false, isNew: false, coreId: null, + coreXrayVersion: null, coreName: '', kind: 'xray', restartNodes: true, @@ -408,7 +417,7 @@ export const useCoreEditorStore = create((set, get) => ({ wgBaseline: null, activeSection: defaultSection('xray'), dirty: true, - monacoJson: JSON.stringify(profileToPersistedConfig(p), null, 2), + monacoJson: JSON.stringify(profileToPersistedConfig(p, get().coreXrayVersion), null, 2), monacoDirty: false, xrayImportWarnings: [], }) @@ -417,12 +426,12 @@ export const useCoreEditorStore = create((set, get) => ({ setMonacoJson: (monacoJson, opts) => set({ monacoJson, monacoDirty: opts?.dirty ?? true }), syncMonacoFromDraft: () => { - const { kind, xrayProfile, wgDraft } = get() + const { kind, xrayProfile, wgDraft, coreXrayVersion } = get() try { if (kind === 'wg' && wgDraft) { set({ monacoJson: JSON.stringify(draftToPersistedConfig(wgDraft), null, 2), monacoDirty: false }) } else if (kind === 'xray' && xrayProfile) { - set({ monacoJson: JSON.stringify(profileToPersistedConfig(xrayProfile), null, 2), monacoDirty: false }) + set({ monacoJson: JSON.stringify(profileToPersistedConfig(xrayProfile, coreXrayVersion), null, 2), monacoDirty: false }) } } catch { /* keep previous monacoJson */ diff --git a/dashboard/src/features/hosts/components/hosts-list.tsx b/dashboard/src/features/hosts/components/hosts-list.tsx index aca4c571d..d246032c0 100644 --- a/dashboard/src/features/hosts/components/hosts-list.tsx +++ b/dashboard/src/features/hosts/components/hosts-list.tsx @@ -275,6 +275,8 @@ export default function HostsList({ uplink_http_method: host.transport_settings.xhttp_settings.uplink_http_method ?? undefined, session_placement: host.transport_settings.xhttp_settings.session_placement ?? undefined, session_key: host.transport_settings.xhttp_settings.session_key ?? undefined, + session_id_table: host.transport_settings.xhttp_settings.session_id_table ?? undefined, + session_id_length: host.transport_settings.xhttp_settings.session_id_length ?? undefined, seq_placement: host.transport_settings.xhttp_settings.seq_placement ?? undefined, seq_key: host.transport_settings.xhttp_settings.seq_key ?? undefined, uplink_data_placement: host.transport_settings.xhttp_settings.uplink_data_placement ?? undefined, diff --git a/dashboard/src/features/hosts/dialogs/host-modal.tsx b/dashboard/src/features/hosts/dialogs/host-modal.tsx index 833c94684..9a96ebfb5 100644 --- a/dashboard/src/features/hosts/dialogs/host-modal.tsx +++ b/dashboard/src/features/hosts/dialogs/host-modal.tsx @@ -24,6 +24,72 @@ import { useTranslation } from 'react-i18next' import { hostFormDefaultValues, type HostFormValues } from '@/features/hosts/forms/host-form' import { LoaderButton } from '@/components/ui/loader-button' +// Predefined sessionIDTable aliases recognized by Xray 26.6.22+. +const SESSION_ID_TABLE_PRESETS = ['ALPHABET', 'Alphabet', 'BASE36', 'Base62', 'HEX', 'alphabet', 'base36', 'hex', 'number'] + +function SessionIdTableField({ control, t }: { control: any; t: (key: string, opts?: any) => string }) { + const [customMode, setCustomMode] = useState(false) + return ( + { + const value: string = field.value ?? '' + const isPreset = SESSION_ID_TABLE_PRESETS.includes(value) + const showCustom = customMode || (value !== '' && !isPreset) + const selectValue = showCustom ? '__custom' : value === '' ? '__default' : value + return ( + + {t('hostsDialog.xhttp.sessionIdTable', { defaultValue: 'Session ID Table' })} + + {showCustom && ( + + field.onChange(e.target.value)} + placeholder={t('hostsDialog.xhttp.sessionIdTableCustomPlaceholder', { defaultValue: 'Enter custom characters (ASCII)' })} + /> + + )} + + + ) + }} + /> + ) +} + interface HostModalProps { isDialogOpen: boolean onOpenChange: (open: boolean) => void @@ -1977,6 +2043,22 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub )} /> + + + ( + + {t('hostsDialog.xhttp.sessionIdLength', { defaultValue: 'Session ID Length' })} + + + + + + )} + /> + { + const table = data?.session_id_table + const length = data?.session_id_length + // Nothing to check without a table — Xray only runs this check when sessionIDTable is set. + // But once a table is set, a missing length is itself a problem (see checkSessionIdRoomSize). + if (!table) return + const problem = checkSessionIdRoomSize(table, length ?? '') + if (!problem) return + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['session_id_length'], + message: + problem === 'length-not-positive' + ? 'sessionIDLength must be greater than 0' + : 'Too few possible session IDs (must be at least ~2.1 billion). Increase the length range or use a larger character table.', + }) + }) .nullish() .optional(), grpc_settings: z diff --git a/dashboard/src/features/nodes/dialogs/node-modal.tsx b/dashboard/src/features/nodes/dialogs/node-modal.tsx index efb0b83b6..5485013f3 100644 --- a/dashboard/src/features/nodes/dialogs/node-modal.tsx +++ b/dashboard/src/features/nodes/dialogs/node-modal.tsx @@ -10,10 +10,10 @@ import { Textarea } from '@/components/ui/textarea' import useDirDetection from '@/hooks/use-dir-detection' import useDynamicErrorHandler from '@/hooks/use-dynamic-errors.ts' import { cn } from '@/lib/utils' -import { CoresSimpleResponse, DataLimitResetStrategy, getNode, NodeConnectionType, NodeResponse, useCreateNode, useGetNode, useModifyNode } from '@/service/api' +import { CoresSimpleResponse, DataLimitResetStrategy, getNode, NodeConnectionType, NodeResponse, useCreateNode, useGetNode, useGetNodes, useModifyNode } from '@/service/api' import { formatBytes, gbToBytes } from '@/utils/formatByte' import { queryClient } from '@/utils/query-client' -import { Loader2, RefreshCw, Settings, Server, Pencil } from 'lucide-react' +import { Loader2, RefreshCw, Settings, Server, Pencil, AlertTriangle } from 'lucide-react' import React, { useEffect, useRef, useState } from 'react' import { UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -68,6 +68,20 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod const currentNode = node || initialNodeData const lastSyncedNodeRef = useRef(null) + // Warn (don't block) when the selected core config is already shared with nodes + // running a different Xray version — such a core is pushed unmodified to every + // node that uses it, so any version-specific field can behave inconsistently. + const selectedCoreConfigId = form.watch('core_config_id') + const { data: coreSiblingNodes } = useGetNodes( + { core_id: selectedCoreConfigId }, + { query: { enabled: isDialogOpen && !!selectedCoreConfigId } }, + ) + const siblingVersions = (coreSiblingNodes?.nodes ?? []) + .filter(n => n.id !== editingNodeId) + .map(n => n.xray_version) + .filter((v): v is string => !!v) + const hasVersionMismatch = new Set(siblingVersions).size > 1 + useEffect(() => { if (isDialogOpen) { setErrorDetails(null) @@ -527,6 +541,21 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod )} /> + {hasVersionMismatch && ( +

+ + + {t('nodeModal.coreVersionMismatchWarning', { + defaultValue: + "Other nodes already using this core config are running a different Xray version. This core is pushed to every node unmodified (except for a few fields the panel safely adapts per node's version) — some of the remaining settings may not work as expected.", + })} + +

+ )} + target[i]) return true + if (parsed[i] < target[i]) return false + } + return true +} + +function isBreakingUpgrade(current: string | null | undefined, target: string): boolean { + if (!current) return false + return !isAtLeast(current, SESSION_RENAME_VERSION) && isAtLeast(target, SESSION_RENAME_VERSION) +} + +function isAboveLatestStable(target: string, latest: string | null): boolean { + if (!latest) return false + const t = parseVersion(target) + const l = parseVersion(latest) + if (!t || !l) return false + for (let i = 0; i < 3; i++) { + if (t[i] > l[i]) return true + if (t[i] < l[i]) return false + } + return false +} + interface UpdateCoreDialogProps { node: NodeResponse isOpen: boolean @@ -34,6 +70,13 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC const currentVersion = node.xray_version ?? node.core_version const showUpdateBadge = currentVersion && latestVersion && hasUpdate(currentVersion) + // Which concrete version would be sent (mirrors handleUpdate's resolution). + const resolvedTargetVersion = versionMode === 'custom' ? customVersion.trim() : selectedVersion === 'latest' ? (latestVersion ?? '') : selectedVersion + const isBreaking = isBreakingUpgrade(currentVersion, resolvedTargetVersion) + const isPrereleaseTarget = resolvedTargetVersion + ? (versions.find(r => r.version === resolvedTargetVersion.replace(/^v/, ''))?.isPrerelease ?? isAboveLatestStable(resolvedTargetVersion, latestVersion)) + : false + React.useEffect(() => { if (isOpen) { setSelectedVersion('latest') @@ -101,7 +144,8 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC nodeId: node.id, data: { core_version: versionToSend, - }, + confirm: isBreaking, + } as any, }) const message = (response as any)?.detail || t('nodeModal.updateCoreSuccess', { defaultValue: 'Xray core updated successfully' }) toast.success(message) @@ -269,6 +313,22 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC + + {/* Pre-release warning — this version hasn't been promoted to a stable release yet */} + {isPrereleaseTarget && ( +
+
+ + {t('nodeModal.prereleaseWarningTitle', { defaultValue: 'Pre-release version selected' })} +
+

+ {t('nodeModal.prereleaseWarningMessage', { + defaultValue: + 'This version has not been promoted to a stable release yet. Some parameters may change or behave differently once it reaches a stable release — avoid this on production nodes unless you understand the risk.', + })} +

+
+ )} @@ -282,7 +342,9 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC isLoading={updateCoreMutation.isPending} loadingText={t('nodeModal.updating', { defaultValue: 'Updating...' })} > - {t('nodeModal.update', { defaultValue: 'Update' })} + {isBreaking || isPrereleaseTarget + ? t('nodeModal.breakingChangeConfirm', { defaultValue: 'I understand, update anyway' }) + : t('nodeModal.update', { defaultValue: 'Update' })} diff --git a/dashboard/src/lib/xray-session-id-room-size.test.ts b/dashboard/src/lib/xray-session-id-room-size.test.ts new file mode 100644 index 000000000..89b20c613 --- /dev/null +++ b/dashboard/src/lib/xray-session-id-room-size.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { checkSessionIdRoomSize, sessionIdRoomSize, sessionIdTableLength } from '@/lib/xray-session-id-room-size' + +describe('sessionIdTableLength', () => { + it('resolves predefined Xray-core aliases', () => { + expect(sessionIdTableLength('Base62')).toBe(62) + expect(sessionIdTableLength('hex')).toBe(16) + expect(sessionIdTableLength('number')).toBe(10) + }) + + it('falls back to the literal character count for a custom table', () => { + expect(sessionIdTableLength('ab')).toBe(2) + expect(sessionIdTableLength('0123456789abcdef')).toBe(16) + }) +}) + +describe('sessionIdRoomSize', () => { + it('sums tableLen^k across the range', () => { + expect(sessionIdRoomSize(2, 1, 1)).toBe(2) + expect(sessionIdRoomSize(10, 1, 2)).toBe(10 + 100) + }) +}) + +describe('checkSessionIdRoomSize', () => { + it('flags a too-small custom table/length combination', () => { + expect(checkSessionIdRoomSize('ab', '1')).toBe('room-too-small') + }) + + it('accepts a large enough preset table with a long enough length', () => { + expect(checkSessionIdRoomSize('Base62', '20')).toBeNull() + }) + + it('flags a non-positive from-length', () => { + expect(checkSessionIdRoomSize('Base62', '0')).toBe('length-not-positive') + }) + + it('flags an unparseable length once a table is set (Xray has no default length)', () => { + expect(checkSessionIdRoomSize('Base62', 'not-a-length')).toBe('length-not-positive') + }) + + it('flags a missing length once a table is set', () => { + expect(checkSessionIdRoomSize('Base62', '')).toBe('length-not-positive') + }) + + it('has nothing to check without a table — Xray only validates sessionIDLength when sessionIDTable is set', () => { + expect(checkSessionIdRoomSize('', '20')).toBeNull() + expect(checkSessionIdRoomSize('', '')).toBeNull() + }) + + it('accepts a range whose upper bound alone clears the threshold', () => { + expect(checkSessionIdRoomSize('hex', '1-30')).toBeNull() + }) +}) diff --git a/dashboard/src/lib/xray-session-id-room-size.ts b/dashboard/src/lib/xray-session-id-room-size.ts new file mode 100644 index 000000000..da6e860f2 --- /dev/null +++ b/dashboard/src/lib/xray-session-id-room-size.ts @@ -0,0 +1,58 @@ +// sessionIDTable predefined aliases (from Xray-core) -> their character-set length. +export const PREDEFINED_TABLE_LENGTHS: Record = { + ALPHABET: 26, + Alphabet: 52, + BASE36: 36, + Base62: 62, + HEX: 16, + alphabet: 26, + base36: 36, + hex: 16, + number: 10, +} + +// Xray requires at least ~2.1B possible session IDs (Go: 2<<30). +export const ROOM_SIZE_THRESHOLD = 2 * 2 ** 30 + +export function sessionIdTableLength(table: string): number { + return PREDEFINED_TABLE_LENGTHS[table] ?? table.length +} + +// Parse "from-to" (or a single "n") into [from, to]. +export function parseLengthRange(value: string): [number, number] | null { + const match = value.match(/^(\d+)(?:-(\d+))?$/) + if (!match) return null + const from = Number(match[1]) + const to = match[2] !== undefined ? Number(match[2]) : from + return [from, to] +} + +// room = sum of tableLen^k for k in [from, to]. Short-circuits once over the threshold. +export function sessionIdRoomSize(tableLen: number, from: number, to: number): number { + let sum = 0 + for (let k = from; k <= to; k++) { + sum += Math.pow(tableLen, k) + if (sum >= ROOM_SIZE_THRESHOLD) return sum + } + return sum +} + +export type SessionIdRoomSizeProblem = 'length-not-positive' | 'room-too-small' + +/** + * Mirrors Xray-core's Build()-time check on `sessionIDTable`/`sessionIDLength` (XHTTP transport). + * Xray only runs this check when `sessionIDTable` is set (`if c.SessionIDTable != ""` in + * infra/conf/transport_internet.go) — with no table, `sessionIDLength` is ignored entirely and + * Xray's own session-ID generation applies. But once a table IS set, `sessionIDLength` has no + * default: an unset/invalid length hits the same Build()-time error as a too-small range, so an + * admin-set table with no length must be flagged too, not silently treated as "nothing to check". + */ +export function checkSessionIdRoomSize(table: string, length: string): SessionIdRoomSizeProblem | null { + if (!table) return null + const range = parseLengthRange(length) + if (!range) return 'length-not-positive' + const [from, to] = range + if (from <= 0) return 'length-not-positive' + const room = sessionIdRoomSize(sessionIdTableLength(table), from, to) + return room < ROOM_SIZE_THRESHOLD ? 'room-too-small' : null +} diff --git a/dashboard/src/lib/xray-version-gates.test.ts b/dashboard/src/lib/xray-version-gates.test.ts new file mode 100644 index 000000000..808ca5103 --- /dev/null +++ b/dashboard/src/lib/xray-version-gates.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { isXrayVersionAtLeast, parseXrayVersion, XRAY_FEATURE_GATES } from '@/lib/xray-version-gates' + +describe('parseXrayVersion', () => { + it('parses a bare version', () => { + expect(parseXrayVersion('26.6.27')).toEqual({ major: 26, minor: 6, patch: 27 }) + }) + + it('parses a v-prefixed version', () => { + expect(parseXrayVersion('v26.6.27')).toEqual({ major: 26, minor: 6, patch: 27 }) + }) + + it('returns null for "latest"', () => { + expect(parseXrayVersion('latest')).toBeNull() + }) + + it('returns null for null, undefined, and empty string', () => { + expect(parseXrayVersion(null)).toBeNull() + expect(parseXrayVersion(undefined)).toBeNull() + expect(parseXrayVersion('')).toBeNull() + }) + + it('returns null for malformed strings', () => { + expect(parseXrayVersion('not-a-version')).toBeNull() + expect(parseXrayVersion('26.6')).toBeNull() + }) +}) + +describe('isXrayVersionAtLeast', () => { + it('is true when version is above cutoff', () => { + expect(isXrayVersionAtLeast('26.6.27', '26.5.3')).toBe(true) + }) + + it('is true when version equals cutoff', () => { + expect(isXrayVersionAtLeast('26.5.3', '26.5.3')).toBe(true) + }) + + it('is false when version is below cutoff', () => { + expect(isXrayVersionAtLeast('26.4.25', '26.5.3')).toBe(false) + }) + + it('is false (fail-open) when version is unknown', () => { + expect(isXrayVersionAtLeast(null, '26.5.3')).toBe(false) + expect(isXrayVersionAtLeast('latest', '26.5.3')).toBe(false) + }) + + it('compares minor/patch correctly, not lexicographically', () => { + // lexicographic "26.10.0" < "26.5.3" would be wrong; numeric compare must get this right + expect(isXrayVersionAtLeast('26.10.0', '26.5.3')).toBe(true) + }) +}) + +describe('XRAY_FEATURE_GATES', () => { + it('has all gate versions defined as parseable version strings', () => { + expect(parseXrayVersion(XRAY_FEATURE_GATES.allowInsecureHardError)).not.toBeNull() + expect(parseXrayVersion(XRAY_FEATURE_GATES.sessionIdFieldsRenamed)).not.toBeNull() + }) +}) diff --git a/dashboard/src/lib/xray-version-gates.ts b/dashboard/src/lib/xray-version-gates.ts new file mode 100644 index 000000000..5f5d7bbd4 --- /dev/null +++ b/dashboard/src/lib/xray-version-gates.ts @@ -0,0 +1,52 @@ +export interface ParsedXrayVersion { + major: number + minor: number + patch: number +} + +const VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)$/ + +export function parseXrayVersion(v: string | null | undefined): ParsedXrayVersion | null { + if (!v) return null + const match = VERSION_PATTERN.exec(v.trim()) + if (!match) return null + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) } +} + +export function compareXrayVersion(a: ParsedXrayVersion, b: ParsedXrayVersion): -1 | 0 | 1 { + if (a.major !== b.major) return a.major > b.major ? 1 : -1 + if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1 + if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1 + return 0 +} + +/** Fail-open: returns false when `version` can't be parsed (unknown/"latest"/malformed). */ +export function isXrayVersionAtLeast(version: string | null | undefined, cutoff: string): boolean { + const parsedVersion = parseXrayVersion(version) + const parsedCutoff = parseXrayVersion(cutoff) + if (!parsedVersion || !parsedCutoff) return false + return compareXrayVersion(parsedVersion, parsedCutoff) >= 0 +} + +// Verified directly against the raw XTLS/Xray-core Go source at each pinned tag +// (`infra/conf/transport_internet.go`), not commit ancestry or changelog reading. +// +// echForceQuery is deliberately NOT a gate here: it's a struct field that's fully +// removed (not a Build()-time error) at v26.6.22, and `@pasarguard/xray-config-kit`'s +// per-release parity data already tracks that — check `caps.securityFields.tls.echForceQuery` +// from `getInboundFormCapabilities()` instead of adding a second, hand-maintained cutoff here. +// +// - allowInsecure (TLSConfig.AllowInsecure): field is never removed from the struct, +// but Build() rejects `allowInsecure: true` unconditionally starting v26.6.22, and +// on v26.4.25/v26.5.3 it was only a log warning until 2026-06-01 UTC, after which +// the same binary starts hard-failing too — that date has already passed, so +// v26.4.25+ hard-fails on any Xray-core running today regardless of exact patch. +// This one stays a hand-written gate because it's a Build()-time semantic check, +// not a schema/struct-presence fact — xray-config-kit's parity data can't see it. +// - session*->sessionID* rename: still matches 26.6.22, unchanged. Kept here (rather +// than sourced from caps) because the UI needs to read/write whichever of the two +// key spellings is already present in a stored raw profile, not just show/hide a field. +export const XRAY_FEATURE_GATES = { + allowInsecureHardError: '26.4.25', + sessionIdFieldsRenamed: '26.6.22', +} as const diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index 705537313..24b733908 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -2731,6 +2731,7 @@ export interface CoreResponse { fallbacks_inbound_tags: string[] id: number created_at: string + xray_version?: string | null } export type CoreCreateFallbacksInboundTags = unknown[] | null diff --git a/dashboard/vitest.config.ts b/dashboard/vitest.config.ts new file mode 100644 index 000000000..b6f10c5e3 --- /dev/null +++ b/dashboard/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + resolve: { + alias: [{ find: '@', replacement: path.resolve(__dirname, 'src') }], + }, + test: { + environment: 'node', + }, +}) diff --git a/tests/api/test_core.py b/tests/api/test_core.py index 5078957b2..d293f2a91 100644 --- a/tests/api/test_core.py +++ b/tests/api/test_core.py @@ -20,6 +20,21 @@ def test_core_create(access_token): delete_core(access_token, core["id"]) +def test_core_get_includes_xray_version(access_token): + """Test that GET /api/core/{id} includes xray_version, defaulting to None when no node is attached to the core.""" + + core = create_core(access_token, name=unique_name("xray_version_field")) + response = client.get( + url=f"/api/core/{core['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert "xray_version" in body + assert body["xray_version"] is None + delete_core(access_token, core["id"]) + + def test_wireguard_core_create(access_token): """Test that a WireGuard core can be created.""" diff --git a/tests/api/test_node.py b/tests/api/test_node.py index d738b792a..e99a8256e 100644 --- a/tests/api/test_node.py +++ b/tests/api/test_node.py @@ -476,6 +476,314 @@ async def cleanup_nodes_simple(core_id: int, node_ids: list[int]) -> None: await remove_core_config(session, db_core) +async def test_update_node_status_preserves_version_when_not_provided(): + from app.db.crud.node import update_node_status + + async with TestSession() as session: + core = await create_core_config(session, core_create_model(unique_name("core_preserve_version"))) + core_id = inspect(core).identity[0] + node_model = NodeCreate(**node_create_payload(name=unique_name("node_preserve_version"), core_config_id=core_id)) + db_node = await db_create_node(session, node_model) + node_id = inspect(db_node).identity[0] + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + await update_node_status(session, db_node, NodeStatus.connected, xray_version="26.6.1", node_version="0.5.2") + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + assert db_node.xray_version == "26.6.1" + assert db_node.node_version == "0.5.2" + + # Transition to error WITHOUT passing xray_version/node_version (the real-world + # call pattern from every error path in operation/node.py and jobs/node_checker.py) — + # the last-known version must survive. + await update_node_status(session, db_node, NodeStatus.error, message="Connection refused") + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + assert db_node.status == NodeStatus.error + assert db_node.message == "Connection refused" + assert db_node.xray_version == "26.6.1" + assert db_node.node_version == "0.5.2" + + async with TestSession() as session: + core = await session.get(CoreConfig, core_id) + await cleanup_nodes_simple(core_id, [node_id]) + + +async def test_bulk_update_node_status_preserves_version_when_empty(): + from app.db.crud.node import bulk_update_node_status, update_node_status + + async with TestSession() as session: + core = await create_core_config(session, core_create_model(unique_name("core_bulk_preserve"))) + core_id = inspect(core).identity[0] + node_a_model = NodeCreate(**node_create_payload(name=unique_name("node_bulk_a"), core_config_id=core_id)) + node_b_model = NodeCreate(**node_create_payload(name=unique_name("node_bulk_b"), core_config_id=core_id)) + node_c_model = NodeCreate(**node_create_payload(name=unique_name("node_bulk_c"), core_config_id=core_id)) + db_node_a = await db_create_node(session, node_a_model) + db_node_b = await db_create_node(session, node_b_model) + db_node_c = await db_create_node(session, node_c_model) + node_a_id = inspect(db_node_a).identity[0] + node_b_id = inspect(db_node_b).identity[0] + node_c_id = inspect(db_node_c).identity[0] + + async with TestSession() as session: + db_node_a = await session.get(Node, node_a_id) + db_node_b = await session.get(Node, node_b_id) + db_node_c = await session.get(Node, node_c_id) + await update_node_status(session, db_node_a, NodeStatus.connected, xray_version="26.6.1", node_version="0.5.2") + await update_node_status(session, db_node_b, NodeStatus.connected, xray_version="26.5.3", node_version="0.5.1") + await update_node_status(session, db_node_c, NodeStatus.connected, xray_version="26.5.9", node_version="0.4.9") + + async with TestSession() as session: + # node_a: real error transition with a fresh reported version (should overwrite). + # node_b: error transition without a known version (should preserve "26.5.3"). + # node_c: mismatched columns - xray_version reported (overwrite), node_version empty (preserve). + await bulk_update_node_status( + session, + [ + { + "node_id": node_a_id, + "status": NodeStatus.error, + "message": "boom", + "xray_version": "26.6.22", + "node_version": "0.5.3", + }, + { + "node_id": node_b_id, + "status": NodeStatus.error, + "message": "boom", + "xray_version": "", + "node_version": "", + }, + { + "node_id": node_c_id, + "status": NodeStatus.error, + "message": "boom", + "xray_version": "26.6.27", + "node_version": "", + }, + ], + ) + + async with TestSession() as session: + db_node_a = await session.get(Node, node_a_id) + db_node_b = await session.get(Node, node_b_id) + db_node_c = await session.get(Node, node_c_id) + assert db_node_a.xray_version == "26.6.22" + assert db_node_a.node_version == "0.5.3" + assert db_node_b.xray_version == "26.5.3" + assert db_node_b.node_version == "0.5.1" + assert db_node_c.xray_version == "26.6.27" + assert db_node_c.node_version == "0.4.9" + + await cleanup_nodes_simple(core_id, [node_a_id, node_b_id, node_c_id]) + + +def _xhttp_core_config(tag: str) -> dict: + return { + "inbounds": [ + { + "tag": tag, + "listen": "0.0.0.0", + "port": 8443, + "protocol": "vless", + "settings": {"clients": [], "decryption": "none"}, + "streamSettings": { + "network": "xhttp", + "xhttpSettings": { + "path": "/", + "sessionPlacement": "header", + "sessionKey": "X-Request-ID", + }, + }, + } + ], + "outbounds": [{"tag": "DIRECT", "protocol": "freedom"}], + } + + +async def test_update_node_status_does_not_rewrite_stored_session_keys(): + """update_node_status must never mutate the admin's stored core config — the sessionID* + rename is applied on the fly per-node when pushing config (NodeOperation.connect_node), + not persisted. Crossing the version threshold here should leave the stored config as-is.""" + from app.db.crud.node import update_node_status + + async with TestSession() as session: + core = await create_core_config( + session, + CoreCreate(name=unique_name("core_no_migrate"), config=_xhttp_core_config("xhttp-no-migrate"), exclude_inbound_tags=set(), fallbacks_inbound_tags=set()), + ) + core_id = inspect(core).identity[0] + node_model = NodeCreate(**node_create_payload(name=unique_name("node_no_migrate"), core_config_id=core_id)) + db_node = await db_create_node(session, node_model) + node_id = inspect(db_node).identity[0] + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + await update_node_status(session, db_node, NodeStatus.connected, xray_version="26.5.9", node_version="0.5.0") + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + # Crossing the sessionID-rename threshold must not touch the stored config. + await update_node_status(session, db_node, NodeStatus.connected, xray_version="26.6.22", node_version="0.5.3") + + async with TestSession() as session: + db_core = await session.get(CoreConfig, core_id) + xhttp = db_core.config["inbounds"][0]["streamSettings"]["xhttpSettings"] + assert xhttp["sessionPlacement"] == "header" + assert xhttp["sessionKey"] == "X-Request-ID" + assert "sessionIDPlacement" not in xhttp + assert "sessionIDKey" not in xhttp + + await cleanup_nodes_simple(core_id, [node_id]) + + +async def test_get_xray_version_by_core_id_ignores_status(): + from app.db.crud.node import get_xray_version_by_core_id, update_node_status + + async with TestSession() as session: + core = await create_core_config(session, core_create_model(unique_name("core_version_lookup"))) + core_id = inspect(core).identity[0] + node_model = NodeCreate(**node_create_payload(name=unique_name("node_version_lookup"), core_config_id=core_id)) + db_node = await db_create_node(session, node_model) + node_id = inspect(db_node).identity[0] + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + await update_node_status(session, db_node, NodeStatus.connected, xray_version="26.6.1", node_version="0.5.2") + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + # Node errors out but keeps its last-known version. + await update_node_status(session, db_node, NodeStatus.error, message="Connection refused") + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + assert db_node.status == NodeStatus.error + + version = await get_xray_version_by_core_id(session, core_id) + assert version == "26.6.1" + + await cleanup_nodes_simple(core_id, [node_id]) + + +async def test_get_xray_version_by_core_id_prefers_known_version_over_never_connected_sibling(): + from app.db.crud.node import get_xray_version_by_core_id, update_node_status + + async with TestSession() as session: + core = await create_core_config(session, core_create_model(unique_name("core_version_sibling"))) + core_id = inspect(core).identity[0] + node_a_model = NodeCreate(**node_create_payload(name=unique_name("node_a_never_connected"), core_config_id=core_id)) + node_b_model = NodeCreate(**node_create_payload(name=unique_name("node_b_known_version"), core_config_id=core_id)) + db_node_a = await db_create_node(session, node_a_model) + db_node_b = await db_create_node(session, node_b_model) + node_a_id = inspect(db_node_a).identity[0] + node_b_id = inspect(db_node_b).identity[0] + + async with TestSession() as session: + # node_a never gets a version set (simulates a freshly added, never-connected node). + db_node_b = await session.get(Node, node_b_id) + await update_node_status(session, db_node_b, NodeStatus.connected, xray_version="26.6.1", node_version="0.5.2") + + async with TestSession() as session: + version = await get_xray_version_by_core_id(session, core_id) + assert version == "26.6.1" + + await cleanup_nodes_simple(core_id, [node_a_id, node_b_id]) + + +async def test_get_xray_version_by_core_id_picks_highest_version_when_shared(): + """When a core is shared by nodes on different Xray versions, the lookup must + deterministically prefer the highest reported version — not an arbitrary DB row + order, and not simply whichever node reported most recently""" + from app.db.crud.node import get_xray_version_by_core_id, update_node_status + + async with TestSession() as session: + core = await create_core_config(session, core_create_model(unique_name("core_version_shared"))) + core_id = inspect(core).identity[0] + node_a_model = NodeCreate(**node_create_payload(name=unique_name("node_a_shared"), core_config_id=core_id)) + node_b_model = NodeCreate(**node_create_payload(name=unique_name("node_b_shared"), core_config_id=core_id)) + db_node_a = await db_create_node(session, node_a_model) + db_node_b = await db_create_node(session, node_b_model) + node_a_id = inspect(db_node_a).identity[0] + node_b_id = inspect(db_node_b).identity[0] + + async with TestSession() as session: + db_node_a = await session.get(Node, node_a_id) + # node_a (higher version) reports first... + await update_node_status(session, db_node_a, NodeStatus.connected, xray_version="26.6.27", node_version="0.5.3") + + async with TestSession() as session: + db_node_b = await session.get(Node, node_b_id) + # ...node_b (lower version) reports later. The higher version must still win. + await update_node_status(session, db_node_b, NodeStatus.connected, xray_version="25.10.15", node_version="0.5.0") + + async with TestSession() as session: + version = await get_xray_version_by_core_id(session, core_id) + assert version == "26.6.27" + + await cleanup_nodes_simple(core_id, [node_a_id, node_b_id]) + + +async def test_modify_node_status_toggle_preserves_version(): + from app.db.crud.node import modify_node, update_node_status + + async with TestSession() as session: + core = await create_core_config(session, core_create_model(unique_name("core_modify_status_only"))) + core_id = inspect(core).identity[0] + node_model = NodeCreate(**node_create_payload(name=unique_name("node_modify_status_only"), core_config_id=core_id)) + db_node = await db_create_node(session, node_model) + node_id = inspect(db_node).identity[0] + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + await update_node_status(session, db_node, NodeStatus.connected, xray_version="26.6.1", node_version="0.5.2") + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + # Pure status toggle: disable the node without changing address/port/etc. + await modify_node(session, db_node, NodeModify(status=NodeStatus.disabled)) + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + assert db_node.status == NodeStatus.disabled + assert db_node.xray_version == "26.6.1" + assert db_node.node_version == "0.5.2" + + await cleanup_nodes_simple(core_id, [node_id]) + + +async def test_modify_node_address_change_clears_version(): + from app.db.crud.node import modify_node, update_node_status + + async with TestSession() as session: + core = await create_core_config(session, core_create_model(unique_name("core_modify_address_change"))) + core_id = inspect(core).identity[0] + node_model = NodeCreate(**node_create_payload(name=unique_name("node_modify_address_change"), core_config_id=core_id)) + db_node = await db_create_node(session, node_model) + node_id = inspect(db_node).identity[0] + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + await update_node_status(session, db_node, NodeStatus.connected, xray_version="26.6.1", node_version="0.5.2") + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + # Real identity change: pointing at a different address should reset the known version, + # since the panel can no longer assume the same physical instance is on the other end. + await modify_node(session, db_node, NodeModify(address="different-node.example.com")) + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + assert db_node.address == "different-node.example.com" + assert db_node.xray_version is None + assert db_node.node_version is None + + await cleanup_nodes_simple(core_id, [node_id]) + + def usage_stats_payload() -> NodeUsageStatsList: start = datetime(2024, 1, 1, tzinfo=timezone.utc) end = start + timedelta(days=1) diff --git a/tests/test_subscription_clash_xhttp.py b/tests/test_subscription_clash_xhttp.py index 58bc83902..2cf57b43e 100644 --- a/tests/test_subscription_clash_xhttp.py +++ b/tests/test_subscription_clash_xhttp.py @@ -1,7 +1,8 @@ -from app.core.xray import XRayConfig +from app.core.xray import XRayConfig, rename_xhttp_session_keys from app.models.host import XHttpSettings, XMuxSettings from app.models.subscription import SubscriptionInboundData, TLSConfig, XHTTPTransportConfig from app.subscription.clash import ClashConfiguration, ClashMetaConfiguration +from app.subscription.xray import XrayConfiguration USER_ID = "11111111-1111-1111-1111-111111111111" @@ -310,3 +311,86 @@ def test_xray_parser_does_not_mix_top_level_advanced_fields_when_extra_exists(): assert inbound["session_key"] == "sid" assert inbound["x_padding_obfs_mode"] is None assert inbound["uplink_http_method"] is None + + +def test_xray_xhttp_old_version_uses_legacy_session_keys(): + conf = XrayConfiguration() + cfg = XHTTPTransportConfig( + path="/up", + host="cdn.example.com", + mode="stream-up", + session_placement="query", + session_key="sid", + core_version="v26.6.1", + ) + + extra = conf._transport_xhttp(cfg, "/up")["extra"] + + assert extra["sessionPlacement"] == "query" + assert extra["sessionKey"] == "sid" + assert "sessionIDPlacement" not in extra + assert "sessionIDKey" not in extra + assert "sessionIDTable" not in extra + assert "sessionIDLength" not in extra + + +def test_xray_xhttp_new_version_uses_session_id_keys_and_new_fields(): + conf = XrayConfiguration() + cfg = XHTTPTransportConfig( + path="/up", + host="cdn.example.com", + mode="stream-up", + session_placement="query", + session_key="sid", + session_id_table="Base62", + session_id_length="8-16", + core_version="v26.6.22", + ) + + extra = conf._transport_xhttp(cfg, "/up")["extra"] + + assert extra["sessionIDPlacement"] == "query" + assert extra["sessionIDKey"] == "sid" + assert extra["sessionIDTable"] == "Base62" + assert extra["sessionIDLength"] == "8-16" + assert "sessionPlacement" not in extra + assert "sessionKey" not in extra + + +def test_xray_xhttp_unknown_version_keeps_legacy_keys(): + conf = XrayConfiguration() + cfg = XHTTPTransportConfig( + path="/up", + host="cdn.example.com", + mode="stream-up", + session_placement="query", + session_key="sid", + core_version=None, + ) + + extra = conf._transport_xhttp(cfg, "/up")["extra"] + + assert extra["sessionPlacement"] == "query" + assert "sessionIDPlacement" not in extra + + +def test_rename_xhttp_session_keys_renames_nested_keys(): + config = { + "inbounds": [ + { + "streamSettings": { + "xhttpSettings": { + "extra": {"sessionPlacement": "query", "sessionKey": "sid"}, + }, + }, + } + ], + } + + rename_xhttp_session_keys(config) + + extra = config["inbounds"][0]["streamSettings"]["xhttpSettings"]["extra"] + assert extra["sessionIDPlacement"] == "query" + assert extra["sessionIDKey"] == "sid" + assert "sessionPlacement" not in extra + assert "sessionKey" not in extra