From c878882988b6c0e688c085900cde416ab3dbb347 Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Fri, 3 Jul 2026 18:18:22 +0700 Subject: [PATCH 01/13] feat(node): preserve and expose last-known xray_version data --- app/db/crud/node.py | 53 +++++-- app/models/core.py | 1 + app/routers/core.py | 8 +- dashboard/src/service/api/index.ts | 1 + tests/api/test_core.py | 15 ++ tests/api/test_node.py | 216 +++++++++++++++++++++++++++++ 6 files changed, 277 insertions(+), 17 deletions(-) diff --git a/app/db/crud/node.py b/app/db/crud/node.py index a3083e376..a523cae0a 100644 --- a/app/db/crud/node.py +++ b/app/db/crud/node.py @@ -86,6 +86,15 @@ 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: + return ( + await db.execute( + select(Node.xray_version) + .where(Node.core_config_id == core_config_id) + .where(Node.xray_version.isnot(None)) + .limit(1) + ) + ).scalar_one_or_none() async def get_nodes( db: AsyncSession, @@ -427,6 +436,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 +456,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 +502,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 +561,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/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/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/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..99a3c3da6 100644 --- a/tests/api/test_node.py +++ b/tests/api/test_node.py @@ -476,6 +476,222 @@ 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]) + + +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 (Task 1's behavior). + 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_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) From 5d63398b47b7c2e603d4781550e3973b3ff01713 Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Fri, 3 Jul 2026 18:18:22 +0700 Subject: [PATCH 02/13] feat(node): migrate xhttp session keys for Xray 26.6.22+ --- app/core/hosts.py | 21 +++- app/core/manager.py | 10 +- app/core/xray.py | 17 +++- app/db/crud/core.py | 47 +++++++++ app/db/crud/node.py | 6 ++ app/models/host.py | 2 + app/models/node.py | 2 +- app/models/subscription.py | 5 +- app/operation/node.py | 25 ++++- app/subscription/base.py | 10 +- app/subscription/links.py | 8 +- app/subscription/xray.py | 9 +- dashboard/public/statics/locales/en.json | 91 ++--------------- dashboard/public/statics/locales/fa.json | 94 +++--------------- dashboard/public/statics/locales/ru.json | 89 ++--------------- dashboard/public/statics/locales/zh.json | 89 ++--------------- .../nodes/dialogs/update-core-modal.tsx | 48 ++++++++- tests/api/test_node.py | 97 +++++++++++++++++++ tests/test_subscription_clash_xhttp.py | 86 +++++++++++++++- 19 files changed, 411 insertions(+), 345 deletions(-) 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/core.py b/app/db/crud/core.py index 20e2c6186..f638c7819 100644 --- a/app/db/crud/core.py +++ b/app/db/crud/core.py @@ -21,6 +21,53 @@ def _build_core_simple_sort_clause(sort_option: CoreSimpleSortOption): return column.desc() if sort_option.value.startswith("-") else column.asc() +def _migrate_xhttp_session_keys(config: dict) -> tuple[dict, bool]: + """Renames legacy xhttp `sessionPlacement`/`sessionKey` fields to `sessionIDPlacement`/ + `sessionIDKey` in every inbound. Returns (possibly-new config, whether anything changed).""" + inbounds = config.get("inbounds") + if not isinstance(inbounds, list): + return config, False + changed = False + new_inbounds = [] + for inbound in inbounds: + stream = inbound.get("streamSettings") if isinstance(inbound, dict) else None + xhttp = stream.get("xhttpSettings") if isinstance(stream, dict) else None + if not isinstance(xhttp, dict): + new_inbounds.append(inbound) + continue + new_xhttp = dict(xhttp) + inbound_changed = False + for old_key, new_key in (("sessionPlacement", "sessionIDPlacement"), ("sessionKey", "sessionIDKey")): + if old_key in new_xhttp and new_key not in new_xhttp: + new_xhttp[new_key] = new_xhttp.pop(old_key) + inbound_changed = True + if not inbound_changed: + new_inbounds.append(inbound) + continue + changed = True + new_inbounds.append({**inbound, "streamSettings": {**stream, "xhttpSettings": new_xhttp}}) + if not changed: + return config, False + return {**config, "inbounds": new_inbounds}, True + + +async def migrate_core_xhttp_session_keys_if_sole_node(db: AsyncSession, core_config_id: int) -> None: + """Called when a node just reported an Xray-core version that requires the sessionID* + rename. Only rewrites the stored core config when this is the only node using it — + a sibling node still on an older Xray-core would otherwise silently break.""" + node_count = (await db.execute(select(func.count()).select_from(Node).where(Node.core_config_id == core_config_id))).scalar_one() + if node_count != 1: + return + db_core_config = await get_core_config_by_id(db, core_config_id) + if db_core_config is None: + return + new_config, changed = _migrate_xhttp_session_keys(db_core_config.config) + if not changed: + return + db_core_config.config = new_config + await db.commit() + + async def get_core_config_by_id(db: AsyncSession, core_id: int) -> CoreConfig | None: """ Retrieves a core configuration by its ID. diff --git a/app/db/crud/node.py b/app/db/crud/node.py index a523cae0a..79527f2cf 100644 --- a/app/db/crud/node.py +++ b/app/db/crud/node.py @@ -7,6 +7,7 @@ from sqlalchemy.sql.functions import coalesce from app.db.compiles_types import DateDiff +from app.db.crud.core import migrate_core_xhttp_session_keys_if_sole_node from app.db.models import ( DataLimitResetStrategy, Node, @@ -26,6 +27,7 @@ UsageTable, ) from app.models.stats import NodeStats, NodeStatsList, NodeUsageStat, NodeUsageStatsList, Period +from app.subscription.base import is_new_xray from .general import ( MYSQL_FORMATS, @@ -502,6 +504,7 @@ async def update_node_status( Returns: Node: The updated Node object. """ + old_xray_version = db_node.xray_version values: dict = { "status": status, "message": message, @@ -516,6 +519,9 @@ async def update_node_status( await db.execute(stmt) await db.commit() + if xray_version and is_new_xray(xray_version) and not is_new_xray(old_xray_version): + await migrate_core_xhttp_session_keys_if_sole_node(db, db_node.core_config_id) + try: # Prefer refreshing the existing instance to keep relationships loaded await db.refresh(db_node) 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/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..e854cf3e3 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,8 @@ 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, "seqPlacement": config.seq_placement, "seqKey": config.seq_key, "uplinkDataPlacement": config.uplink_data_placement, @@ -125,6 +126,7 @@ def _transport_xhttp(self, payload: dict, protocol: str, config: XHTTPTransportC "xmux": config.xmux, "headers": config.http_headers if config.http_headers else {}, "downloadSettings": config.download_settings, + **({"sessionIDTable": config.session_id_table, "sessionIDLength": config.session_id_length} if is_new else {}), } if config.random_user_agent: diff --git a/app/subscription/xray.py b/app/subscription/xray.py index f21740614..4afaba219 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,8 @@ 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, "seqPlacement": config.seq_placement, "seqKey": config.seq_key, "uplinkDataPlacement": config.uplink_data_placement, @@ -160,6 +160,7 @@ def _transport_xhttp(self, config: XHTTPTransportConfig, path: str) -> dict: "downloadSettings": self._xhttp_download_config(config.download_settings) if config.download_settings else None, + **({"sessionIDTable": config.session_id_table, "sessionIDLength": config.session_id_length} if is_new else {}), } if config.random_user_agent: diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 18c60a682..aa48926ec 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -180,7 +180,6 @@ "usageLimitWarning": "Usage Limit Warning", "modifyHosts": "Modify Hosts", "connect": "Connect", - "recovered": "Recovered", "error": "Error", "limited": "Limited", "statusChange": "Status Change", @@ -260,14 +259,6 @@ "headerValue": "Header value", "noHeaders": "No response headers configured. Add headers to include custom HTTP headers in subscription responses." }, - "customVariables": { - "title": "Custom Variables", - "description": "Create reusable placeholders for subscription and host output.", - "addVariable": "Add Variable", - "empty": "No custom variables configured.", - "duplicateKey": "Duplicate custom variable key.", - "builtinConflict": "This key is reserved for a built-in variable." - }, "rules": { "title": "Subscription Rules", "description": "Configure client-specific subscription rules and formats", @@ -711,14 +702,6 @@ "subDomain": "Subscription Domain", "profile": "Profile Title", "subTemplate": "Subscription Template Path", - "customVariables": { - "title": "Custom Variables", - "description": "Override global custom variables for users owned by this admin.", - "addVariable": "Add Variable", - "empty": "No custom variables configured.", - "duplicateKey": "Duplicate custom variable key.", - "builtinConflict": "This key is reserved for a built-in variable." - }, "passwordConfirm": "Confirm Password", "enterPasswordConfirm": "Enter Confirm Password", "disableSuccess": "Admin «{{name}}» has been disabled successfully", @@ -744,54 +727,7 @@ "monitor": { "traffic": "Monitor admin traffic usage over time", "no_traffic": "No traffic data available" - }, - "disabled": "Disabled" - }, - "apiKeys": { - "title": "API Keys", - "description": "Manage API keys for programmatic access", - "createKey": "Create API Key", - "editKey": "Modify API Key", - "admin": "Admin", - "selectAdmin": "Select admin", - "adminDescription": "The key will authenticate as this admin.", - "name": "Name", - "key": "API Key", - "keyId": "Key ID", - "note": "Note", - "role": "Role", - "status": "Status", - "expireDate": "Expire Date", - "createdAt": "Created At", - "revokedAt": "Revoked At", - "revoke": "Revoke", - "delete": "Delete", - "apiKey": "API Key", - "apiKeyCopy": "Copy API Key", - "apiKeyCopySuccess": "API key copied to clipboard", - "apiKeyShowWarning": "This key will only be shown once. Please copy it now.", - "createSuccess": "API key created successfully", - "createFailed": "Failed to create API key", - "updateSuccess": "API key updated successfully", - "updateFailed": "Failed to update API key", - "revokeSuccess": "API key revoked and reissued successfully", - "revokeFailed": "Failed to revoke API key", - "deleteSuccess": "API key deleted successfully", - "deleteFailed": "Failed to delete API key", - "deletePrompt": "Are you sure you want to delete API key \"{{name}}\"?", - "revokePrompt": "Revoking will invalidate the current key and issue a new one. Are you sure?", - "revokeTitle": "Revoke & Reissue API Key", - "deleteTitle": "Delete API Key", - "inheritPermissions": "Inherit admin permissions", - "inheritPermissionsDescription": "Use the owning admin's current role permissions. Disable to store custom permissions on this key.", - "inherited": "Inherited", - "noKeys": "No API keys configured", - "noKeysDescription": "Create an API key to allow programmatic access.", - "noSearchResults": "No API keys match your search criteria. Try adjusting your search terms or filters.", - "bulkDeleteSuccess": "{{count}} API keys deleted successfully.", - "bulkDeleteFailed": "Failed to delete selected API keys.", - "bulkDeleteTitle": "Delete selected API keys", - "bulkDeletePrompt": "Are you sure you want to delete {{count}} selected API keys? This action cannot be undone." + } }, "adminRoles": { "title": "Admin Roles", @@ -812,7 +748,7 @@ "limits": "Limits", "limitsAndFeatures": "Limits & Features", "limitsHint": "Leave empty to inherit defaults. Set to 0 to disable.", - "roleFormHint": "Scoped actions use none, own, or all. Other actions are boolean toggles.", + "roleFormHint": "Scoped user actions use none, own, or all. Other actions are boolean toggles.", "scopedActionInfo": "Choose ownership scope: none denies access, own limits to the admin's own users, all grants full access.", "allowAll": "Allow all", "features": "Features", @@ -858,7 +794,6 @@ "users": "Users", "admins": "Admins", "roles": "Roles", - "apiKeys": "API Keys", "nodes": "Nodes", "coreHosts": "Core and hosts", "groupsTemplates": "Groups and templates", @@ -914,7 +849,6 @@ "users": "Users", "admins": "Admins", "admin_roles": "Roles", - "api_keys": "API Keys", "nodes": "Nodes", "cores": "Cores", "hosts": "Hosts", @@ -925,7 +859,6 @@ "system": "System", "hwids": "HWIDs" }, - "noAvailablePermissions": "No permissions available.", "scopedBadge": "Scoped", "scopes": { "none": "None", @@ -1193,9 +1126,7 @@ "header.nodeSettings": "Node Settings", "header.nodesUsage": "Nodes Usage", "loading": "Loading...", - "clear": "Clear", "never": "Never", - "resources": "resources", "noResults": "No results found", "noAdminsFound": "No admins found", "emptyState": { @@ -1289,6 +1220,10 @@ "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", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", @@ -1438,13 +1373,6 @@ "format": "Matched subscription format (e.g. links, sing_box)", "admin_username": "Admin username" }, - "customVariables": { - "title": "Custom variables", - "trigger": "Custom variables", - "preview": "Preview", - "empty": "No custom variables configured.", - "emptyValue": "Empty value" - }, "advancedOptions": "Advanced Options", "hos.info": "By default, if a request host is set in the XRAY configuration, it will be used. However, if needed, you can set a custom request host here.", "multiHost": "To set multiple addresses, separate them with , A random address will be selected each time.", @@ -1705,7 +1633,6 @@ "dataLimit": "Data Limit", "hwidLimit": "HWID Limit", "hwidLimitPlaceholder": "Empty = default, 0 = unlimited", - "hwidLimitHint": "Empty = use default policy. 0 = unlimited and exempt from HWID, even when the HWID header is forced.", "days": "Days", "editUser": "Modify user", "editUserTitle": "Modify user", @@ -2059,7 +1986,10 @@ "updating": "Updating...", "updateAvailable": "Update Available", "prerelease": "Pre-release", - "loadingReleases": "Loading releases..." + "loadingReleases": "Loading releases...", + "breakingChangeTitle": "Breaking changes in Xray 26.6.22+", + "breakingChangeWarning": "Starting with Xray 26.6.22, the xhttp parameters \"sessionPlacement\" and \"sessionKey\" are renamed to \"sessionIDPlacement\" and \"sessionIDKey\". Server configs are migrated automatically when the new version is installed. However, users whose xhttp inbound subscriptions still contain sessionPlacement/sessionKey will not be able to connect to the server after the upgrade until they re-import their subscription. Continue?", + "breakingChangeConfirm": "I understand, update anyway" }, "theme": { "title": "Theme", @@ -3387,7 +3317,6 @@ "timeSelector.pickDate": "Pick a date", "advanceSearch": { "title": "Advanced Search", - "description": "Filter API keys by identifier and status.", "searchMode": "Search mode", "searchModeDescription": "Choose how the main search field should be interpreted.", "byUsername": "Username and Notes", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 136f58b88..9da105232 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -63,7 +63,6 @@ "usageLimitWarning": "هشدار محدودیت مصرف", "modifyHosts": "ویرایش هاست‌ها", "connect": "اتصال", - "recovered": "بازیابی", "error": "خطا", "limited": "محدود شده", "statusChange": "تغییر وضعیت", @@ -146,14 +145,6 @@ "headerValue": "مقدار هدر", "noHeaders": "هیچ هدر پاسخی پیکربندی نشده است. هدرها را اضافه کنید تا هدرهای HTTP سفارشی در پاسخ‌های اشتراک قرار گیرند." }, - "customVariables": { - "title": "متغیرهای سفارشی", - "description": "جای‌نگهدارهای قابل استفاده مجدد برای خروجی اشتراک و هاست بسازید.", - "addVariable": "افزودن متغیر", - "empty": "هیچ متغیر سفارشی پیکربندی نشده است.", - "duplicateKey": "کلید متغیر سفارشی تکراری است.", - "builtinConflict": "این کلید برای یک متغیر داخلی رزرو شده است." - }, "rules": { "title": "قوانین اشتراک", "description": "پیکربندی قوانین اشتراک مخصوص کلاینت و فرمت‌ها", @@ -481,9 +472,7 @@ "saving": "در حال ذخیره...", "general": "عمومی", "loading": "در حال بارگذاری...", - "clear": "پاک کردن", "never": "هرگز", - "resources": "منابع", "noResults": "نتیجه‌ای یافت نشد", "noAdminsFound": "مدیری یافت نشد", "emptyState": { @@ -580,14 +569,6 @@ "subDomain": "دامنه اشتراک", "profile": "عنوان پروفایل", "subTemplate": "مسیر قالب اشتراک", - "customVariables": { - "title": "متغیرهای سفارشی", - "description": "متغیرهای سفارشی سراسری را برای کاربران این ادمین بازنویسی کنید.", - "addVariable": "افزودن متغیر", - "empty": "هیچ متغیر سفارشی پیکربندی نشده است.", - "duplicateKey": "کلید متغیر سفارشی تکراری است.", - "builtinConflict": "این کلید برای یک متغیر داخلی رزرو شده است." - }, "passwordConfirm": "تأیید رمز عبور", "enterPasswordConfirm": "رمز عبور را دوباره وارد کنید", "disableSuccess": "مدیر «{{name}}» با موفقیت غیرفعال شد", @@ -613,54 +594,7 @@ "monitor": { "traffic": "نظارت بر مصرف ترافیک مدیر در طول زمان", "no_traffic": "داده‌ای برای ترافیک موجود نیست" - }, - "disabled": "???????" - }, - "apiKeys": { - "title": "کلیدهای API", - "description": "مدیریت کلیدهای API برای دسترسی برنامه‌نویسی", - "createKey": "ایجاد کلید API", - "editKey": "ویرایش کلید API", - "admin": "مدیر", - "selectAdmin": "انتخاب مدیر", - "adminDescription": "این کلید با هویت این مدیر احراز هویت می‌شود.", - "name": "نام", - "key": "کلید API", - "keyId": "شناسه کلید", - "note": "یادداشت", - "role": "نقش", - "status": "وضعیت", - "expireDate": "تاریخ انقضا", - "createdAt": "تاریخ ایجاد", - "revokedAt": "تاریخ ابطال", - "revoke": "ابطال", - "delete": "حذف", - "apiKey": "کلید API", - "apiKeyCopy": "کپی کلید API", - "apiKeyCopySuccess": "کلید API در حافظه کپی شد", - "apiKeyShowWarning": "این کلید فقط یکبار نمایش داده می‌شود. لطفا همین الان آن را کپی کنید.", - "createSuccess": "کلید API با موفقیت ایجاد شد", - "createFailed": "ایجاد کلید API ناموفق بود", - "updateSuccess": "کلید API با موفقیت بروزرسانی شد", - "updateFailed": "بروزرسانی کلید API ناموفق بود", - "revokeSuccess": "کلید API با موفقیت ابطال و مجدداً صادر شد", - "revokeFailed": "ابطال کلید API ناموفق بود", - "deleteSuccess": "کلید API با موفقیت حذف شد", - "deleteFailed": "حذف کلید API ناموفق بود", - "deletePrompt": "آیا از حذف کلید API «{{name}}» اطمینان دارید؟", - "revokePrompt": "ابطال کلید باعث غیرفعال شدن کلید فعلی و صدور کلید جدید می‌شود. آیا مطمئن هستید؟", - "revokeTitle": "ابطال و صدور مجدد کلید API", - "deleteTitle": "حذف کلید API", - "inheritPermissions": "ارث‌بری مجوزهای مدیر", - "inheritPermissionsDescription": "از مجوزهای فعلی نقش مدیر مالک استفاده شود. برای ذخیره مجوزهای سفارشی روی این کلید غیرفعال کنید.", - "inherited": "ارث‌بری‌شده", - "noKeys": "هیچ کلید API تنظیم نشده است", - "noKeysDescription": "برای دسترسی برنامه‌نویسی یک کلید API ایجاد کنید.", - "noSearchResults": "هیچ کلید API با معیارهای جست‌وجوی شما مطابقت ندارد. عبارت جست‌وجو یا فیلترها را تغییر دهید.", - "bulkDeleteSuccess": "{{count}} کلید API با موفقیت حذف شد.", - "bulkDeleteFailed": "حذف کلیدهای API انتخاب‌شده ناموفق بود.", - "bulkDeleteTitle": "حذف کلیدهای API انتخاب‌شده", - "bulkDeletePrompt": "آیا از حذف {{count}} کلید API انتخاب‌شده مطمئن هستید؟ این عملیات قابل بازگشت نیست." + } }, "adminRoles": { "title": "نقش‌های مدیر", @@ -681,7 +615,7 @@ "limits": "محدودیت‌ها", "limitsAndFeatures": "محدودیت‌ها و قابلیت‌ها", "limitsHint": "برای استفاده از پیش‌فرض، خالی بگذارید. برای غیرفعال‌سازی ۰ بگذارید.", - "roleFormHint": "اعمال دارای محدوده، مقدار «هیچ»، «خودی» یا «همه» می‌گیرند. سایر اعمال سوئیچ بولی هستند.", + "roleFormHint": "اعمال کاربر دارای محدوده، مقدار «هیچ»، «خودی» یا «همه» می‌گیرند. سایر اعمال سوئیچ بولی هستند.", "scopedActionInfo": "محدوده مالکیت را انتخاب کنید: «هیچ» دسترسی را رد می‌کند، «خودی» فقط کاربران خود مدیر را شامل می‌شود و «همه» دسترسی کامل می‌دهد.", "allowAll": "اجازه همه", "features": "قابلیت‌ها", @@ -727,7 +661,6 @@ "users": "کاربران", "admins": "مدیران", "roles": "نقش‌ها", - "apiKeys": "کلیدهای API", "nodes": "گره‌ها", "coreHosts": "هسته و هاست‌ها", "groupsTemplates": "گروه‌ها و قالب‌ها", @@ -783,7 +716,6 @@ "users": "کاربران", "admins": "مدیران", "admin_roles": "نقش‌ها", - "api_keys": "کلیدهای API", "nodes": "گره‌ها", "cores": "هسته‌ها", "hosts": "هاست‌ها", @@ -794,7 +726,6 @@ "system": "سیستم", "hwids": "HWID" }, - "noAvailablePermissions": "هیچ مجوزی در دسترس نیست.", "scopedBadge": "محدوده‌دار", "scopes": { "none": "هیچ", @@ -811,7 +742,8 @@ "max_hwid_per_user": "حداکثر HWID برای هر کاربر", "on_hold_timeout_days_min": "حداقل زمان انتظار (روز)", "on_hold_timeout_days_max": "حداکثر زمان انتظار (روز)" - }, + }, + "limitedBehavior": { "disabledWhenLimited": { "title": "مسدود کردن مدیران محدودشده", @@ -909,7 +841,6 @@ "dataLimit": "محدودیت داده", "hwidLimit": "محدودیت شناسه سخت‌افزار", "hwidLimitPlaceholder": "خالی = پیش‌فرض، ۰ = نامحدود", - "hwidLimitHint": "خالی = استفاده از سیاست پیش‌فرض. ۰ = نامحدود و معاف از شناسه سخت‌افزار، حتی زمانی که هدر شناسه سخت‌افزار الزامی شده باشد.", "expire": "مدت انقضا", "onHoldTimeout": "زمان انتظار توقف", "method": "روش", @@ -1141,6 +1072,10 @@ "uplinkHttpMethod": "Uplink HTTP Method", "sessionPlacement": "Session Placement", "sessionKey": "Session Key", + "sessionIdTable": "جدول Session ID", + "sessionIdTablePlaceholder": "مثلاً Base62، HEX یا کاراکترهای دلخواه", + "sessionIdLength": "طول Session ID", + "sessionIdLengthPlaceholder": "مثلاً ۸ یا ۸-۱۶", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", @@ -1289,13 +1224,6 @@ "format": "فرمت اشتراک منطبق‌شده (مثلاً links یا sing_box)", "admin_username": "نام کاربری ادمین" }, - "customVariables": { - "title": "متغیرهای سفارشی", - "trigger": "متغیرهای سفارشی", - "preview": "پیش‌نمایش", - "empty": "هیچ متغیر سفارشی پیکربندی نشده است.", - "emptyValue": "مقدار خالی" - }, "advancedOptions": "تنظیمات پیشرفته", "host.info": "به طور پیش‌فرض، اگر هاست درخواست در پیکربندی XRAY تنظیم شده باشد، از آن استفاده می‌شود. با این حال، در صورت نیاز، می‌توانید یک هاست درخواست سفارشی در اینجا تنظیم کنید.", "host.multiHost": "برای تنظیم چند آدرس، آنها را با , جدا کنید. هر بار یک آدرس به صورت تصادفی انتخاب می‌شود.", @@ -1871,7 +1799,10 @@ "updating": "در حال به‌روزرسانی...", "updateAvailable": "به‌روزرسانی موجود است", "prerelease": "نسخه پیش‌انتشار", - "loadingReleases": "در حال بارگذاری نسخه‌ها..." + "loadingReleases": "در حال بارگذاری نسخه‌ها...", + "breakingChangeTitle": "تغییرات ناسازگار در Xray 26.6.22+", + "breakingChangeWarning": "از نسخه Xray 26.6.22 به بعد، پارامترهای xhttp یعنی «sessionPlacement» و «sessionKey» به «sessionIDPlacement» و «sessionIDKey» تغییر نام می‌یابند. پیکربندی‌های سرور هنگام نصب نسخه جدید به‌صورت خودکار مهاجرت می‌کنند. اما کاربرانی که در اشتراک‌های اینباند xhttp آن‌ها هنوز sessionPlacement/sessionKey وجود دارد، پس از به‌روزرسانی تا زمانی که اشتراک خود را دوباره وارد نکنند نمی‌توانند به سرور متصل شوند. ادامه می‌دهید؟", + "breakingChangeConfirm": "متوجه شدم، در هر صورت به‌روزرسانی شود" }, "nodes": { "title": "گره‌ها", @@ -3360,7 +3291,6 @@ "timeSelector.pickDate": "انتخاب تاریخ", "advanceSearch": { "title": "جستجوی پیشرفته", - "description": "فیلتر کردن کلیدهای API بر اساس شناسه و وضعیت.", "searchMode": "حالت جست‌وجو", "searchModeDescription": "مشخص کنید فیلد جست‌وجوی اصلی چگونه تفسیر شود.", "byUsername": "نام کاربری و یادداشت‌ها", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 537e29c17..ba47e9a36 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -195,7 +195,6 @@ "usageLimitWarning": "Предупреждение о лимите", "modifyHosts": "Изменить хосты", "connect": "Подключить", - "recovered": "Восстановлен", "error": "Ошибка", "limited": "Ограничен", "statusChange": "Изменение статуса", @@ -278,14 +277,6 @@ "headerValue": "Значение заголовка", "noHeaders": "Заголовки ответа не настроены. Добавьте заголовки для включения пользовательских HTTP-заголовков в ответы подписки." }, - "customVariables": { - "title": "Пользовательские переменные", - "description": "Создавайте переиспользуемые заполнители для вывода подписки и хоста.", - "addVariable": "Добавить переменную", - "empty": "Пользовательские переменные не настроены.", - "duplicateKey": "Ключ пользовательской переменной уже используется.", - "builtinConflict": "Этот ключ зарезервирован для встроенной переменной." - }, "rules": { "title": "Правила подписки", "description": "Настройка правил подписки для конкретных клиентов и форматов", @@ -697,14 +688,6 @@ "subDomain": "Домен подписки", "profile": "Заголовок профиля", "subTemplate": "Путь шаблона подписки", - "customVariables": { - "title": "Пользовательские переменные", - "description": "Переопределите глобальные пользовательские переменные для пользователей этого администратора.", - "addVariable": "Добавить переменную", - "empty": "Пользовательские переменные не настроены.", - "duplicateKey": "Ключ пользовательской переменной уже используется.", - "builtinConflict": "Этот ключ зарезервирован для встроенной переменной." - }, "passwordConfirm": "Подтвердите пароль", "enterPasswordConfirm": "Введите подтверждение пароля", "disableSuccess": "Администратор «{{name}}» был успешно отключен", @@ -730,54 +713,7 @@ "monitor": { "traffic": "Мониторинг использования трафика администратором во времени", "no_traffic": "Данные о трафике отсутствуют" - }, - "disabled": "????????" - }, - "apiKeys": { - "title": "API ключи", - "description": "Управление API ключами для программного доступа", - "createKey": "Создать API ключ", - "editKey": "Изменить API ключ", - "admin": "Админ", - "selectAdmin": "Выберите админа", - "adminDescription": "Ключ будет выполнять аутентификацию от имени этого админа.", - "name": "Название", - "key": "API ключ", - "keyId": "ID ключа", - "note": "Заметка", - "role": "Роль", - "status": "Статус", - "expireDate": "Дата истечения", - "createdAt": "Дата создания", - "revokedAt": "Дата отзыва", - "revoke": "Отозвать", - "delete": "Удалить", - "apiKey": "API ключ", - "apiKeyCopy": "Копировать API ключ", - "apiKeyCopySuccess": "API ключ скопирован в буфер обмена", - "apiKeyShowWarning": "Этот ключ будет показан только один раз. Пожалуйста, скопируйте его сейчас.", - "createSuccess": "API ключ успешно создан", - "createFailed": "Не удалось создать API ключ", - "updateSuccess": "API ключ успешно обновлен", - "updateFailed": "Не удалось обновить API ключ", - "revokeSuccess": "API ключ успешно отозван и перевыпущен", - "revokeFailed": "Не удалось отозвать API ключ", - "deleteSuccess": "API ключ успешно удален", - "deleteFailed": "Не удалось удалить API ключ", - "deletePrompt": "Вы уверены, что хотите удалить API ключ «{{name}}»?", - "revokePrompt": "Отзыв сделает текущий ключ недействительным и выпустит новый. Вы уверены?", - "revokeTitle": "Отозвать и перевыпустить API ключ", - "deleteTitle": "Удалить API ключ", - "inheritPermissions": "Наследовать права админа", - "inheritPermissionsDescription": "Использовать текущие права роли владельца ключа. Отключите, чтобы сохранить на этом ключе собственные права.", - "inherited": "Наследуется", - "noKeys": "API ключи не настроены", - "noKeysDescription": "Создайте API ключ для программного доступа.", - "noSearchResults": "Нет API ключей, соответствующих условиям поиска. Измените поисковый запрос или фильтры.", - "bulkDeleteSuccess": "{{count}} API ключей успешно удалено.", - "bulkDeleteFailed": "Не удалось удалить выбранные API ключи.", - "bulkDeleteTitle": "Удалить выбранные API ключи", - "bulkDeletePrompt": "Вы уверены, что хотите удалить выбранные API ключи ({{count}})? Это действие нельзя отменить." + } }, "adminRoles": { "title": "Роли администраторов", @@ -844,7 +780,6 @@ "users": "Пользователи", "admins": "Админы", "roles": "Роли", - "apiKeys": "API Ключи", "nodes": "Узлы", "coreHosts": "Ядра и хосты", "groupsTemplates": "Группы и шаблоны", @@ -900,7 +835,6 @@ "users": "Пользователи", "admins": "Админы", "admin_roles": "Роли", - "api_keys": "API ключи", "nodes": "Узлы", "cores": "Ядра", "hosts": "Хосты", @@ -911,7 +845,6 @@ "system": "Система", "hwids": "HWID" }, - "noAvailablePermissions": "Нет доступных разрешений.", "scopedBadge": "С областью", "scopes": { "none": "Нет", @@ -1028,7 +961,6 @@ "dataLimit": "Лимит данных", "hwidLimit": "Лимит HWID", "hwidLimitPlaceholder": "Пусто = по умолчанию, 0 = без лимита", - "hwidLimitHint": "Пусто = использовать политику по умолчанию. 0 = без лимита и без проверки HWID, даже когда заголовок HWID обязателен.", "expire": "Срок действия", "onHoldTimeout": "Тайм-аут при ожидании", "method": "Метод", @@ -1500,6 +1432,10 @@ "uplinkHttpMethod": "Uplink HTTP Method", "sessionPlacement": "Session Placement", "sessionKey": "Session Key", + "sessionIdTable": "Таблица Session ID", + "sessionIdTablePlaceholder": "напр. Base62, HEX или свой набор символов", + "sessionIdLength": "Длина Session ID", + "sessionIdLengthPlaceholder": "напр. 8 или 8-16", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", @@ -1649,13 +1585,6 @@ "format": "Совпавший формат подписки (например, links, sing_box)", "admin_username": "Имя пользователя администратора" }, - "customVariables": { - "title": "Пользовательские переменные", - "trigger": "Пользовательские переменные", - "preview": "Предпросмотр", - "empty": "Пользовательские переменные не настроены.", - "emptyValue": "Пустое значение" - }, "httpVersions": { "1.0": "HTTP/1.0", "1.1": "HTTP/1.1", @@ -1947,7 +1876,10 @@ "updating": "Обновление...", "updateAvailable": "Доступно обновление", "prerelease": "Предварительная версия", - "loadingReleases": "Загрузка релизов..." + "loadingReleases": "Загрузка релизов...", + "breakingChangeTitle": "Ломающие изменения в Xray 26.6.22+", + "breakingChangeWarning": "Начиная с Xray 26.6.22, параметры xhttp «sessionPlacement» и «sessionKey» переименованы в «sessionIDPlacement» и «sessionIDKey». Серверные конфиги мигрируют автоматически при установке новой версии. Однако пользователи, у которых в подписках с xhttp-инбаундами остались sessionPlacement/sessionKey, не смогут подключиться к серверу после обновления, пока не переимпортируют подписку. Продолжить?", + "breakingChangeConfirm": "Понимаю, всё равно обновить" }, "theme": { "title": "Тема", @@ -3325,7 +3257,6 @@ "timeSelector.pickDate": "Выбрать дату", "advanceSearch": { "title": "Расширенный поиск", - "description": "Фильтрация API ключей по идентификатору и статусу.", "searchMode": "Режим поиска", "searchModeDescription": "Определяет, как интерпретируется основное поле поиска.", "byUsername": "Имя пользователя и заметки", @@ -3379,9 +3310,7 @@ "clearAllFilters": "Очистить все фильтры", "activeFilters": "активных", "loading": "Загрузка...", - "clear": "Очистить", "never": "Никогда", - "resources": "ресурсы", "all": "Все", "hosts.filters.userStatus": "Правила статуса пользователя", "hosts.filters.userStatusDescription": "Фильтрует хосты по заданным статусам пользователей (активен, на паузе, ограничен и т.д.).", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 856d09f6c..9716fce7a 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -164,7 +164,6 @@ "usageLimitWarning": "用量限制预警", "modifyHosts": "修改主机", "connect": "连接", - "recovered": "已恢复", "error": "错误", "limited": "受限", "statusChange": "状态变更", @@ -244,14 +243,6 @@ "headerValue": "头值", "noHeaders": "未配置响应头。添加头以在订阅响应中包含自定义 HTTP 头。" }, - "customVariables": { - "title": "自定义变量", - "description": "为订阅和主机输出创建可复用的占位符。", - "addVariable": "添加变量", - "empty": "未配置自定义变量。", - "duplicateKey": "自定义变量键重复。", - "builtinConflict": "此键已保留给内置变量。" - }, "rules": { "title": "订阅规则", "description": "为特定客户端模式定义自定义规则", @@ -711,14 +702,6 @@ "subDomain": "订阅域名", "profile": "个人资料标题", "subTemplate": "订阅模板路径", - "customVariables": { - "title": "自定义变量", - "description": "为此管理员名下的用户覆盖全局自定义变量。", - "addVariable": "添加变量", - "empty": "未配置自定义变量。", - "duplicateKey": "自定义变量键重复。", - "builtinConflict": "此键已保留给内置变量。" - }, "passwordConfirm": "确认密码", "enterPasswordConfirm": "请输入确认密码", "disableSuccess": "管理员「{{name}}」已成功禁用", @@ -744,54 +727,7 @@ "monitor": { "traffic": "监控管理员使用情况", "no_traffic": "没有使用情况数据" - }, - "disabled": "??" - }, - "apiKeys": { - "title": "API 密钥", - "description": "管理用于编程访问的 API 密钥", - "createKey": "创建 API 密钥", - "editKey": "修改 API 密钥", - "admin": "管理员", - "selectAdmin": "选择管理员", - "adminDescription": "此密钥将以该管理员身份进行认证。", - "name": "名称", - "key": "API 密钥", - "keyId": "密钥 ID", - "note": "备注", - "role": "角色", - "status": "状态", - "expireDate": "过期日期", - "createdAt": "创建于", - "revokedAt": "吊销于", - "revoke": "吊销", - "delete": "删除", - "apiKey": "API 密钥", - "apiKeyCopy": "复制 API 密钥", - "apiKeyCopySuccess": "API 密钥已复制到剪贴板", - "apiKeyShowWarning": "此密钥仅显示一次。请立即复制。", - "createSuccess": "API 密钥创建成功", - "createFailed": "创建 API 密钥失败", - "updateSuccess": "API 密钥更新成功", - "updateFailed": "更新 API 密钥失败", - "revokeSuccess": "API 密钥已成功吊销并重新签发", - "revokeFailed": "吊销 API 密钥失败", - "deleteSuccess": "API 密钥删除成功", - "deleteFailed": "删除 API 密钥失败", - "deletePrompt": "您确定要删除 API 密钥「{{name}}」吗?", - "revokePrompt": "吊销将使当前密钥失效并签发新密钥。您确定吗?", - "revokeTitle": "吊销并重新签发 API 密钥", - "deleteTitle": "删除 API 密钥", - "inheritPermissions": "继承管理员权限", - "inheritPermissionsDescription": "使用拥有者管理员当前角色的权限。关闭后将在此密钥上保存自定义权限。", - "inherited": "已继承", - "noKeys": "未配置 API 密钥", - "noKeysDescription": "创建 API 密钥以允许程序化访问。", - "noSearchResults": "没有 API 密钥符合搜索条件。请调整搜索词或筛选器。", - "bulkDeleteSuccess": "已成功删除 {{count}} 个 API 密钥。", - "bulkDeleteFailed": "删除所选 API 密钥失败。", - "bulkDeleteTitle": "删除所选 API 密钥", - "bulkDeletePrompt": "确定要删除选中的 {{count}} 个 API 密钥吗?此操作无法撤销。" + } }, "adminRoles": { "title": "管理员角色", @@ -858,7 +794,6 @@ "users": "用户", "admins": "管理员", "roles": "角色", - "apiKeys": "API 密钥", "nodes": "节点", "coreHosts": "核心和主机", "groupsTemplates": "组和模板", @@ -914,7 +849,6 @@ "users": "用户", "admins": "管理员", "admin_roles": "角色", - "api_keys": "API 密钥", "nodes": "节点", "cores": "核心", "hosts": "主机", @@ -925,7 +859,6 @@ "system": "系统", "hwids": "HWID" }, - "noAvailablePermissions": "没有可用权限。", "scopedBadge": "带范围", "scopes": { "none": "无", @@ -1042,7 +975,6 @@ "dataLimit": "数据限制", "hwidLimit": "HWID 限制", "hwidLimitPlaceholder": "留空 = 默认,0 = 无限制", - "hwidLimitHint": "留空 = 使用默认策略。0 = 无限制并免除 HWID 校验,即使已强制要求 HWID 请求头。", "expire": "过期时间", "onHoldTimeout": "挂起超时", "method": "方法", @@ -1262,6 +1194,10 @@ "uplinkHttpMethod": "Uplink HTTP Method", "sessionPlacement": "Session Placement", "sessionKey": "Session Key", + "sessionIdTable": "Session ID 字符表", + "sessionIdTablePlaceholder": "例如 Base62、HEX 或自定义字符", + "sessionIdLength": "Session ID 长度", + "sessionIdLengthPlaceholder": "例如 8 或 8-16", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", @@ -1411,13 +1347,6 @@ "format": "匹配到的订阅格式(例如:links、sing_box)", "admin_username": "管理员用户名" }, - "customVariables": { - "title": "自定义变量", - "trigger": "自定义变量", - "preview": "预览", - "empty": "未配置自定义变量。", - "emptyValue": "空值" - }, "advancedOptions": "高级选项", "hos.info": "默认情况下,如果请求主机在 XRAY 配置中设置,则使用该主机。但是,如果需要,您可以在此处设置自定义请求主机。", "multiHost": "要设置多个地址,请使用 , 作为分隔符。每次随机选择一个地址。", @@ -2018,7 +1947,10 @@ "updating": "更新中...", "updateAvailable": "有更新可用", "prerelease": "预发布版本", - "loadingReleases": "正在加载版本..." + "loadingReleases": "正在加载版本...", + "breakingChangeTitle": "Xray 26.6.22+ 的重大变更", + "breakingChangeWarning": "从 Xray 26.6.22 开始,xhttp 参数 “sessionPlacement” 和 “sessionKey” 已重命名为 “sessionIDPlacement” 和 “sessionIDKey”。安装新版本时,服务器配置会自动迁移。但是,订阅中 xhttp 入站仍包含 sessionPlacement/sessionKey 的用户在升级后将无法连接到服务器,直到他们重新导入订阅。是否继续?", + "breakingChangeConfirm": "我已了解,仍然更新" }, "theme": { "title": "主题", @@ -3169,9 +3101,7 @@ }, "online": "在线", "loading": "加载中...", - "clear": "清除", "never": "从未", - "resources": "资源", "noResults": "未找到结果", "noAdminsFound": "未找到管理员", "emptyState": { @@ -3384,7 +3314,6 @@ "timeSelector.pickDate": "选择日期", "advanceSearch": { "title": "高级搜索", - "description": "按标识符和状态筛选 API 密钥。", "searchMode": "搜索模式", "searchModeDescription": "选择如何解释主搜索框中的内容。", "byUsername": "用户名和备注", diff --git a/dashboard/src/features/nodes/dialogs/update-core-modal.tsx b/dashboard/src/features/nodes/dialogs/update-core-modal.tsx index 111adcde0..aaad80696 100644 --- a/dashboard/src/features/nodes/dialogs/update-core-modal.tsx +++ b/dashboard/src/features/nodes/dialogs/update-core-modal.tsx @@ -8,13 +8,37 @@ import { queryClient } from '@/utils/query-client' import { useUpdateCore, NodeResponse } from '@/service/api' import { useXrayReleases } from '@/hooks/use-xray-releases' import { LoaderButton } from '@/components/ui/loader-button' -import { Cpu, ExternalLink } from 'lucide-react' +import { Cpu, ExternalLink, AlertTriangle } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { cn } from '@/lib/utils' import useDirDetection from '@/hooks/use-dir-detection' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Input } from '@/components/ui/input' +// Xray 26.6.22 renamed xhttp session* params to sessionID* (breaking change). +const SESSION_RENAME_VERSION = [26, 6, 22] + +function parseVersion(version: string): number[] | null { + const match = version.replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/) + if (!match) return null + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +function isAtLeast(version: string, target: number[]): boolean { + const parsed = parseVersion(version) + if (!parsed) return false + for (let i = 0; i < 3; i++) { + if (parsed[i] > 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) +} + interface UpdateCoreDialogProps { node: NodeResponse isOpen: boolean @@ -34,6 +58,10 @@ 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) + React.useEffect(() => { if (isOpen) { setSelectedVersion('latest') @@ -101,7 +129,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 +298,19 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC + + {/* Breaking change warning (xhttp session* -> sessionID* in Xray 26.6.22+) */} + {isBreaking && ( +
+
+ + {t('nodeModal.breakingChangeTitle', { defaultValue: 'Breaking changes in Xray 26.6.22+' })} +
+

+ {t('nodeModal.breakingChangeWarning')} +

+
+ )} @@ -282,7 +324,7 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC isLoading={updateCoreMutation.isPending} loadingText={t('nodeModal.updating', { defaultValue: 'Updating...' })} > - {t('nodeModal.update', { defaultValue: 'Update' })} + {isBreaking ? t('nodeModal.breakingChangeConfirm', { defaultValue: 'I understand, update anyway' }) : t('nodeModal.update', { defaultValue: 'Update' })} diff --git a/tests/api/test_node.py b/tests/api/test_node.py index 99a3c3da6..755e1a549 100644 --- a/tests/api/test_node.py +++ b/tests/api/test_node.py @@ -581,6 +581,103 @@ async def test_bulk_update_node_status_preserves_version_when_empty(): 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_migrates_session_keys_for_sole_node(): + 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_migrate_sole"), config=_xhttp_core_config("xhttp-sole"), exclude_inbound_tags=set(), fallbacks_inbound_tags=set()), + ) + core_id = inspect(core).identity[0] + node_model = NodeCreate(**node_create_payload(name=unique_name("node_migrate_sole"), 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) + # Starts below the sessionID-rename threshold — no migration yet. + 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_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" + + async with TestSession() as session: + db_node = await session.get(Node, node_id) + # Crossing the threshold on the sole node using this core triggers the migration. + 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["sessionIDPlacement"] == "header" + assert xhttp["sessionIDKey"] == "X-Request-ID" + assert "sessionPlacement" not in xhttp + assert "sessionKey" not in xhttp + + await cleanup_nodes_simple(core_id, [node_id]) + + +async def test_update_node_status_skips_migration_when_core_shared(): + 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_migrate_shared"), config=_xhttp_core_config("xhttp-shared"), exclude_inbound_tags=set(), fallbacks_inbound_tags=set()), + ) + core_id = inspect(core).identity[0] + node_a_model = NodeCreate(**node_create_payload(name=unique_name("node_migrate_shared_a"), core_config_id=core_id)) + node_b_model = NodeCreate(**node_create_payload(name=unique_name("node_migrate_shared_b"), 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) + await update_node_status(session, db_node_a, NodeStatus.connected, xray_version="26.5.9", node_version="0.5.0") + + async with TestSession() as session: + db_node_a = await session.get(Node, node_a_id) + # node_b (still on this core) is unaccounted for, so the shared config must not be rewritten. + await update_node_status(session, db_node_a, 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" + + await cleanup_nodes_simple(core_id, [node_a_id, node_b_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 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 From 9db8823c38302e1dd078e0e45c6de41c5de5a22f Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Fri, 3 Jul 2026 18:18:22 +0700 Subject: [PATCH 03/13] feat(hosts): add sessionIDTable/sessionIDLength controls --- dashboard/public/statics/locales/en.json | 2 + dashboard/public/statics/locales/fa.json | 2 + dashboard/public/statics/locales/ru.json | 2 + dashboard/public/statics/locales/zh.json | 2 + .../src/features/hosts/dialogs/host-modal.tsx | 193 ++++++++++++------ 5 files changed, 141 insertions(+), 60 deletions(-) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index aa48926ec..f1b772623 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -1224,6 +1224,8 @@ "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", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 9da105232..e091631f5 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -1076,6 +1076,8 @@ "sessionIdTablePlaceholder": "مثلاً Base62، HEX یا کاراکترهای دلخواه", "sessionIdLength": "طول Session ID", "sessionIdLengthPlaceholder": "مثلاً ۸ یا ۸-۱۶", + "customValue": "سفارشی", + "sessionIdTableCustomPlaceholder": "کاراکترهای دلخواه را وارد کنید (ASCII)", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index ba47e9a36..58163aa7e 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1436,6 +1436,8 @@ "sessionIdTablePlaceholder": "напр. Base62, HEX или свой набор символов", "sessionIdLength": "Длина Session ID", "sessionIdLengthPlaceholder": "напр. 8 или 8-16", + "customValue": "Своё значение", + "sessionIdTableCustomPlaceholder": "Введите свой набор символов (ASCII)", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 9716fce7a..1cf79dd72 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -1198,6 +1198,8 @@ "sessionIdTablePlaceholder": "例如 Base62、HEX 或自定义字符", "sessionIdLength": "Session ID 长度", "sessionIdLengthPlaceholder": "例如 8 或 8-16", + "customValue": "自定义", + "sessionIdTableCustomPlaceholder": "输入自定义字符 (ASCII)", "seqPlacement": "Seq Placement", "seqKey": "Seq Key", "uplinkDataPlacement": "Uplink Data Placement", diff --git a/dashboard/src/features/hosts/dialogs/host-modal.tsx b/dashboard/src/features/hosts/dialogs/host-modal.tsx index 833c94684..f827babb2 100644 --- a/dashboard/src/features/hosts/dialogs/host-modal.tsx +++ b/dashboard/src/features/hosts/dialogs/host-modal.tsx @@ -10,7 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { CustomVariablesPopover, VariablesList, VariablesPopover } from '@/components/ui/variables-popover' +import { VariablesList, VariablesPopover } from '@/components/ui/variables-popover' import useDirDetection from '@/hooks/use-dir-detection' import { useIsMobile } from '@/hooks/use-mobile' import { cn } from '@/lib/utils' @@ -24,6 +24,73 @@ 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'] + +// Select with predefined tables + a "Custom" option that reveals a free-text input. +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 @@ -190,10 +257,9 @@ interface ArrayInputProps { placeholder: string label: string infoContent?: React.ReactNode - customVariablesTrigger?: React.ReactNode } -const ArrayInput = memo(({ field, placeholder, label, infoContent, customVariablesTrigger }) => { +const ArrayInput = memo(({ field, placeholder, label, infoContent }) => { const { t } = useTranslation() const dir = useDirDetection() const isMobile = useIsMobile() @@ -217,7 +283,6 @@ const ArrayInput = memo(({ field, placeholder, label, infoConte )} - {customVariablesTrigger} = ({ isDialogOpen, onOpenChange, onSub
{t('remark')} -
@@ -1002,7 +1066,7 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub render={({ field }) => { const infoContent = - return } /> + return }} /> @@ -1180,7 +1244,7 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub ) - return } /> + return }} /> = ({ isDialogOpen, onOpenChange, onSub

{t('hostsDialog.path.info')}

- @@ -1977,6 +2040,22 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub )} /> + + + ( + + {t('hostsDialog.xhttp.sessionIdLength', { defaultValue: 'Session ID Length' })} + + + + + + )} + /> + = ({ isDialogOpen, onOpenChange, onSub

{t('hostsDialog.tcp.requestHeaders')}

-
- - -
+
{/* Render request headers */} @@ -2670,32 +2746,29 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub

{t('hostsDialog.tcp.responseHeaders')}

-
- - -
+
{/* Render response headers */} From c29dfade3c4377f258370fe511d544a7207bf5c8 Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Fri, 3 Jul 2026 18:18:23 +0700 Subject: [PATCH 04/13] feat(core-editor): make Xray validation and UI version-aware --- dashboard/bun.lock | 71 ++++- dashboard/package.json | 6 +- dashboard/public/statics/locales/en.json | 85 +++++- dashboard/public/statics/locales/fa.json | 88 ++++++- dashboard/public/statics/locales/ru.json | 83 +++++- dashboard/public/statics/locales/zh.json | 83 +++++- .../components/xray/xray-advanced-section.tsx | 9 +- .../xray/xray-balancers-section.tsx | 3 +- .../components/xray/xray-inbounds-section.tsx | 244 +++++++++++++++--- .../xray/xray-outbounds-section.tsx | 3 +- .../components/xray/xray-routing-section.tsx | 7 +- .../use-xray-persist-validation-items.ts | 5 +- .../kit/core-editor-change-state.ts | 4 +- .../kit/routing-rule-dialog-validation.ts | 3 +- .../features/core-editor/kit/xray-adapter.ts | 22 +- .../core-editor/routes/core-editor-page.tsx | 4 +- .../core-editor/state/core-editor-store.ts | 17 +- dashboard/src/lib/xray-version-gates.test.ts | 58 +++++ dashboard/src/lib/xray-version-gates.ts | 52 ++++ dashboard/vitest.config.ts | 11 + 20 files changed, 782 insertions(+), 76 deletions(-) create mode 100644 dashboard/src/lib/xray-version-gates.test.ts create mode 100644 dashboard/src/lib/xray-version-gates.ts create mode 100644 dashboard/vitest.config.ts 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 f1b772623..d4fd9e6c9 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -180,6 +180,7 @@ "usageLimitWarning": "Usage Limit Warning", "modifyHosts": "Modify Hosts", "connect": "Connect", + "recovered": "Recovered", "error": "Error", "limited": "Limited", "statusChange": "Status Change", @@ -259,6 +260,14 @@ "headerValue": "Header value", "noHeaders": "No response headers configured. Add headers to include custom HTTP headers in subscription responses." }, + "customVariables": { + "title": "Custom Variables", + "description": "Create reusable placeholders for subscription and host output.", + "addVariable": "Add Variable", + "empty": "No custom variables configured.", + "duplicateKey": "Duplicate custom variable key.", + "builtinConflict": "This key is reserved for a built-in variable." + }, "rules": { "title": "Subscription Rules", "description": "Configure client-specific subscription rules and formats", @@ -702,6 +711,14 @@ "subDomain": "Subscription Domain", "profile": "Profile Title", "subTemplate": "Subscription Template Path", + "customVariables": { + "title": "Custom Variables", + "description": "Override global custom variables for users owned by this admin.", + "addVariable": "Add Variable", + "empty": "No custom variables configured.", + "duplicateKey": "Duplicate custom variable key.", + "builtinConflict": "This key is reserved for a built-in variable." + }, "passwordConfirm": "Confirm Password", "enterPasswordConfirm": "Enter Confirm Password", "disableSuccess": "Admin «{{name}}» has been disabled successfully", @@ -727,7 +744,54 @@ "monitor": { "traffic": "Monitor admin traffic usage over time", "no_traffic": "No traffic data available" - } + }, + "disabled": "Disabled" + }, + "apiKeys": { + "title": "API Keys", + "description": "Manage API keys for programmatic access", + "createKey": "Create API Key", + "editKey": "Modify API Key", + "admin": "Admin", + "selectAdmin": "Select admin", + "adminDescription": "The key will authenticate as this admin.", + "name": "Name", + "key": "API Key", + "keyId": "Key ID", + "note": "Note", + "role": "Role", + "status": "Status", + "expireDate": "Expire Date", + "createdAt": "Created At", + "revokedAt": "Revoked At", + "revoke": "Revoke", + "delete": "Delete", + "apiKey": "API Key", + "apiKeyCopy": "Copy API Key", + "apiKeyCopySuccess": "API key copied to clipboard", + "apiKeyShowWarning": "This key will only be shown once. Please copy it now.", + "createSuccess": "API key created successfully", + "createFailed": "Failed to create API key", + "updateSuccess": "API key updated successfully", + "updateFailed": "Failed to update API key", + "revokeSuccess": "API key revoked and reissued successfully", + "revokeFailed": "Failed to revoke API key", + "deleteSuccess": "API key deleted successfully", + "deleteFailed": "Failed to delete API key", + "deletePrompt": "Are you sure you want to delete API key \"{{name}}\"?", + "revokePrompt": "Revoking will invalidate the current key and issue a new one. Are you sure?", + "revokeTitle": "Revoke & Reissue API Key", + "deleteTitle": "Delete API Key", + "inheritPermissions": "Inherit admin permissions", + "inheritPermissionsDescription": "Use the owning admin's current role permissions. Disable to store custom permissions on this key.", + "inherited": "Inherited", + "noKeys": "No API keys configured", + "noKeysDescription": "Create an API key to allow programmatic access.", + "noSearchResults": "No API keys match your search criteria. Try adjusting your search terms or filters.", + "bulkDeleteSuccess": "{{count}} API keys deleted successfully.", + "bulkDeleteFailed": "Failed to delete selected API keys.", + "bulkDeleteTitle": "Delete selected API keys", + "bulkDeletePrompt": "Are you sure you want to delete {{count}} selected API keys? This action cannot be undone." }, "adminRoles": { "title": "Admin Roles", @@ -748,7 +812,7 @@ "limits": "Limits", "limitsAndFeatures": "Limits & Features", "limitsHint": "Leave empty to inherit defaults. Set to 0 to disable.", - "roleFormHint": "Scoped user actions use none, own, or all. Other actions are boolean toggles.", + "roleFormHint": "Scoped actions use none, own, or all. Other actions are boolean toggles.", "scopedActionInfo": "Choose ownership scope: none denies access, own limits to the admin's own users, all grants full access.", "allowAll": "Allow all", "features": "Features", @@ -794,6 +858,7 @@ "users": "Users", "admins": "Admins", "roles": "Roles", + "apiKeys": "API Keys", "nodes": "Nodes", "coreHosts": "Core and hosts", "groupsTemplates": "Groups and templates", @@ -849,6 +914,7 @@ "users": "Users", "admins": "Admins", "admin_roles": "Roles", + "api_keys": "API Keys", "nodes": "Nodes", "cores": "Cores", "hosts": "Hosts", @@ -859,6 +925,7 @@ "system": "System", "hwids": "HWIDs" }, + "noAvailablePermissions": "No permissions available.", "scopedBadge": "Scoped", "scopes": { "none": "None", @@ -1126,7 +1193,9 @@ "header.nodeSettings": "Node Settings", "header.nodesUsage": "Nodes Usage", "loading": "Loading...", + "clear": "Clear", "never": "Never", + "resources": "resources", "noResults": "No results found", "noAdminsFound": "No admins found", "emptyState": { @@ -1375,6 +1444,13 @@ "format": "Matched subscription format (e.g. links, sing_box)", "admin_username": "Admin username" }, + "customVariables": { + "title": "Custom variables", + "trigger": "Custom variables", + "preview": "Preview", + "empty": "No custom variables configured.", + "emptyValue": "Empty value" + }, "advancedOptions": "Advanced Options", "hos.info": "By default, if a request host is set in the XRAY configuration, it will be used. However, if needed, you can set a custom request host here.", "multiHost": "To set multiple addresses, separate them with , A random address will be selected each time.", @@ -1635,6 +1711,7 @@ "dataLimit": "Data Limit", "hwidLimit": "HWID Limit", "hwidLimitPlaceholder": "Empty = default, 0 = unlimited", + "hwidLimitHint": "Empty = use default policy. 0 = unlimited and exempt from HWID, even when the HWID header is forced.", "days": "Days", "editUser": "Modify user", "editUserTitle": "Modify user", @@ -2467,6 +2544,9 @@ }, "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." + }, "tlsCertificates": { "sectionLabel": "Certificates", "emptyHint": "No certificates yet. Click + to add one.", @@ -3319,6 +3399,7 @@ "timeSelector.pickDate": "Pick a date", "advanceSearch": { "title": "Advanced Search", + "description": "Filter API keys by identifier and status.", "searchMode": "Search mode", "searchModeDescription": "Choose how the main search field should be interpreted.", "byUsername": "Username and Notes", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index e091631f5..ecaf63ff5 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -63,6 +63,7 @@ "usageLimitWarning": "هشدار محدودیت مصرف", "modifyHosts": "ویرایش هاست‌ها", "connect": "اتصال", + "recovered": "بازیابی", "error": "خطا", "limited": "محدود شده", "statusChange": "تغییر وضعیت", @@ -145,6 +146,14 @@ "headerValue": "مقدار هدر", "noHeaders": "هیچ هدر پاسخی پیکربندی نشده است. هدرها را اضافه کنید تا هدرهای HTTP سفارشی در پاسخ‌های اشتراک قرار گیرند." }, + "customVariables": { + "title": "متغیرهای سفارشی", + "description": "جای‌نگهدارهای قابل استفاده مجدد برای خروجی اشتراک و هاست بسازید.", + "addVariable": "افزودن متغیر", + "empty": "هیچ متغیر سفارشی پیکربندی نشده است.", + "duplicateKey": "کلید متغیر سفارشی تکراری است.", + "builtinConflict": "این کلید برای یک متغیر داخلی رزرو شده است." + }, "rules": { "title": "قوانین اشتراک", "description": "پیکربندی قوانین اشتراک مخصوص کلاینت و فرمت‌ها", @@ -472,7 +481,9 @@ "saving": "در حال ذخیره...", "general": "عمومی", "loading": "در حال بارگذاری...", + "clear": "پاک کردن", "never": "هرگز", + "resources": "منابع", "noResults": "نتیجه‌ای یافت نشد", "noAdminsFound": "مدیری یافت نشد", "emptyState": { @@ -569,6 +580,14 @@ "subDomain": "دامنه اشتراک", "profile": "عنوان پروفایل", "subTemplate": "مسیر قالب اشتراک", + "customVariables": { + "title": "متغیرهای سفارشی", + "description": "متغیرهای سفارشی سراسری را برای کاربران این ادمین بازنویسی کنید.", + "addVariable": "افزودن متغیر", + "empty": "هیچ متغیر سفارشی پیکربندی نشده است.", + "duplicateKey": "کلید متغیر سفارشی تکراری است.", + "builtinConflict": "این کلید برای یک متغیر داخلی رزرو شده است." + }, "passwordConfirm": "تأیید رمز عبور", "enterPasswordConfirm": "رمز عبور را دوباره وارد کنید", "disableSuccess": "مدیر «{{name}}» با موفقیت غیرفعال شد", @@ -594,7 +613,54 @@ "monitor": { "traffic": "نظارت بر مصرف ترافیک مدیر در طول زمان", "no_traffic": "داده‌ای برای ترافیک موجود نیست" - } + }, + "disabled": "???????" + }, + "apiKeys": { + "title": "کلیدهای API", + "description": "مدیریت کلیدهای API برای دسترسی برنامه‌نویسی", + "createKey": "ایجاد کلید API", + "editKey": "ویرایش کلید API", + "admin": "مدیر", + "selectAdmin": "انتخاب مدیر", + "adminDescription": "این کلید با هویت این مدیر احراز هویت می‌شود.", + "name": "نام", + "key": "کلید API", + "keyId": "شناسه کلید", + "note": "یادداشت", + "role": "نقش", + "status": "وضعیت", + "expireDate": "تاریخ انقضا", + "createdAt": "تاریخ ایجاد", + "revokedAt": "تاریخ ابطال", + "revoke": "ابطال", + "delete": "حذف", + "apiKey": "کلید API", + "apiKeyCopy": "کپی کلید API", + "apiKeyCopySuccess": "کلید API در حافظه کپی شد", + "apiKeyShowWarning": "این کلید فقط یکبار نمایش داده می‌شود. لطفا همین الان آن را کپی کنید.", + "createSuccess": "کلید API با موفقیت ایجاد شد", + "createFailed": "ایجاد کلید API ناموفق بود", + "updateSuccess": "کلید API با موفقیت بروزرسانی شد", + "updateFailed": "بروزرسانی کلید API ناموفق بود", + "revokeSuccess": "کلید API با موفقیت ابطال و مجدداً صادر شد", + "revokeFailed": "ابطال کلید API ناموفق بود", + "deleteSuccess": "کلید API با موفقیت حذف شد", + "deleteFailed": "حذف کلید API ناموفق بود", + "deletePrompt": "آیا از حذف کلید API «{{name}}» اطمینان دارید؟", + "revokePrompt": "ابطال کلید باعث غیرفعال شدن کلید فعلی و صدور کلید جدید می‌شود. آیا مطمئن هستید؟", + "revokeTitle": "ابطال و صدور مجدد کلید API", + "deleteTitle": "حذف کلید API", + "inheritPermissions": "ارث‌بری مجوزهای مدیر", + "inheritPermissionsDescription": "از مجوزهای فعلی نقش مدیر مالک استفاده شود. برای ذخیره مجوزهای سفارشی روی این کلید غیرفعال کنید.", + "inherited": "ارث‌بری‌شده", + "noKeys": "هیچ کلید API تنظیم نشده است", + "noKeysDescription": "برای دسترسی برنامه‌نویسی یک کلید API ایجاد کنید.", + "noSearchResults": "هیچ کلید API با معیارهای جست‌وجوی شما مطابقت ندارد. عبارت جست‌وجو یا فیلترها را تغییر دهید.", + "bulkDeleteSuccess": "{{count}} کلید API با موفقیت حذف شد.", + "bulkDeleteFailed": "حذف کلیدهای API انتخاب‌شده ناموفق بود.", + "bulkDeleteTitle": "حذف کلیدهای API انتخاب‌شده", + "bulkDeletePrompt": "آیا از حذف {{count}} کلید API انتخاب‌شده مطمئن هستید؟ این عملیات قابل بازگشت نیست." }, "adminRoles": { "title": "نقش‌های مدیر", @@ -615,7 +681,7 @@ "limits": "محدودیت‌ها", "limitsAndFeatures": "محدودیت‌ها و قابلیت‌ها", "limitsHint": "برای استفاده از پیش‌فرض، خالی بگذارید. برای غیرفعال‌سازی ۰ بگذارید.", - "roleFormHint": "اعمال کاربر دارای محدوده، مقدار «هیچ»، «خودی» یا «همه» می‌گیرند. سایر اعمال سوئیچ بولی هستند.", + "roleFormHint": "اعمال دارای محدوده، مقدار «هیچ»، «خودی» یا «همه» می‌گیرند. سایر اعمال سوئیچ بولی هستند.", "scopedActionInfo": "محدوده مالکیت را انتخاب کنید: «هیچ» دسترسی را رد می‌کند، «خودی» فقط کاربران خود مدیر را شامل می‌شود و «همه» دسترسی کامل می‌دهد.", "allowAll": "اجازه همه", "features": "قابلیت‌ها", @@ -661,6 +727,7 @@ "users": "کاربران", "admins": "مدیران", "roles": "نقش‌ها", + "apiKeys": "کلیدهای API", "nodes": "گره‌ها", "coreHosts": "هسته و هاست‌ها", "groupsTemplates": "گروه‌ها و قالب‌ها", @@ -716,6 +783,7 @@ "users": "کاربران", "admins": "مدیران", "admin_roles": "نقش‌ها", + "api_keys": "کلیدهای API", "nodes": "گره‌ها", "cores": "هسته‌ها", "hosts": "هاست‌ها", @@ -726,6 +794,7 @@ "system": "سیستم", "hwids": "HWID" }, + "noAvailablePermissions": "هیچ مجوزی در دسترس نیست.", "scopedBadge": "محدوده‌دار", "scopes": { "none": "هیچ", @@ -742,8 +811,7 @@ "max_hwid_per_user": "حداکثر HWID برای هر کاربر", "on_hold_timeout_days_min": "حداقل زمان انتظار (روز)", "on_hold_timeout_days_max": "حداکثر زمان انتظار (روز)" - }, - + }, "limitedBehavior": { "disabledWhenLimited": { "title": "مسدود کردن مدیران محدودشده", @@ -841,6 +909,7 @@ "dataLimit": "محدودیت داده", "hwidLimit": "محدودیت شناسه سخت‌افزار", "hwidLimitPlaceholder": "خالی = پیش‌فرض، ۰ = نامحدود", + "hwidLimitHint": "خالی = استفاده از سیاست پیش‌فرض. ۰ = نامحدود و معاف از شناسه سخت‌افزار، حتی زمانی که هدر شناسه سخت‌افزار الزامی شده باشد.", "expire": "مدت انقضا", "onHoldTimeout": "زمان انتظار توقف", "method": "روش", @@ -1226,6 +1295,13 @@ "format": "فرمت اشتراک منطبق‌شده (مثلاً links یا sing_box)", "admin_username": "نام کاربری ادمین" }, + "customVariables": { + "title": "متغیرهای سفارشی", + "trigger": "متغیرهای سفارشی", + "preview": "پیش‌نمایش", + "empty": "هیچ متغیر سفارشی پیکربندی نشده است.", + "emptyValue": "مقدار خالی" + }, "advancedOptions": "تنظیمات پیشرفته", "host.info": "به طور پیش‌فرض، اگر هاست درخواست در پیکربندی XRAY تنظیم شده باشد، از آن استفاده می‌شود. با این حال، در صورت نیاز، می‌توانید یک هاست درخواست سفارشی در اینجا تنظیم کنید.", "host.multiHost": "برای تنظیم چند آدرس، آنها را با , جدا کنید. هر بار یک آدرس به صورت تصادفی انتخاب می‌شود.", @@ -2381,6 +2457,9 @@ }, "tlsCertificateServeOnNode": "سرو روی نود", "tlsCertificateServeOnNodeHint": "فایل‌های گواهی فقط روی نود استفاده می‌شوند؛ سرور آن‌ها را برای SNI نمی‌خواند.", + "tls": { + "allowInsecureRemoved": "Xray-core این نود دیگر پیکربندی‌هایی با allowInsecure فعال نمی‌سازد — اجرا نخواهد شد." + }, "tlsCertificates": { "sectionLabel": "گواهی‌ها", "emptyHint": "هنوز گواهی‌ای نیست. برای افزودن روی + کلیک کنید.", @@ -3293,6 +3372,7 @@ "timeSelector.pickDate": "انتخاب تاریخ", "advanceSearch": { "title": "جستجوی پیشرفته", + "description": "فیلتر کردن کلیدهای API بر اساس شناسه و وضعیت.", "searchMode": "حالت جست‌وجو", "searchModeDescription": "مشخص کنید فیلد جست‌وجوی اصلی چگونه تفسیر شود.", "byUsername": "نام کاربری و یادداشت‌ها", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 58163aa7e..93a257263 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -195,6 +195,7 @@ "usageLimitWarning": "Предупреждение о лимите", "modifyHosts": "Изменить хосты", "connect": "Подключить", + "recovered": "Восстановлен", "error": "Ошибка", "limited": "Ограничен", "statusChange": "Изменение статуса", @@ -277,6 +278,14 @@ "headerValue": "Значение заголовка", "noHeaders": "Заголовки ответа не настроены. Добавьте заголовки для включения пользовательских HTTP-заголовков в ответы подписки." }, + "customVariables": { + "title": "Пользовательские переменные", + "description": "Создавайте переиспользуемые заполнители для вывода подписки и хоста.", + "addVariable": "Добавить переменную", + "empty": "Пользовательские переменные не настроены.", + "duplicateKey": "Ключ пользовательской переменной уже используется.", + "builtinConflict": "Этот ключ зарезервирован для встроенной переменной." + }, "rules": { "title": "Правила подписки", "description": "Настройка правил подписки для конкретных клиентов и форматов", @@ -688,6 +697,14 @@ "subDomain": "Домен подписки", "profile": "Заголовок профиля", "subTemplate": "Путь шаблона подписки", + "customVariables": { + "title": "Пользовательские переменные", + "description": "Переопределите глобальные пользовательские переменные для пользователей этого администратора.", + "addVariable": "Добавить переменную", + "empty": "Пользовательские переменные не настроены.", + "duplicateKey": "Ключ пользовательской переменной уже используется.", + "builtinConflict": "Этот ключ зарезервирован для встроенной переменной." + }, "passwordConfirm": "Подтвердите пароль", "enterPasswordConfirm": "Введите подтверждение пароля", "disableSuccess": "Администратор «{{name}}» был успешно отключен", @@ -713,7 +730,54 @@ "monitor": { "traffic": "Мониторинг использования трафика администратором во времени", "no_traffic": "Данные о трафике отсутствуют" - } + }, + "disabled": "????????" + }, + "apiKeys": { + "title": "API ключи", + "description": "Управление API ключами для программного доступа", + "createKey": "Создать API ключ", + "editKey": "Изменить API ключ", + "admin": "Админ", + "selectAdmin": "Выберите админа", + "adminDescription": "Ключ будет выполнять аутентификацию от имени этого админа.", + "name": "Название", + "key": "API ключ", + "keyId": "ID ключа", + "note": "Заметка", + "role": "Роль", + "status": "Статус", + "expireDate": "Дата истечения", + "createdAt": "Дата создания", + "revokedAt": "Дата отзыва", + "revoke": "Отозвать", + "delete": "Удалить", + "apiKey": "API ключ", + "apiKeyCopy": "Копировать API ключ", + "apiKeyCopySuccess": "API ключ скопирован в буфер обмена", + "apiKeyShowWarning": "Этот ключ будет показан только один раз. Пожалуйста, скопируйте его сейчас.", + "createSuccess": "API ключ успешно создан", + "createFailed": "Не удалось создать API ключ", + "updateSuccess": "API ключ успешно обновлен", + "updateFailed": "Не удалось обновить API ключ", + "revokeSuccess": "API ключ успешно отозван и перевыпущен", + "revokeFailed": "Не удалось отозвать API ключ", + "deleteSuccess": "API ключ успешно удален", + "deleteFailed": "Не удалось удалить API ключ", + "deletePrompt": "Вы уверены, что хотите удалить API ключ «{{name}}»?", + "revokePrompt": "Отзыв сделает текущий ключ недействительным и выпустит новый. Вы уверены?", + "revokeTitle": "Отозвать и перевыпустить API ключ", + "deleteTitle": "Удалить API ключ", + "inheritPermissions": "Наследовать права админа", + "inheritPermissionsDescription": "Использовать текущие права роли владельца ключа. Отключите, чтобы сохранить на этом ключе собственные права.", + "inherited": "Наследуется", + "noKeys": "API ключи не настроены", + "noKeysDescription": "Создайте API ключ для программного доступа.", + "noSearchResults": "Нет API ключей, соответствующих условиям поиска. Измените поисковый запрос или фильтры.", + "bulkDeleteSuccess": "{{count}} API ключей успешно удалено.", + "bulkDeleteFailed": "Не удалось удалить выбранные API ключи.", + "bulkDeleteTitle": "Удалить выбранные API ключи", + "bulkDeletePrompt": "Вы уверены, что хотите удалить выбранные API ключи ({{count}})? Это действие нельзя отменить." }, "adminRoles": { "title": "Роли администраторов", @@ -780,6 +844,7 @@ "users": "Пользователи", "admins": "Админы", "roles": "Роли", + "apiKeys": "API Ключи", "nodes": "Узлы", "coreHosts": "Ядра и хосты", "groupsTemplates": "Группы и шаблоны", @@ -835,6 +900,7 @@ "users": "Пользователи", "admins": "Админы", "admin_roles": "Роли", + "api_keys": "API ключи", "nodes": "Узлы", "cores": "Ядра", "hosts": "Хосты", @@ -845,6 +911,7 @@ "system": "Система", "hwids": "HWID" }, + "noAvailablePermissions": "Нет доступных разрешений.", "scopedBadge": "С областью", "scopes": { "none": "Нет", @@ -961,6 +1028,7 @@ "dataLimit": "Лимит данных", "hwidLimit": "Лимит HWID", "hwidLimitPlaceholder": "Пусто = по умолчанию, 0 = без лимита", + "hwidLimitHint": "Пусто = использовать политику по умолчанию. 0 = без лимита и без проверки HWID, даже когда заголовок HWID обязателен.", "expire": "Срок действия", "onHoldTimeout": "Тайм-аут при ожидании", "method": "Метод", @@ -1587,6 +1655,13 @@ "format": "Совпавший формат подписки (например, links, sing_box)", "admin_username": "Имя пользователя администратора" }, + "customVariables": { + "title": "Пользовательские переменные", + "trigger": "Пользовательские переменные", + "preview": "Предпросмотр", + "empty": "Пользовательские переменные не настроены.", + "emptyValue": "Пустое значение" + }, "httpVersions": { "1.0": "HTTP/1.0", "1.1": "HTTP/1.1", @@ -2355,6 +2430,9 @@ }, "tlsCertificateServeOnNode": "Использовать на ноде", "tlsCertificateServeOnNodeHint": "Файлы сертификата используются только на ноде; сервер не читает их для SNI.", + "tls": { + "allowInsecureRemoved": "Xray-core этой ноды больше не собирает конфиги с включённым allowInsecure — запуск будет отклонён." + }, "tlsCertificates": { "sectionLabel": "Сертификаты", "emptyHint": "Сертификатов пока нет. Нажмите +, чтобы добавить.", @@ -3259,6 +3337,7 @@ "timeSelector.pickDate": "Выбрать дату", "advanceSearch": { "title": "Расширенный поиск", + "description": "Фильтрация API ключей по идентификатору и статусу.", "searchMode": "Режим поиска", "searchModeDescription": "Определяет, как интерпретируется основное поле поиска.", "byUsername": "Имя пользователя и заметки", @@ -3312,7 +3391,9 @@ "clearAllFilters": "Очистить все фильтры", "activeFilters": "активных", "loading": "Загрузка...", + "clear": "Очистить", "never": "Никогда", + "resources": "ресурсы", "all": "Все", "hosts.filters.userStatus": "Правила статуса пользователя", "hosts.filters.userStatusDescription": "Фильтрует хосты по заданным статусам пользователей (активен, на паузе, ограничен и т.д.).", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 1cf79dd72..e0ba3376f 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -164,6 +164,7 @@ "usageLimitWarning": "用量限制预警", "modifyHosts": "修改主机", "connect": "连接", + "recovered": "已恢复", "error": "错误", "limited": "受限", "statusChange": "状态变更", @@ -243,6 +244,14 @@ "headerValue": "头值", "noHeaders": "未配置响应头。添加头以在订阅响应中包含自定义 HTTP 头。" }, + "customVariables": { + "title": "自定义变量", + "description": "为订阅和主机输出创建可复用的占位符。", + "addVariable": "添加变量", + "empty": "未配置自定义变量。", + "duplicateKey": "自定义变量键重复。", + "builtinConflict": "此键已保留给内置变量。" + }, "rules": { "title": "订阅规则", "description": "为特定客户端模式定义自定义规则", @@ -702,6 +711,14 @@ "subDomain": "订阅域名", "profile": "个人资料标题", "subTemplate": "订阅模板路径", + "customVariables": { + "title": "自定义变量", + "description": "为此管理员名下的用户覆盖全局自定义变量。", + "addVariable": "添加变量", + "empty": "未配置自定义变量。", + "duplicateKey": "自定义变量键重复。", + "builtinConflict": "此键已保留给内置变量。" + }, "passwordConfirm": "确认密码", "enterPasswordConfirm": "请输入确认密码", "disableSuccess": "管理员「{{name}}」已成功禁用", @@ -727,7 +744,54 @@ "monitor": { "traffic": "监控管理员使用情况", "no_traffic": "没有使用情况数据" - } + }, + "disabled": "??" + }, + "apiKeys": { + "title": "API 密钥", + "description": "管理用于编程访问的 API 密钥", + "createKey": "创建 API 密钥", + "editKey": "修改 API 密钥", + "admin": "管理员", + "selectAdmin": "选择管理员", + "adminDescription": "此密钥将以该管理员身份进行认证。", + "name": "名称", + "key": "API 密钥", + "keyId": "密钥 ID", + "note": "备注", + "role": "角色", + "status": "状态", + "expireDate": "过期日期", + "createdAt": "创建于", + "revokedAt": "吊销于", + "revoke": "吊销", + "delete": "删除", + "apiKey": "API 密钥", + "apiKeyCopy": "复制 API 密钥", + "apiKeyCopySuccess": "API 密钥已复制到剪贴板", + "apiKeyShowWarning": "此密钥仅显示一次。请立即复制。", + "createSuccess": "API 密钥创建成功", + "createFailed": "创建 API 密钥失败", + "updateSuccess": "API 密钥更新成功", + "updateFailed": "更新 API 密钥失败", + "revokeSuccess": "API 密钥已成功吊销并重新签发", + "revokeFailed": "吊销 API 密钥失败", + "deleteSuccess": "API 密钥删除成功", + "deleteFailed": "删除 API 密钥失败", + "deletePrompt": "您确定要删除 API 密钥「{{name}}」吗?", + "revokePrompt": "吊销将使当前密钥失效并签发新密钥。您确定吗?", + "revokeTitle": "吊销并重新签发 API 密钥", + "deleteTitle": "删除 API 密钥", + "inheritPermissions": "继承管理员权限", + "inheritPermissionsDescription": "使用拥有者管理员当前角色的权限。关闭后将在此密钥上保存自定义权限。", + "inherited": "已继承", + "noKeys": "未配置 API 密钥", + "noKeysDescription": "创建 API 密钥以允许程序化访问。", + "noSearchResults": "没有 API 密钥符合搜索条件。请调整搜索词或筛选器。", + "bulkDeleteSuccess": "已成功删除 {{count}} 个 API 密钥。", + "bulkDeleteFailed": "删除所选 API 密钥失败。", + "bulkDeleteTitle": "删除所选 API 密钥", + "bulkDeletePrompt": "确定要删除选中的 {{count}} 个 API 密钥吗?此操作无法撤销。" }, "adminRoles": { "title": "管理员角色", @@ -794,6 +858,7 @@ "users": "用户", "admins": "管理员", "roles": "角色", + "apiKeys": "API 密钥", "nodes": "节点", "coreHosts": "核心和主机", "groupsTemplates": "组和模板", @@ -849,6 +914,7 @@ "users": "用户", "admins": "管理员", "admin_roles": "角色", + "api_keys": "API 密钥", "nodes": "节点", "cores": "核心", "hosts": "主机", @@ -859,6 +925,7 @@ "system": "系统", "hwids": "HWID" }, + "noAvailablePermissions": "没有可用权限。", "scopedBadge": "带范围", "scopes": { "none": "无", @@ -975,6 +1042,7 @@ "dataLimit": "数据限制", "hwidLimit": "HWID 限制", "hwidLimitPlaceholder": "留空 = 默认,0 = 无限制", + "hwidLimitHint": "留空 = 使用默认策略。0 = 无限制并免除 HWID 校验,即使已强制要求 HWID 请求头。", "expire": "过期时间", "onHoldTimeout": "挂起超时", "method": "方法", @@ -1349,6 +1417,13 @@ "format": "匹配到的订阅格式(例如:links、sing_box)", "admin_username": "管理员用户名" }, + "customVariables": { + "title": "自定义变量", + "trigger": "自定义变量", + "preview": "预览", + "empty": "未配置自定义变量。", + "emptyValue": "空值" + }, "advancedOptions": "高级选项", "hos.info": "默认情况下,如果请求主机在 XRAY 配置中设置,则使用该主机。但是,如果需要,您可以在此处设置自定义请求主机。", "multiHost": "要设置多个地址,请使用 , 作为分隔符。每次随机选择一个地址。", @@ -2426,6 +2501,9 @@ }, "tlsCertificateServeOnNode": "在节点提供服务", "tlsCertificateServeOnNodeHint": "证书文件仅在节点上使用;服务器不会为 SNI 读取这些文件。", + "tls": { + "allowInsecureRemoved": "该节点的 Xray-core 已不再构建启用 allowInsecure 的配置——它将拒绝启动。" + }, "tlsCertificates": { "sectionLabel": "证书", "emptyHint": "暂无证书。点击 + 添加。", @@ -3103,7 +3181,9 @@ }, "online": "在线", "loading": "加载中...", + "clear": "清除", "never": "从未", + "resources": "资源", "noResults": "未找到结果", "noAdminsFound": "未找到管理员", "emptyState": { @@ -3316,6 +3396,7 @@ "timeSelector.pickDate": "选择日期", "advanceSearch": { "title": "高级搜索", + "description": "按标识符和状态筛选 API 密钥。", "searchMode": "搜索模式", "searchModeDescription": "选择如何解释主搜索框中的内容。", "byUsername": "用户名和备注", 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..cef1cab74 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,7 @@ 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 useDirDetection from '@/hooks/use-dir-detection' import { cn } from '@/lib/utils' import { @@ -159,6 +160,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 +174,10 @@ const XHTTP_EXTRA_META_KEYS = new Set([ 'uplinkhttpmethod', 'sessionplacement', 'sessionkey', + 'sessionidplacement', + 'sessionidkey', + 'sessionidtable', + 'sessionidlength', 'seqplacement', 'seqkey', 'uplinkdataplacement', @@ -893,6 +901,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 +920,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 +942,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 +1413,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 +1450,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 +1496,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 +2397,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 +3291,134 @@ 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 + 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' })} + /> + + +
+
+ ) + })()} + {transportSettingsFieldOrder.map((jsonKey: string) => { const def = caps.transportSettingsFieldDefinitions[inboundTransportType]?.[jsonKey] if (!def) return null @@ -3269,6 +3436,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 +4042,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..d272014cf 100644 --- a/dashboard/src/features/core-editor/kit/xray-adapter.ts +++ b/dashboard/src/features/core-editor/kit/xray-adapter.ts @@ -375,8 +375,8 @@ function applyHysteriaTransportUdpmasksToCompiledConfig(profile: Profile, config /** * Issues from {@link buildXrayConfig} in strict mode when the profile does not compile (schema / semantic / unsafe patches, …). */ -export function getXrayStrictCompileBlockers(profile: Profile): Issue[] { - const { config, issues } = buildXrayConfig(prepareProfileForKit(profile), { mode: 'strict' }) +export function getXrayStrictCompileBlockers(profile: Profile, xrayVersion?: string | null): Issue[] { + const { config, issues } = buildXrayConfig(prepareProfileForKit(profile), { mode: 'strict', xrayVersion: xrayVersion ?? undefined }) if (!isEmptyCompiledConfig(config)) return [] const errors = issues.filter(i => i.severity === 'error') return errors.length > 0 ? errors : issues @@ -401,9 +401,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 +414,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/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/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', + }, +}) From f69403f87dad6a176baa6192bc2efdab93075a7f Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Fri, 3 Jul 2026 18:18:23 +0700 Subject: [PATCH 05/13] feat(core-editor): unify xhttp sessionID room-size validation --- dashboard/public/statics/locales/en.json | 4 ++ dashboard/public/statics/locales/fa.json | 4 ++ dashboard/public/statics/locales/ru.json | 4 ++ dashboard/public/statics/locales/zh.json | 4 ++ .../components/xray/xray-inbounds-section.tsx | 18 ++++++- .../features/core-editor/kit/xray-adapter.ts | 41 ++++++++++++-- .../src/features/hosts/forms/host-form.ts | 21 ++++++++ .../src/lib/xray-session-id-room-size.test.ts | 44 +++++++++++++++ .../src/lib/xray-session-id-room-size.ts | 53 +++++++++++++++++++ 9 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 dashboard/src/lib/xray-session-id-room-size.test.ts create mode 100644 dashboard/src/lib/xray-session-id-room-size.ts diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index d4fd9e6c9..dc573d354 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2547,6 +2547,10 @@ "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 ecaf63ff5..5772a0cf6 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -2460,6 +2460,10 @@ "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 93a257263..db36efc78 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -2433,6 +2433,10 @@ "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 e0ba3376f..213b412b1 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -2504,6 +2504,10 @@ "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-inbounds-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx index cef1cab74..54289a076 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 @@ -29,6 +29,7 @@ import { isPlaceholderTunnelRewriteAddress, normalizeTunnelNetworkForKit } from 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 { @@ -3354,6 +3355,9 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo 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 = + sessionIdTableValue && sessionIdLengthValue ? checkSessionIdRoomSize(sessionIdTableValue, sessionIdLengthValue) : null return (
@@ -3408,13 +3412,25 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo 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.', + })} +

+ )}
) })()} diff --git a/dashboard/src/features/core-editor/kit/xray-adapter.ts b/dashboard/src/features/core-editor/kit/xray-adapter.ts index d272014cf..354d4b079 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. + */ +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' || typeof length !== 'string' || !table || !length) return + const problem = checkSessionIdRoomSize(table, 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 [] + 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[] } diff --git a/dashboard/src/features/hosts/forms/host-form.ts b/dashboard/src/features/hosts/forms/host-form.ts index 5ab970db4..77dc56051 100644 --- a/dashboard/src/features/hosts/forms/host-form.ts +++ b/dashboard/src/features/hosts/forms/host-form.ts @@ -1,4 +1,5 @@ import * as z from 'zod' +import { checkSessionIdRoomSize } from '@/lib/xray-session-id-room-size' interface Brutal { enable?: boolean @@ -110,6 +111,8 @@ export interface HostFormValues { uplink_http_method?: string session_placement?: string session_key?: string + session_id_table?: string + session_id_length?: string seq_placement?: string seq_key?: string uplink_data_placement?: string @@ -178,6 +181,8 @@ const transportSettingsSchema = z uplink_http_method: z.string().nullish().optional(), session_placement: z.string().nullish().optional(), session_key: z.string().nullish().optional(), + session_id_table: z.string().nullish().optional(), + session_id_length: z.string().nullish().optional(), seq_placement: z.string().nullish().optional(), seq_key: z.string().nullish().optional(), uplink_data_placement: z.string().nullish().optional(), @@ -198,6 +203,22 @@ const transportSettingsSchema = z .nullish() .optional(), }) + .superRefine((data, ctx) => { + const table = data?.session_id_table + const length = data?.session_id_length + // Only validate when a custom session ID table is configured. + if (!table || !length) 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/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..54af95962 --- /dev/null +++ b/dashboard/src/lib/xray-session-id-room-size.test.ts @@ -0,0 +1,44 @@ +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('returns null for an unparseable length (format is checked elsewhere)', () => { + expect(checkSessionIdRoomSize('Base62', 'not-a-length')).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..02a3f4016 --- /dev/null +++ b/dashboard/src/lib/xray-session-id-room-size.ts @@ -0,0 +1,53 @@ +// 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). + * Returns null when `length` doesn't parse (format is checked elsewhere) or the combination is safe. + */ +export function checkSessionIdRoomSize(table: string, length: string): SessionIdRoomSizeProblem | null { + const range = parseLengthRange(length) + if (!range) return null + 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 +} From d6851f49f3bb05f77dcd620693f5dafffbfdb673 Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Fri, 3 Jul 2026 18:18:23 +0700 Subject: [PATCH 06/13] chore: clean up misleading comments --- .../src/features/hosts/dialogs/host-modal.tsx | 111 ++++++++++-------- .../src/features/hosts/forms/host-form.ts | 2 +- tests/api/test_node.py | 2 +- 3 files changed, 62 insertions(+), 53 deletions(-) diff --git a/dashboard/src/features/hosts/dialogs/host-modal.tsx b/dashboard/src/features/hosts/dialogs/host-modal.tsx index f827babb2..9a96ebfb5 100644 --- a/dashboard/src/features/hosts/dialogs/host-modal.tsx +++ b/dashboard/src/features/hosts/dialogs/host-modal.tsx @@ -10,7 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { VariablesList, VariablesPopover } from '@/components/ui/variables-popover' +import { CustomVariablesPopover, VariablesList, VariablesPopover } from '@/components/ui/variables-popover' import useDirDetection from '@/hooks/use-dir-detection' import { useIsMobile } from '@/hooks/use-mobile' import { cn } from '@/lib/utils' @@ -27,7 +27,6 @@ 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'] -// Select with predefined tables + a "Custom" option that reveals a free-text input. function SessionIdTableField({ control, t }: { control: any; t: (key: string, opts?: any) => string }) { const [customMode, setCustomMode] = useState(false) return ( @@ -257,9 +256,10 @@ interface ArrayInputProps { placeholder: string label: string infoContent?: React.ReactNode + customVariablesTrigger?: React.ReactNode } -const ArrayInput = memo(({ field, placeholder, label, infoContent }) => { +const ArrayInput = memo(({ field, placeholder, label, infoContent, customVariablesTrigger }) => { const { t } = useTranslation() const dir = useDirDetection() const isMobile = useIsMobile() @@ -283,6 +283,7 @@ const ArrayInput = memo(({ field, placeholder, label, infoConte )} + {customVariablesTrigger}
= ({ isDialogOpen, onOpenChange, onSub
{t('remark')} +
@@ -1066,7 +1068,7 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub render={({ field }) => { const infoContent = - return + return } /> }} /> @@ -1244,7 +1246,7 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub ) - return + return } /> }} /> = ({ isDialogOpen, onOpenChange, onSub

{t('hostsDialog.path.info')}

+ @@ -2524,29 +2527,32 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub

{t('hostsDialog.tcp.requestHeaders')}

- +
+ + +
{/* Render request headers */} @@ -2746,29 +2752,32 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub

{t('hostsDialog.tcp.responseHeaders')}

- +
+ + +
{/* Render response headers */} diff --git a/dashboard/src/features/hosts/forms/host-form.ts b/dashboard/src/features/hosts/forms/host-form.ts index 77dc56051..c31023f2e 100644 --- a/dashboard/src/features/hosts/forms/host-form.ts +++ b/dashboard/src/features/hosts/forms/host-form.ts @@ -206,7 +206,7 @@ const transportSettingsSchema = z .superRefine((data, ctx) => { const table = data?.session_id_table const length = data?.session_id_length - // Only validate when a custom session ID table is configured. + // Nothing to check until both are set — either one alone means Xray's default applies. if (!table || !length) return const problem = checkSessionIdRoomSize(table, length) if (!problem) return diff --git a/tests/api/test_node.py b/tests/api/test_node.py index 755e1a549..965edb0d4 100644 --- a/tests/api/test_node.py +++ b/tests/api/test_node.py @@ -694,7 +694,7 @@ async def test_get_xray_version_by_core_id_ignores_status(): async with TestSession() as session: db_node = await session.get(Node, node_id) - # Node errors out but keeps its last-known version (Task 1's behavior). + # 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: From c95f4325abb21eb7aebfda161f83f11d488cf7fe Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Fri, 3 Jul 2026 22:23:32 +0700 Subject: [PATCH 07/13] fix(node): stop force-migrating stored core config, warn on pre-release core versions - Remove migrate_core_xhttp_session_keys_if_sole_node() and its call site in update_node_status(): the panel was silently rewriting an admin's stored Xray core JSON (sessionPlacement/sessionKey -> sessionIDPlacement/ sessionIDKey) as soon as any node reported Xray >= 26.6.22, with no confirmation. This is redundant on top of the per-push rename already done in NodeOperation.connect_node (which translates a deep copy at the moment config is sent to a node, based on that node's own version) and the dual-name read fallback in XRayConfig/subscription builders, so removing it costs no functionality while no longer touching admin data behind their back. - update-core-modal.tsx: warn when the selected/entered core version is a pre-release or numerically above the latest stable release, separate from the existing sessionID breaking-change warning. --- app/db/crud/core.py | 47 --------------- app/db/crud/node.py | 6 -- dashboard/public/statics/locales/en.json | 4 +- dashboard/public/statics/locales/fa.json | 4 +- dashboard/public/statics/locales/ru.json | 4 +- dashboard/public/statics/locales/zh.json | 4 +- .../nodes/dialogs/update-core-modal.tsx | 31 ++++++++++ tests/api/test_node.py | 58 ++++--------------- 8 files changed, 53 insertions(+), 105 deletions(-) diff --git a/app/db/crud/core.py b/app/db/crud/core.py index f638c7819..20e2c6186 100644 --- a/app/db/crud/core.py +++ b/app/db/crud/core.py @@ -21,53 +21,6 @@ def _build_core_simple_sort_clause(sort_option: CoreSimpleSortOption): return column.desc() if sort_option.value.startswith("-") else column.asc() -def _migrate_xhttp_session_keys(config: dict) -> tuple[dict, bool]: - """Renames legacy xhttp `sessionPlacement`/`sessionKey` fields to `sessionIDPlacement`/ - `sessionIDKey` in every inbound. Returns (possibly-new config, whether anything changed).""" - inbounds = config.get("inbounds") - if not isinstance(inbounds, list): - return config, False - changed = False - new_inbounds = [] - for inbound in inbounds: - stream = inbound.get("streamSettings") if isinstance(inbound, dict) else None - xhttp = stream.get("xhttpSettings") if isinstance(stream, dict) else None - if not isinstance(xhttp, dict): - new_inbounds.append(inbound) - continue - new_xhttp = dict(xhttp) - inbound_changed = False - for old_key, new_key in (("sessionPlacement", "sessionIDPlacement"), ("sessionKey", "sessionIDKey")): - if old_key in new_xhttp and new_key not in new_xhttp: - new_xhttp[new_key] = new_xhttp.pop(old_key) - inbound_changed = True - if not inbound_changed: - new_inbounds.append(inbound) - continue - changed = True - new_inbounds.append({**inbound, "streamSettings": {**stream, "xhttpSettings": new_xhttp}}) - if not changed: - return config, False - return {**config, "inbounds": new_inbounds}, True - - -async def migrate_core_xhttp_session_keys_if_sole_node(db: AsyncSession, core_config_id: int) -> None: - """Called when a node just reported an Xray-core version that requires the sessionID* - rename. Only rewrites the stored core config when this is the only node using it — - a sibling node still on an older Xray-core would otherwise silently break.""" - node_count = (await db.execute(select(func.count()).select_from(Node).where(Node.core_config_id == core_config_id))).scalar_one() - if node_count != 1: - return - db_core_config = await get_core_config_by_id(db, core_config_id) - if db_core_config is None: - return - new_config, changed = _migrate_xhttp_session_keys(db_core_config.config) - if not changed: - return - db_core_config.config = new_config - await db.commit() - - async def get_core_config_by_id(db: AsyncSession, core_id: int) -> CoreConfig | None: """ Retrieves a core configuration by its ID. diff --git a/app/db/crud/node.py b/app/db/crud/node.py index 79527f2cf..a523cae0a 100644 --- a/app/db/crud/node.py +++ b/app/db/crud/node.py @@ -7,7 +7,6 @@ from sqlalchemy.sql.functions import coalesce from app.db.compiles_types import DateDiff -from app.db.crud.core import migrate_core_xhttp_session_keys_if_sole_node from app.db.models import ( DataLimitResetStrategy, Node, @@ -27,7 +26,6 @@ UsageTable, ) from app.models.stats import NodeStats, NodeStatsList, NodeUsageStat, NodeUsageStatsList, Period -from app.subscription.base import is_new_xray from .general import ( MYSQL_FORMATS, @@ -504,7 +502,6 @@ async def update_node_status( Returns: Node: The updated Node object. """ - old_xray_version = db_node.xray_version values: dict = { "status": status, "message": message, @@ -519,9 +516,6 @@ async def update_node_status( await db.execute(stmt) await db.commit() - if xray_version and is_new_xray(xray_version) and not is_new_xray(old_xray_version): - await migrate_core_xhttp_session_keys_if_sole_node(db, db_node.core_config_id) - try: # Prefer refreshing the existing instance to keep relationships loaded await db.refresh(db_node) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index dc573d354..c6185a2e6 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2068,7 +2068,9 @@ "loadingReleases": "Loading releases...", "breakingChangeTitle": "Breaking changes in Xray 26.6.22+", "breakingChangeWarning": "Starting with Xray 26.6.22, the xhttp parameters \"sessionPlacement\" and \"sessionKey\" are renamed to \"sessionIDPlacement\" and \"sessionIDKey\". Server configs are migrated automatically when the new version is installed. However, users whose xhttp inbound subscriptions still contain sessionPlacement/sessionKey will not be able to connect to the server after the upgrade until they re-import their subscription. Continue?", - "breakingChangeConfirm": "I understand, update anyway" + "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." }, "theme": { "title": "Theme", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 5772a0cf6..c95c7ff30 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -1880,7 +1880,9 @@ "loadingReleases": "در حال بارگذاری نسخه‌ها...", "breakingChangeTitle": "تغییرات ناسازگار در Xray 26.6.22+", "breakingChangeWarning": "از نسخه Xray 26.6.22 به بعد، پارامترهای xhttp یعنی «sessionPlacement» و «sessionKey» به «sessionIDPlacement» و «sessionIDKey» تغییر نام می‌یابند. پیکربندی‌های سرور هنگام نصب نسخه جدید به‌صورت خودکار مهاجرت می‌کنند. اما کاربرانی که در اشتراک‌های اینباند xhttp آن‌ها هنوز sessionPlacement/sessionKey وجود دارد، پس از به‌روزرسانی تا زمانی که اشتراک خود را دوباره وارد نکنند نمی‌توانند به سرور متصل شوند. ادامه می‌دهید؟", - "breakingChangeConfirm": "متوجه شدم، در هر صورت به‌روزرسانی شود" + "breakingChangeConfirm": "متوجه شدم، در هر صورت به‌روزرسانی شود", + "prereleaseWarningTitle": "نسخه پیش‌انتشار انتخاب شد", + "prereleaseWarningMessage": "این نسخه هنوز به یک نسخه پایدار ارتقا نیافته است. برخی پارامترها ممکن است پس از رسیدن به نسخه پایدار تغییر کنند یا رفتار متفاوتی داشته باشند — تا زمانی که ریسک آن را درک نکرده‌اید، از آن روی نودهای تولیدی استفاده نکنید." }, "nodes": { "title": "گره‌ها", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index db36efc78..d8597876e 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1956,7 +1956,9 @@ "loadingReleases": "Загрузка релизов...", "breakingChangeTitle": "Ломающие изменения в Xray 26.6.22+", "breakingChangeWarning": "Начиная с Xray 26.6.22, параметры xhttp «sessionPlacement» и «sessionKey» переименованы в «sessionIDPlacement» и «sessionIDKey». Серверные конфиги мигрируют автоматически при установке новой версии. Однако пользователи, у которых в подписках с xhttp-инбаундами остались sessionPlacement/sessionKey, не смогут подключиться к серверу после обновления, пока не переимпортируют подписку. Продолжить?", - "breakingChangeConfirm": "Понимаю, всё равно обновить" + "breakingChangeConfirm": "Понимаю, всё равно обновить", + "prereleaseWarningTitle": "Выбрана pre-release версия", + "prereleaseWarningMessage": "Эта версия ещё не стала стабильным релизом. Некоторые параметры могут измениться или начать работать иначе, когда она станет стабильной — не используйте её на боевых нодах, если не понимаете риски." }, "theme": { "title": "Тема", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 213b412b1..dd9d8bc3b 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -2027,7 +2027,9 @@ "loadingReleases": "正在加载版本...", "breakingChangeTitle": "Xray 26.6.22+ 的重大变更", "breakingChangeWarning": "从 Xray 26.6.22 开始,xhttp 参数 “sessionPlacement” 和 “sessionKey” 已重命名为 “sessionIDPlacement” 和 “sessionIDKey”。安装新版本时,服务器配置会自动迁移。但是,订阅中 xhttp 入站仍包含 sessionPlacement/sessionKey 的用户在升级后将无法连接到服务器,直到他们重新导入订阅。是否继续?", - "breakingChangeConfirm": "我已了解,仍然更新" + "breakingChangeConfirm": "我已了解,仍然更新", + "prereleaseWarningTitle": "已选择预发布版本", + "prereleaseWarningMessage": "该版本尚未成为稳定发行版。待其成为稳定版本时,部分参数可能会更改或表现不同——除非您了解其中风险,否则不要在生产节点上使用。" }, "theme": { "title": "主题", diff --git a/dashboard/src/features/nodes/dialogs/update-core-modal.tsx b/dashboard/src/features/nodes/dialogs/update-core-modal.tsx index aaad80696..70c843987 100644 --- a/dashboard/src/features/nodes/dialogs/update-core-modal.tsx +++ b/dashboard/src/features/nodes/dialogs/update-core-modal.tsx @@ -39,6 +39,18 @@ function isBreakingUpgrade(current: string | null | undefined, target: string): 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 @@ -61,6 +73,9 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC // 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) { @@ -311,6 +326,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.', + })} +

+
+ )}
diff --git a/tests/api/test_node.py b/tests/api/test_node.py index 965edb0d4..b4cf26385 100644 --- a/tests/api/test_node.py +++ b/tests/api/test_node.py @@ -604,78 +604,40 @@ def _xhttp_core_config(tag: str) -> dict: } -async def test_update_node_status_migrates_session_keys_for_sole_node(): +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_migrate_sole"), config=_xhttp_core_config("xhttp-sole"), exclude_inbound_tags=set(), fallbacks_inbound_tags=set()), + 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_migrate_sole"), core_config_id=core_id)) + 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) - # Starts below the sessionID-rename threshold — no migration yet. 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_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" - async with TestSession() as session: db_node = await session.get(Node, node_id) - # Crossing the threshold on the sole node using this core triggers the migration. + # 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["sessionIDPlacement"] == "header" - assert xhttp["sessionIDKey"] == "X-Request-ID" - assert "sessionPlacement" not in xhttp - assert "sessionKey" not in xhttp - - await cleanup_nodes_simple(core_id, [node_id]) - - -async def test_update_node_status_skips_migration_when_core_shared(): - 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_migrate_shared"), config=_xhttp_core_config("xhttp-shared"), exclude_inbound_tags=set(), fallbacks_inbound_tags=set()), - ) - core_id = inspect(core).identity[0] - node_a_model = NodeCreate(**node_create_payload(name=unique_name("node_migrate_shared_a"), core_config_id=core_id)) - node_b_model = NodeCreate(**node_create_payload(name=unique_name("node_migrate_shared_b"), 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) - await update_node_status(session, db_node_a, NodeStatus.connected, xray_version="26.5.9", node_version="0.5.0") - - async with TestSession() as session: - db_node_a = await session.get(Node, node_a_id) - # node_b (still on this core) is unaccounted for, so the shared config must not be rewritten. - await update_node_status(session, db_node_a, 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_a_id, node_b_id]) + await cleanup_nodes_simple(core_id, [node_id]) async def test_get_xray_version_by_core_id_ignores_status(): From 229513d5f3522521a020ceee32c1dcbc87e4709c Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Fri, 3 Jul 2026 22:55:58 +0700 Subject: [PATCH 08/13] feat(node): warn when a core config is shared across nodes on different Xray versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-modal.tsx: when selecting a Core Config, fetch the other nodes already using it (useGetNodes({ core_id })) and show a muted, non-blocking hint if their reported Xray versions differ — that core is pushed to every node unmodified except for the handful of fields the panel already translates per node version, so anything else version-specific can behave differently across them. Kept as a warning rather than a hard block, consistent with not forcing changes on the admin's data. --- dashboard/public/statics/locales/en.json | 3 +- dashboard/public/statics/locales/fa.json | 3 +- dashboard/public/statics/locales/ru.json | 3 +- dashboard/public/statics/locales/zh.json | 3 +- .../src/features/nodes/dialogs/node-modal.tsx | 33 +++++++++++++++++-- 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index c6185a2e6..f55d71904 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2070,7 +2070,8 @@ "breakingChangeWarning": "Starting with Xray 26.6.22, the xhttp parameters \"sessionPlacement\" and \"sessionKey\" are renamed to \"sessionIDPlacement\" and \"sessionIDKey\". Server configs are migrated automatically when the new version is installed. However, users whose xhttp inbound subscriptions still contain sessionPlacement/sessionKey will not be able to connect to the server after the upgrade until they re-import their subscription. Continue?", "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." + "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", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index c95c7ff30..73af005af 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -1882,7 +1882,8 @@ "breakingChangeWarning": "از نسخه Xray 26.6.22 به بعد، پارامترهای xhttp یعنی «sessionPlacement» و «sessionKey» به «sessionIDPlacement» و «sessionIDKey» تغییر نام می‌یابند. پیکربندی‌های سرور هنگام نصب نسخه جدید به‌صورت خودکار مهاجرت می‌کنند. اما کاربرانی که در اشتراک‌های اینباند xhttp آن‌ها هنوز sessionPlacement/sessionKey وجود دارد، پس از به‌روزرسانی تا زمانی که اشتراک خود را دوباره وارد نکنند نمی‌توانند به سرور متصل شوند. ادامه می‌دهید؟", "breakingChangeConfirm": "متوجه شدم، در هر صورت به‌روزرسانی شود", "prereleaseWarningTitle": "نسخه پیش‌انتشار انتخاب شد", - "prereleaseWarningMessage": "این نسخه هنوز به یک نسخه پایدار ارتقا نیافته است. برخی پارامترها ممکن است پس از رسیدن به نسخه پایدار تغییر کنند یا رفتار متفاوتی داشته باشند — تا زمانی که ریسک آن را درک نکرده‌اید، از آن روی نودهای تولیدی استفاده نکنید." + "prereleaseWarningMessage": "این نسخه هنوز به یک نسخه پایدار ارتقا نیافته است. برخی پارامترها ممکن است پس از رسیدن به نسخه پایدار تغییر کنند یا رفتار متفاوتی داشته باشند — تا زمانی که ریسک آن را درک نکرده‌اید، از آن روی نودهای تولیدی استفاده نکنید.", + "coreVersionMismatchWarning": "سایر نودهایی که از این core config استفاده می‌کنند نسخه متفاوتی از Xray را اجرا می‌کنند. این core به‌جز چند فیلد که پنل به‌صورت ایمن متناسب با نسخه هر نود تطبیق می‌دهد، بدون تغییر به همه نودها ارسال می‌شود — ممکن است برخی از تنظیمات باقی‌مانده آن‌طور که انتظار می‌رود کار نکنند." }, "nodes": { "title": "گره‌ها", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index d8597876e..b3fa05a1e 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1958,7 +1958,8 @@ "breakingChangeWarning": "Начиная с Xray 26.6.22, параметры xhttp «sessionPlacement» и «sessionKey» переименованы в «sessionIDPlacement» и «sessionIDKey». Серверные конфиги мигрируют автоматически при установке новой версии. Однако пользователи, у которых в подписках с xhttp-инбаундами остались sessionPlacement/sessionKey, не смогут подключиться к серверу после обновления, пока не переимпортируют подписку. Продолжить?", "breakingChangeConfirm": "Понимаю, всё равно обновить", "prereleaseWarningTitle": "Выбрана pre-release версия", - "prereleaseWarningMessage": "Эта версия ещё не стала стабильным релизом. Некоторые параметры могут измениться или начать работать иначе, когда она станет стабильной — не используйте её на боевых нодах, если не понимаете риски." + "prereleaseWarningMessage": "Эта версия ещё не стала стабильным релизом. Некоторые параметры могут измениться или начать работать иначе, когда она станет стабильной — не используйте её на боевых нодах, если не понимаете риски.", + "coreVersionMismatchWarning": "Другие ноды, уже использующие этот core config, работают на другой версии Xray. Этот core пушится на все ноды без изменений (кроме нескольких полей, которые панель безопасно адаптирует под версию каждой ноды) — часть остальных настроек может работать не так, как ожидается." }, "theme": { "title": "Тема", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index dd9d8bc3b..b3e31f201 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -2029,7 +2029,8 @@ "breakingChangeWarning": "从 Xray 26.6.22 开始,xhttp 参数 “sessionPlacement” 和 “sessionKey” 已重命名为 “sessionIDPlacement” 和 “sessionIDKey”。安装新版本时,服务器配置会自动迁移。但是,订阅中 xhttp 入站仍包含 sessionPlacement/sessionKey 的用户在升级后将无法连接到服务器,直到他们重新导入订阅。是否继续?", "breakingChangeConfirm": "我已了解,仍然更新", "prereleaseWarningTitle": "已选择预发布版本", - "prereleaseWarningMessage": "该版本尚未成为稳定发行版。待其成为稳定版本时,部分参数可能会更改或表现不同——除非您了解其中风险,否则不要在生产节点上使用。" + "prereleaseWarningMessage": "该版本尚未成为稳定发行版。待其成为稳定版本时,部分参数可能会更改或表现不同——除非您了解其中风险,否则不要在生产节点上使用。", + "coreVersionMismatchWarning": "已使用此核心配置的其他节点运行的是不同的 Xray 版本。除少数由面板根据每个节点版本安全适配的字段外,该核心配置会原样推送到所有节点——其余部分设置可能无法按预期工作。" }, "theme": { "title": "主题", 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.", + })} + +

+ )} + Date: Fri, 3 Jul 2026 23:42:05 +0700 Subject: [PATCH 09/13] fix(node): make shared-core xray_version lookup deterministic get_xray_version_by_core_id previously picked an unordered .limit(1) row when a core config is shared by multiple nodes, so the core-editor's version-aware gating (e.g. Session ID Table/Length visibility) could pin to an arbitrary sibling node's version instead of the one actually being configured. Now it picks the highest reported version among all nodes on that core, so the editor never hides a capability a real node on the core actually supports; the admin is separately warned elsewhere when nodes on a shared core report different versions. --- app/db/crud/node.py | 23 +++++++++++++++++------ tests/api/test_node.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/app/db/crud/node.py b/app/db/crud/node.py index a523cae0a..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 @@ -87,14 +88,24 @@ async def get_node_by_id(db: AsyncSession, node_id: int) -> Optional[Node]: return node async def get_xray_version_by_core_id(db: AsyncSession, core_config_id: int) -> str | None: - return ( + """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)) - .limit(1) + select(Node.xray_version).where(Node.core_config_id == core_config_id).where(Node.xray_version.isnot(None)) ) - ).scalar_one_or_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, diff --git a/tests/api/test_node.py b/tests/api/test_node.py index b4cf26385..e99a8256e 100644 --- a/tests/api/test_node.py +++ b/tests/api/test_node.py @@ -694,6 +694,39 @@ async def test_get_xray_version_by_core_id_prefers_known_version_over_never_conn 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 From b6acb3dafbe62f2ab1fcd712c386911c542c5d40 Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Sat, 4 Jul 2026 00:50:43 +0700 Subject: [PATCH 10/13] fix(node): unify pre-release warning with breaking-change styling, drop redundant block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restyle the pre-release-version warning to match the removed breaking-change warning's amber styling instead of blue, so there's one consistent look instead of two stacked warnings when both applied. - Drop the breaking-change warning's own UI block; the version-threshold check it was built on (isBreaking) stays, since the backend still requires `confirm: true` when actually crossing the sessionID-rename boundary — only the now-redundant dedicated banner is gone. - Fix an unclear ru translation ("боевых нодах" → "продакшен-нодах"). --- dashboard/public/statics/locales/en.json | 2 -- dashboard/public/statics/locales/fa.json | 2 -- dashboard/public/statics/locales/ru.json | 4 +--- dashboard/public/statics/locales/zh.json | 2 -- .../nodes/dialogs/update-core-modal.tsx | 19 ++++--------------- 5 files changed, 5 insertions(+), 24 deletions(-) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index f55d71904..b7522aee9 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2066,8 +2066,6 @@ "updateAvailable": "Update Available", "prerelease": "Pre-release", "loadingReleases": "Loading releases...", - "breakingChangeTitle": "Breaking changes in Xray 26.6.22+", - "breakingChangeWarning": "Starting with Xray 26.6.22, the xhttp parameters \"sessionPlacement\" and \"sessionKey\" are renamed to \"sessionIDPlacement\" and \"sessionIDKey\". Server configs are migrated automatically when the new version is installed. However, users whose xhttp inbound subscriptions still contain sessionPlacement/sessionKey will not be able to connect to the server after the upgrade until they re-import their subscription. Continue?", "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.", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 73af005af..bd38b84da 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -1878,8 +1878,6 @@ "updateAvailable": "به‌روزرسانی موجود است", "prerelease": "نسخه پیش‌انتشار", "loadingReleases": "در حال بارگذاری نسخه‌ها...", - "breakingChangeTitle": "تغییرات ناسازگار در Xray 26.6.22+", - "breakingChangeWarning": "از نسخه Xray 26.6.22 به بعد، پارامترهای xhttp یعنی «sessionPlacement» و «sessionKey» به «sessionIDPlacement» و «sessionIDKey» تغییر نام می‌یابند. پیکربندی‌های سرور هنگام نصب نسخه جدید به‌صورت خودکار مهاجرت می‌کنند. اما کاربرانی که در اشتراک‌های اینباند xhttp آن‌ها هنوز sessionPlacement/sessionKey وجود دارد، پس از به‌روزرسانی تا زمانی که اشتراک خود را دوباره وارد نکنند نمی‌توانند به سرور متصل شوند. ادامه می‌دهید؟", "breakingChangeConfirm": "متوجه شدم، در هر صورت به‌روزرسانی شود", "prereleaseWarningTitle": "نسخه پیش‌انتشار انتخاب شد", "prereleaseWarningMessage": "این نسخه هنوز به یک نسخه پایدار ارتقا نیافته است. برخی پارامترها ممکن است پس از رسیدن به نسخه پایدار تغییر کنند یا رفتار متفاوتی داشته باشند — تا زمانی که ریسک آن را درک نکرده‌اید، از آن روی نودهای تولیدی استفاده نکنید.", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index b3fa05a1e..5ad01e4e2 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1954,11 +1954,9 @@ "updateAvailable": "Доступно обновление", "prerelease": "Предварительная версия", "loadingReleases": "Загрузка релизов...", - "breakingChangeTitle": "Ломающие изменения в Xray 26.6.22+", - "breakingChangeWarning": "Начиная с Xray 26.6.22, параметры xhttp «sessionPlacement» и «sessionKey» переименованы в «sessionIDPlacement» и «sessionIDKey». Серверные конфиги мигрируют автоматически при установке новой версии. Однако пользователи, у которых в подписках с xhttp-инбаундами остались sessionPlacement/sessionKey, не смогут подключиться к серверу после обновления, пока не переимпортируют подписку. Продолжить?", "breakingChangeConfirm": "Понимаю, всё равно обновить", "prereleaseWarningTitle": "Выбрана pre-release версия", - "prereleaseWarningMessage": "Эта версия ещё не стала стабильным релизом. Некоторые параметры могут измениться или начать работать иначе, когда она станет стабильной — не используйте её на боевых нодах, если не понимаете риски.", + "prereleaseWarningMessage": "Эта версия ещё не стала стабильным релизом. Некоторые параметры могут измениться или начать работать иначе, когда она станет стабильной — не используйте её на продакшен-нодах, если не понимаете риски.", "coreVersionMismatchWarning": "Другие ноды, уже использующие этот core config, работают на другой версии Xray. Этот core пушится на все ноды без изменений (кроме нескольких полей, которые панель безопасно адаптирует под версию каждой ноды) — часть остальных настроек может работать не так, как ожидается." }, "theme": { diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index b3e31f201..72d2317e0 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -2025,8 +2025,6 @@ "updateAvailable": "有更新可用", "prerelease": "预发布版本", "loadingReleases": "正在加载版本...", - "breakingChangeTitle": "Xray 26.6.22+ 的重大变更", - "breakingChangeWarning": "从 Xray 26.6.22 开始,xhttp 参数 “sessionPlacement” 和 “sessionKey” 已重命名为 “sessionIDPlacement” 和 “sessionIDKey”。安装新版本时,服务器配置会自动迁移。但是,订阅中 xhttp 入站仍包含 sessionPlacement/sessionKey 的用户在升级后将无法连接到服务器,直到他们重新导入订阅。是否继续?", "breakingChangeConfirm": "我已了解,仍然更新", "prereleaseWarningTitle": "已选择预发布版本", "prereleaseWarningMessage": "该版本尚未成为稳定发行版。待其成为稳定版本时,部分参数可能会更改或表现不同——除非您了解其中风险,否则不要在生产节点上使用。", diff --git a/dashboard/src/features/nodes/dialogs/update-core-modal.tsx b/dashboard/src/features/nodes/dialogs/update-core-modal.tsx index 70c843987..ec8934689 100644 --- a/dashboard/src/features/nodes/dialogs/update-core-modal.tsx +++ b/dashboard/src/features/nodes/dialogs/update-core-modal.tsx @@ -314,22 +314,9 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC - {/* Breaking change warning (xhttp session* -> sessionID* in Xray 26.6.22+) */} - {isBreaking && ( -
-
- - {t('nodeModal.breakingChangeTitle', { defaultValue: 'Breaking changes in Xray 26.6.22+' })} -
-

- {t('nodeModal.breakingChangeWarning')} -

-
- )} - {/* Pre-release warning — this version hasn't been promoted to a stable release yet */} {isPrereleaseTarget && ( -
+
{t('nodeModal.prereleaseWarningTitle', { defaultValue: 'Pre-release version selected' })} @@ -355,7 +342,9 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC isLoading={updateCoreMutation.isPending} loadingText={t('nodeModal.updating', { defaultValue: 'Updating...' })} > - {isBreaking ? t('nodeModal.breakingChangeConfirm', { defaultValue: 'I understand, update anyway' }) : t('nodeModal.update', { defaultValue: 'Update' })} + {isBreaking || isPrereleaseTarget + ? t('nodeModal.breakingChangeConfirm', { defaultValue: 'I understand, update anyway' }) + : t('nodeModal.update', { defaultValue: 'Update' })} From 28ee044ea81d1a027c4382d761f1d7b63c9a85ce Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Sat, 4 Jul 2026 01:46:08 +0700 Subject: [PATCH 11/13] fix(core-editor): flag sessionIDTable set without sessionIDLength MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkSessionIdRoomSize() and its three call sites (Hosts form, inbound editor, profile-level persist check) previously skipped validation whenever either sessionIDTable or sessionIDLength was missing, treating it as "nothing to check yet". That's only true in one direction: Xray-core only runs this check when sessionIDTable is set at all (infra/conf/ transport_internet.go gates the whole block on `c.SessionIDTable != ""`), but once a table is set there's no default for sessionIDLength — an unset or invalid length hits the same Build()-time error as a too-small range. A table configured without a length now gets flagged instead of silently saving a config Xray would refuse to start. --- .../components/xray/xray-inbounds-section.tsx | 3 +-- .../src/features/core-editor/kit/xray-adapter.ts | 4 ++-- dashboard/src/features/hosts/forms/host-form.ts | 7 ++++--- dashboard/src/lib/xray-session-id-room-size.test.ts | 13 +++++++++++-- dashboard/src/lib/xray-session-id-room-size.ts | 9 +++++++-- 5 files changed, 25 insertions(+), 11 deletions(-) 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 54289a076..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 @@ -3356,8 +3356,7 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo const showCustomTable = sessionIdTableCustomMode || (sessionIdTableValue !== '' && !isPresetTable) const tableSelectValue = showCustomTable ? '__custom' : sessionIdTableValue === '' ? '__default' : sessionIdTableValue const sessionIdLengthValue = String(getTransportMetaValue(xhttpExtra, 'sessionidlength') ?? '') - const sessionIdRoomSizeProblem = - sessionIdTableValue && sessionIdLengthValue ? checkSessionIdRoomSize(sessionIdTableValue, sessionIdLengthValue) : null + const sessionIdRoomSizeProblem = checkSessionIdRoomSize(sessionIdTableValue, sessionIdLengthValue) return (
diff --git a/dashboard/src/features/core-editor/kit/xray-adapter.ts b/dashboard/src/features/core-editor/kit/xray-adapter.ts index 354d4b079..5126b92f8 100644 --- a/dashboard/src/features/core-editor/kit/xray-adapter.ts +++ b/dashboard/src/features/core-editor/kit/xray-adapter.ts @@ -388,8 +388,8 @@ function getXhttpSessionIdRoomSizeIssues(profile: Profile): Issue[] { if (transport?.type !== 'xhttp') return const table = transport.extra?.sessionIDTable const length = transport.extra?.sessionIDLength - if (typeof table !== 'string' || typeof length !== 'string' || !table || !length) return - const problem = checkSessionIdRoomSize(table, length) + 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', diff --git a/dashboard/src/features/hosts/forms/host-form.ts b/dashboard/src/features/hosts/forms/host-form.ts index c31023f2e..672d8842f 100644 --- a/dashboard/src/features/hosts/forms/host-form.ts +++ b/dashboard/src/features/hosts/forms/host-form.ts @@ -206,9 +206,10 @@ const transportSettingsSchema = z .superRefine((data, ctx) => { const table = data?.session_id_table const length = data?.session_id_length - // Nothing to check until both are set — either one alone means Xray's default applies. - if (!table || !length) return - const problem = checkSessionIdRoomSize(table, 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, diff --git a/dashboard/src/lib/xray-session-id-room-size.test.ts b/dashboard/src/lib/xray-session-id-room-size.test.ts index 54af95962..89b20c613 100644 --- a/dashboard/src/lib/xray-session-id-room-size.test.ts +++ b/dashboard/src/lib/xray-session-id-room-size.test.ts @@ -34,8 +34,17 @@ describe('checkSessionIdRoomSize', () => { expect(checkSessionIdRoomSize('Base62', '0')).toBe('length-not-positive') }) - it('returns null for an unparseable length (format is checked elsewhere)', () => { - expect(checkSessionIdRoomSize('Base62', 'not-a-length')).toBeNull() + 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', () => { diff --git a/dashboard/src/lib/xray-session-id-room-size.ts b/dashboard/src/lib/xray-session-id-room-size.ts index 02a3f4016..da6e860f2 100644 --- a/dashboard/src/lib/xray-session-id-room-size.ts +++ b/dashboard/src/lib/xray-session-id-room-size.ts @@ -41,11 +41,16 @@ export type SessionIdRoomSizeProblem = 'length-not-positive' | 'room-too-small' /** * Mirrors Xray-core's Build()-time check on `sessionIDTable`/`sessionIDLength` (XHTTP transport). - * Returns null when `length` doesn't parse (format is checked elsewhere) or the combination is safe. + * 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 null + if (!range) return 'length-not-positive' const [from, to] = range if (from <= 0) return 'length-not-positive' const room = sessionIdRoomSize(sessionIdTableLength(table), from, to) From c896541dad5a36077bea9f3d05e985e4e260d8f3 Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Sat, 4 Jul 2026 18:37:51 +0700 Subject: [PATCH 12/13] fix(hosts): include sessionIDTable/sessionIDLength when loading a host for edit --- dashboard/src/features/hosts/components/hosts-list.tsx | 2 ++ 1 file changed, 2 insertions(+) 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, From de580896b6b50d3f4b1ff9191861db160a5ebe5b Mon Sep 17 00:00:00 2001 From: FunLay123 Date: Sat, 4 Jul 2026 20:14:29 +0700 Subject: [PATCH 13/13] fix(subscription): order sessionIDTable/sessionIDLength after sessionIDKey in xhttp extra --- app/subscription/links.py | 2 +- app/subscription/xray.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/subscription/links.py b/app/subscription/links.py index e854cf3e3..d21bf585b 100644 --- a/app/subscription/links.py +++ b/app/subscription/links.py @@ -117,6 +117,7 @@ def _transport_xhttp(self, payload: dict, protocol: str, config: XHTTPTransportC "uplinkHTTPMethod": config.uplink_http_method, ("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, @@ -126,7 +127,6 @@ def _transport_xhttp(self, payload: dict, protocol: str, config: XHTTPTransportC "xmux": config.xmux, "headers": config.http_headers if config.http_headers else {}, "downloadSettings": config.download_settings, - **({"sessionIDTable": config.session_id_table, "sessionIDLength": config.session_id_length} if is_new else {}), } if config.random_user_agent: diff --git a/app/subscription/xray.py b/app/subscription/xray.py index 4afaba219..2b8cb472f 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -149,7 +149,8 @@ def _transport_xhttp(self, config: XHTTPTransportConfig, path: str) -> dict: "xPaddingMethod": config.x_padding_method, "uplinkHTTPMethod": config.uplink_http_method, ("sessionIDPlacement" if is_new else "sessionPlacement"): config.session_placement, - ("sessionIDKey" if is_new else "sessionKey"): config.session_key, + ("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, @@ -160,7 +161,6 @@ def _transport_xhttp(self, config: XHTTPTransportConfig, path: str) -> dict: "downloadSettings": self._xhttp_download_config(config.download_settings) if config.download_settings else None, - **({"sessionIDTable": config.session_id_table, "sessionIDLength": config.session_id_length} if is_new else {}), } if config.random_user_agent: