From 9c3b78fd7bb8e0c4ef2516074d98bfffb25a59a6 Mon Sep 17 00:00:00 2001 From: x0sina Date: Thu, 2 Apr 2026 15:03:39 +0330 Subject: [PATCH 01/53] feat(core): add WireGuard core management support --- app/core/abstract_core.py | 5 + app/core/manager.py | 59 +- app/core/wireguard.py | 144 ++++ app/core/xray.py | 24 +- app/db/crud/core.py | 1 + app/db/crud/group.py | 2 +- app/db/crud/user.py | 3 + ...5f2b87_add_backend_type_to_core_configs.py | 29 + app/db/models.py | 1 + app/models/core.py | 9 + app/models/node.py | 12 +- app/models/proxy.py | 49 +- app/node/user.py | 3 + app/notification/discord/node.py | 2 +- app/notification/telegram/node.py | 2 +- app/operation/__init__.py | 14 + app/operation/core.py | 12 +- app/operation/group.py | 72 +- app/operation/host.py | 4 +- app/operation/node.py | 24 +- app/operation/user.py | 48 ++ app/utils/crypto.py | 40 + app/wireguard.py | 224 ++++++ dashboard/src/components/cores/cores-list.tsx | 8 + .../components/dialogs/core-config-modal.tsx | 754 +++++++++++------- .../src/components/dialogs/node-modal.tsx | 23 +- .../components/dialogs/update-core-modal.tsx | 261 +++--- .../src/components/dialogs/user-modal.tsx | 184 ++++- .../src/components/forms/core-config-form.ts | 2 + dashboard/src/components/forms/user-form.ts | 11 + dashboard/src/components/nodes/node.tsx | 76 +- .../src/pages/_dashboard.nodes.cores.tsx | 3 + dashboard/src/service/api/index.ts | 22 + dashboard/src/utils/wireguard.ts | 17 + 34 files changed, 1602 insertions(+), 542 deletions(-) create mode 100644 app/core/wireguard.py create mode 100644 app/db/migrations/versions/6d0f9e5f2b87_add_backend_type_to_core_configs.py create mode 100644 app/wireguard.py create mode 100644 dashboard/src/utils/wireguard.ts diff --git a/app/core/abstract_core.py b/app/core/abstract_core.py index 02625f87b..c25f77216 100644 --- a/app/core/abstract_core.py +++ b/app/core/abstract_core.py @@ -10,6 +10,11 @@ def __init__(self, config: dict, exclude_inbound_tags: list[str], fallbacks_inbo def to_str(self, **json_kwargs) -> str: raise NotImplementedError + @property + @abstractmethod + def backend_type(self) -> str: + raise NotImplementedError + @property @abstractmethod def inbounds_by_tag(self) -> dict: diff --git a/app/core/manager.py b/app/core/manager.py index 80508c034..2f637c4ba 100644 --- a/app/core/manager.py +++ b/app/core/manager.py @@ -9,10 +9,12 @@ from app import on_shutdown, on_startup from app.core.abstract_core import AbstractCore +from app.core.wireguard import WireGuardConfig from app.core.xray import XRayConfig from app.db import GetDB from app.db.crud.core import get_core_configs from app.db.models import CoreConfig +from app.models.core import CoreType from app.nats import is_nats_enabled from app.nats.client import setup_nats_kv from app.nats.message import MessageTopic @@ -24,6 +26,10 @@ class CoreManager: STATE_CACHE_KEY = "state" KV_BUCKET_NAME = "core_manager_state" + CORE_CLASSES = { + CoreType.XRAY: XRayConfig, + CoreType.WIREGUARD: WireGuardConfig, + } def __init__(self): self._cores: dict[int, AbstractCore] = {} @@ -87,8 +93,7 @@ async def _load_state_from_cache(self) -> bool: cores = {} for core_id, core_data in cached_state.get("cores", {}).items(): try: - # Currently we only support XRayConfig, but this could be dynamic based on type - cores[int(core_id)] = XRayConfig.from_json(core_data) + cores[int(core_id)] = self._core_from_json(core_data) except Exception: self._logger.warning(f"Failed to reconstruct core {core_id} from JSON") continue @@ -113,14 +118,36 @@ async def _reload_from_cache(self): def _core_payload_from_db(self, db_core_config: CoreConfig) -> dict: return { "id": db_core_config.id, + "backend_type": db_core_config.backend_type, "config": db_core_config.config, "exclude_inbound_tags": list(db_core_config.exclude_inbound_tags or []), "fallbacks_inbound_tags": list(db_core_config.fallbacks_inbound_tags or []), } + @classmethod + def _normalize_backend_type(cls, backend_type: str | CoreType | None) -> CoreType: + if not backend_type: + return CoreType.XRAY + try: + return CoreType(backend_type) + except ValueError as exc: + raise ValueError(f"unsupported backend_type: {backend_type}") from exc + + @classmethod + def _get_core_class(cls, backend_type: str | CoreType | None): + normalized_backend_type = cls._normalize_backend_type(backend_type) + return cls.CORE_CLASSES[normalized_backend_type] + + @classmethod + def _core_from_json(cls, data: dict) -> AbstractCore: + backend_type = data.get("backend_type") + core_class = cls._get_core_class(backend_type) + return core_class.from_json(data) + async def _apply_core_payload(self, payload: dict): try: core_id = payload["id"] + backend_type = payload.get("backend_type", CoreType.XRAY) config = payload["config"] except Exception: await self._reload_from_cache() @@ -130,13 +157,14 @@ async def _apply_core_payload(self, payload: dict): fallback_tags = set(payload.get("fallbacks_inbound_tags") or []) class _PayloadCore: - def __init__(self, cid, cfg, exclude, fallbacks): + def __init__(self, cid, cfg, backend, exclude, fallbacks): self.id = cid + self.backend_type = backend self.config = cfg self.exclude_inbound_tags = exclude self.fallbacks_inbound_tags = fallbacks - await self._update_core_local(_PayloadCore(core_id, config, exclude_tags, fallback_tags)) + await self._update_core_local(_PayloadCore(core_id, config, backend_type, exclude_tags, fallback_tags)) async def _handle_core_message(self, data: dict): """Handle incoming core messages from router.""" @@ -162,11 +190,15 @@ async def _publish_invalidation(self, message: dict): @staticmethod def validate_core( - config: dict, exclude_inbounds: set[str] | None = None, fallbacks_inbounds: set[str] | None = None + config: dict, + exclude_inbounds: set[str] | None = None, + fallbacks_inbounds: set[str] | None = None, + backend_type: str | CoreType | None = None, ): exclude_inbounds = exclude_inbounds or set() fallbacks_inbounds = fallbacks_inbounds or set() - return XRayConfig(config, exclude_inbounds.copy(), fallbacks_inbounds.copy()) + core_class = CoreManager._get_core_class(backend_type) + return core_class(config, exclude_inbounds.copy(), fallbacks_inbounds.copy()) async def initialize(self, db): # Register handler with global router @@ -184,7 +216,10 @@ async def initialize(self, db): backends: dict[int, AbstractCore] = {} for config in core_configs: backend_config = self.validate_core( - config.config, config.exclude_inbound_tags, config.fallbacks_inbound_tags + config.config, + config.exclude_inbound_tags, + config.fallbacks_inbound_tags, + config.backend_type, ) backends[config.id] = backend_config @@ -208,7 +243,10 @@ async def update_inbounds(self): async def _update_core_local(self, db_core_config: CoreConfig): backend_config = self.validate_core( - db_core_config.config, db_core_config.exclude_inbound_tags, db_core_config.fallbacks_inbound_tags + db_core_config.config, + db_core_config.exclude_inbound_tags, + db_core_config.fallbacks_inbound_tags, + db_core_config.backend_type, ) async with self._lock: @@ -224,7 +262,10 @@ async def _update_core_nats(self, db_core_config: CoreConfig): # Validate payload before publishing the broadcast message. self.validate_core( - db_core_config.config, db_core_config.exclude_inbound_tags, db_core_config.fallbacks_inbound_tags + db_core_config.config, + db_core_config.exclude_inbound_tags, + db_core_config.fallbacks_inbound_tags, + db_core_config.backend_type, ) try: await self._publish_invalidation({"action": "update", "core": self._core_payload_from_db(db_core_config)}) diff --git a/app/core/wireguard.py b/app/core/wireguard.py new file mode 100644 index 000000000..154dd070e --- /dev/null +++ b/app/core/wireguard.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from ipaddress import ip_interface +from pathlib import PosixPath +from typing import Union + +import commentjson + +from app.models.core import CoreType +from app.utils.crypto import get_wireguard_public_key, validate_wireguard_key + + +class WireGuardConfig(dict): + def __init__( + self, + config: Union[dict, str, PosixPath] | None = None, + exclude_inbound_tags: set[str] | None = None, + fallbacks_inbound_tags: set[str] | None = None, + skip_validation: bool = False, + ): + if config is None: + config = {} + if isinstance(config, str): + config = commentjson.loads(config) + if isinstance(config, dict): + config = deepcopy(config) + + super().__init__(config) + + self._backend_type = CoreType.WIREGUARD + self.exclude_inbound_tags = set(exclude_inbound_tags or set()) + self.fallbacks_inbound_tags = set(fallbacks_inbound_tags or set()) + self._inbounds: list[str] = [] + self._inbounds_by_tag: dict[str, dict] = {} + + if skip_validation: + return + + self._validate() + self._resolve_inbounds() + + @property + def backend_type(self) -> str: + return self._backend_type + + def _validate(self): + if self.exclude_inbound_tags: + raise ValueError("exclude_inbound_tags is only supported for xray cores") + if self.fallbacks_inbound_tags: + raise ValueError("fallbacks_inbound_tags is only supported for xray cores") + + interface_name = str(self.get("interface_name") or "").strip() + if not interface_name: + raise ValueError("interface_name is required") + if "," in interface_name: + raise ValueError("character ',' is not allowed in interface_name") + if "<=>" in interface_name: + raise ValueError("character '<=>' is not allowed in interface_name") + self["interface_name"] = interface_name + + private_key = str(self.get("private_key") or "").strip() + if not private_key: + raise ValueError("private_key is required") + self["private_key"] = validate_wireguard_key(private_key, "private_key") + self["public_key"] = get_wireguard_public_key(self["private_key"]) + + pre_shared_key = str(self.get("pre_shared_key") or "").strip() + if pre_shared_key: + self["pre_shared_key"] = validate_wireguard_key(pre_shared_key, "pre_shared_key") + else: + self.pop("pre_shared_key", None) + + listen_port = self.get("listen_port") + if not isinstance(listen_port, int) or listen_port <= 0 or listen_port > 65535: + raise ValueError("listen_port must be an integer between 1 and 65535") + + addresses = self.get("address") + if not isinstance(addresses, list) or not addresses: + raise ValueError("address must contain at least one CIDR") + + normalized_addresses: list[str] = [] + for cidr in addresses: + if not isinstance(cidr, str) or not cidr.strip(): + raise ValueError("address entries must be valid CIDR strings") + normalized_addresses.append(str(ip_interface(cidr.strip()))) + self["address"] = normalized_addresses + + peer_keepalive_seconds = self.get("peer_keepalive_seconds", 25) + if not isinstance(peer_keepalive_seconds, int): + raise ValueError("peer_keepalive_seconds must be an integer") + if peer_keepalive_seconds <= 0: + peer_keepalive_seconds = 25 + self["peer_keepalive_seconds"] = peer_keepalive_seconds + + def _resolve_inbounds(self): + interface_name = self["interface_name"] + metadata = { + "tag": interface_name, + "protocol": "wireguard", + "network": "udp", + "tls": "none", + "interface_name": interface_name, + "listen_port": self["listen_port"], + "address": list(self["address"]), + "peer_keepalive_seconds": self["peer_keepalive_seconds"], + "public_key": self.get("public_key", ""), + } + self._inbounds = [interface_name] + self._inbounds_by_tag = {interface_name: metadata} + + def to_str(self, **json_kwargs) -> str: + return json.dumps(self, **json_kwargs) + + @property + def inbounds_by_tag(self) -> dict: + return self._inbounds_by_tag + + @property + def inbounds(self) -> list[str]: + return self._inbounds + + def to_json(self) -> dict: + return { + "backend_type": self.backend_type, + "config": dict(self), + "exclude_inbound_tags": [], + "fallbacks_inbound_tags": [], + "inbounds": self.inbounds, + "inbounds_by_tag": self.inbounds_by_tag, + } + + @classmethod + def from_json(cls, data: dict) -> "WireGuardConfig": + instance = cls(config=data.get("config", {}), skip_validation=True) + if "inbounds" in data: + instance._inbounds = data["inbounds"] + if "inbounds_by_tag" in data: + instance._inbounds_by_tag = data["inbounds_by_tag"] + return instance + + def copy(self): + return deepcopy(self) diff --git a/app/core/xray.py b/app/core/xray.py index 713ed1876..1feddd866 100644 --- a/app/core/xray.py +++ b/app/core/xray.py @@ -8,18 +8,21 @@ import commentjson +from app.models.core import CoreType from app.utils.crypto import get_cert_SANs, get_x25519_public_key class XRayConfig(dict): def __init__( self, - config: Union[dict, str, PosixPath] = {}, - exclude_inbound_tags: set[str] | None = set(), - fallbacks_inbound_tags: set[str] | None = set(), + config: Union[dict, str, PosixPath] | None = None, + exclude_inbound_tags: set[str] | None = None, + fallbacks_inbound_tags: set[str] | None = None, skip_validation: bool = False, ): """Initialize the XRay config.""" + if config is None: + config = {} if isinstance(config, str): # considering string as json config = commentjson.loads(config) @@ -34,8 +37,10 @@ def __init__( if fallbacks_inbound_tags is None: fallbacks_inbound_tags = set() + self._backend_type = CoreType.XRAY exclude_inbound_tags.update(fallbacks_inbound_tags) self.exclude_inbound_tags = exclude_inbound_tags + self.fallbacks_inbound_tags = set(fallbacks_inbound_tags) self._inbounds = [] self._inbounds_by_tag = {} @@ -442,23 +447,32 @@ def inbounds(self) -> list[str]: """Get inbounds by tag.""" return self._inbounds + @property + def backend_type(self) -> str: + return self._backend_type + def to_json(self) -> dict: """Convert the config to a JSON-serializable dictionary.""" return { + "backend_type": self.backend_type, "config": dict(self), "exclude_inbound_tags": list(self.exclude_inbound_tags), + "fallbacks_inbound_tags": list(self.fallbacks_inbound_tags), "inbounds": self.inbounds, "inbounds_by_tag": self.inbounds_by_tag, - "fallbacks_inbound": self._fallbacks_inbound, } @classmethod def from_json(cls, data: dict) -> "XRayConfig": """Reconstruct the config from a dictionary.""" + fallback_tags = data.get("fallbacks_inbound_tags") + if fallback_tags is None: + fallback_tags = [] + instance = cls( config=data.get("config", {}), exclude_inbound_tags=set(data.get("exclude_inbound_tags", [])), - fallbacks_inbound_tags=set(data.get("fallbacks_inbound", [])), + fallbacks_inbound_tags=set(fallback_tags), skip_validation=True, ) if "inbounds" in data: diff --git a/app/db/crud/core.py b/app/db/crud/core.py index 8a8a3f3ea..b9380e060 100644 --- a/app/db/crud/core.py +++ b/app/db/crud/core.py @@ -45,6 +45,7 @@ async def create_core_config(db: AsyncSession, core_config: CoreCreate) -> CoreC """ db_core_config = CoreConfig( name=core_config.name, + backend_type=core_config.backend_type, config=core_config.config, exclude_inbound_tags=core_config.exclude_inbound_tags or set(), fallbacks_inbound_tags=core_config.fallbacks_inbound_tags or set(), diff --git a/app/db/crud/group.py b/app/db/crud/group.py index baafad55e..733100b56 100644 --- a/app/db/crud/group.py +++ b/app/db/crud/group.py @@ -220,7 +220,7 @@ async def modify_group(db: AsyncSession, db_group: Group, modified_group: GroupM Group: The updated Group object. """ - if modified_group.inbound_tags: + if modified_group.inbound_tags is not None: inbounds = await get_inbounds_by_tags(db, modified_group.inbound_tags) db_group.inbounds = inbounds if db_group.name != modified_group.name: diff --git a/app/db/crud/user.py b/app/db/crud/user.py index c2fb70bc7..aadef25bf 100644 --- a/app/db/crud/user.py +++ b/app/db/crud/user.py @@ -1023,6 +1023,9 @@ async def revoke_user_sub(db: AsyncSession, db_user: User) -> User: proxy_settings.shadowsocks.method = db_user.proxy_settings.get("shadowsocks", {}).get( "method", "chacha20-ietf-poly1305" ) + proxy_settings.wireguard.private_key = db_user.proxy_settings.get("wireguard", {}).get("private_key") + proxy_settings.wireguard.public_key = db_user.proxy_settings.get("wireguard", {}).get("public_key") + proxy_settings.wireguard.peer_ips = db_user.proxy_settings.get("wireguard", {}).get("peer_ips", []) or [] db_user.proxy_settings = proxy_settings.dict() await db.commit() await refresh_and_load_user(db, db_user) diff --git a/app/db/migrations/versions/6d0f9e5f2b87_add_backend_type_to_core_configs.py b/app/db/migrations/versions/6d0f9e5f2b87_add_backend_type_to_core_configs.py new file mode 100644 index 000000000..8b55a4ef5 --- /dev/null +++ b/app/db/migrations/versions/6d0f9e5f2b87_add_backend_type_to_core_configs.py @@ -0,0 +1,29 @@ +"""add backend type to core configs + +Revision ID: 6d0f9e5f2b87 +Revises: 145c22ab174f +Create Date: 2026-04-02 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "6d0f9e5f2b87" +down_revision = "145c22ab174f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "core_configs", + sa.Column("backend_type", sa.String(length=32), nullable=False, server_default="xray"), + ) + op.execute("UPDATE core_configs SET backend_type = 'xray' WHERE backend_type IS NULL") + + +def downgrade() -> None: + op.drop_column("core_configs", "backend_type") diff --git a/app/db/models.py b/app/db/models.py index 1deecd020..8fd8e64a1 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -718,6 +718,7 @@ class CoreConfig(Base): created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False) name: Mapped[str] = mapped_column(String(256)) config: Mapped[Dict[str, Any]] = mapped_column(JSON(False)) + backend_type: Mapped[str] = mapped_column(String(32), default="xray", server_default="xray") exclude_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set) fallbacks_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set) diff --git a/app/models/core.py b/app/models/core.py index 267fc08b0..af4504d69 100644 --- a/app/models/core.py +++ b/app/models/core.py @@ -1,4 +1,5 @@ from datetime import datetime as dt +from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -6,9 +7,15 @@ from .validators import StringArrayValidator +class CoreType(StrEnum): + XRAY = "xray" + WIREGUARD = "wireguard" + + class CoreBase(BaseModel): name: str config: dict + backend_type: CoreType = Field(default=CoreType.XRAY) exclude_inbound_tags: set[str] fallbacks_inbound_tags: set[str] @@ -27,6 +34,7 @@ def fallback_tags(self) -> str: class CoreCreate(CoreBase): name: str | None = Field(max_length=256, default=None) + backend_type: CoreType = Field(default=CoreType.XRAY) exclude_inbound_tags: set | None = Field(default=None) fallbacks_inbound_tags: set | None = Field(default=None) @@ -67,6 +75,7 @@ class CoreSimple(BaseModel): id: int name: str + backend_type: CoreType = Field(default=CoreType.XRAY) model_config = ConfigDict(from_attributes=True) diff --git a/app/models/node.py b/app/models/node.py index 69f4658a8..56c62d896 100644 --- a/app/models/node.py +++ b/app/models/node.py @@ -4,7 +4,7 @@ from uuid import UUID from cryptography.x509 import load_pem_x509_certificate -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator from app.db.models import DataLimitResetStrategy, NodeConnectionType, NodeStatus @@ -206,6 +206,11 @@ class NodeResponse(Node): model_config = ConfigDict(from_attributes=True) + @computed_field + @property + def core_version(self) -> str | None: + return self.xray_version + class NodesResponse(BaseModel): nodes: list[NodeResponse] @@ -239,6 +244,11 @@ class NodeNotification(BaseModel): model_config = ConfigDict(from_attributes=True) + @computed_field + @property + def core_version(self) -> str | None: + return self.xray_version + class UserIPList(BaseModel): """User IP list - mapping of IP addresses to connection counts""" diff --git a/app/models/proxy.py b/app/models/proxy.py index 83176e1da..82b2201b0 100644 --- a/app/models/proxy.py +++ b/app/models/proxy.py @@ -1,9 +1,11 @@ import json from enum import StrEnum +from ipaddress import ip_network from uuid import UUID, uuid4 -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, model_validator +from app.utils.crypto import get_wireguard_public_key, validate_wireguard_key from app.utils.system import random_password @@ -41,11 +43,56 @@ class HysteriaSettings(BaseModel): auth: UUID = Field(default_factory=uuid4) +class WireGuardSettings(BaseModel): + private_key: str | None = None + public_key: str | None = None + peer_ips: list[str] = Field(default_factory=list) + + @field_validator("private_key", mode="before") + @classmethod + def validate_private_key(cls, value): + if value in (None, ""): + return None + return validate_wireguard_key(value, "wireguard private_key") + + @field_validator("public_key", mode="before") + @classmethod + def validate_public_key(cls, value): + if value in (None, ""): + return None + return validate_wireguard_key(value, "wireguard public_key") + + @field_validator("peer_ips", mode="before") + @classmethod + def validate_peer_ips(cls, value): + if value in (None, ""): + return [] + + normalized: list[str] = [] + for peer_ip in value: + if not isinstance(peer_ip, str) or not peer_ip.strip(): + continue + normalized_peer_ip = str(ip_network(peer_ip.strip(), strict=False)) + if normalized_peer_ip not in normalized: + normalized.append(normalized_peer_ip) + return normalized + + @model_validator(mode="after") + def derive_public_key(self): + if self.private_key: + derived_public_key = get_wireguard_public_key(self.private_key) + if self.public_key and self.public_key != derived_public_key: + raise ValueError("wireguard public_key does not match private_key") + self.public_key = derived_public_key + return self + + class ProxyTable(BaseModel): vmess: VMessSettings = Field(default_factory=VMessSettings) vless: VlessSettings = Field(default_factory=VlessSettings) trojan: TrojanSettings = Field(default_factory=TrojanSettings) shadowsocks: ShadowsocksSettings = Field(default_factory=ShadowsocksSettings) + wireguard: WireGuardSettings = Field(default_factory=WireGuardSettings) hysteria: HysteriaSettings = Field(default_factory=HysteriaSettings) def dict(self, *, no_obj=True, **kwargs): diff --git a/app/node/user.py b/app/node/user.py index 3a5fbce73..fbf08e959 100644 --- a/app/node/user.py +++ b/app/node/user.py @@ -46,6 +46,7 @@ def _serialize_user_for_node(id: int, username: str, user_settings: dict, inboun vless_settings = user_settings.get("vless", {}) trojan_settings = user_settings.get("trojan", {}) shadowsocks_settings = user_settings.get("shadowsocks", {}) + wireguard_settings = user_settings.get("wireguard", {}) hysteria_settings = user_settings.get("hysteria", {}) return create_user( @@ -57,6 +58,8 @@ def _serialize_user_for_node(id: int, username: str, user_settings: dict, inboun trojan_password=trojan_settings.get("password"), shadowsocks_password=shadowsocks_settings.get("password"), shadowsocks_method=shadowsocks_settings.get("method"), + wireguard_public_key=wireguard_settings.get("public_key"), + wireguard_peer_ips=wireguard_settings.get("peer_ips") or [], hysteria_auth=hysteria_settings.get("auth"), ), inbounds, diff --git a/app/notification/discord/node.py b/app/notification/discord/node.py index 335dbf965..e685fea6f 100644 --- a/app/notification/discord/node.py +++ b/app/notification/discord/node.py @@ -65,7 +65,7 @@ async def connect_node(node: NodeNotification): name = escape_ds_markdown(node.name) message = copy.deepcopy(messages.CONNECT_NODE) message["description"] = message["description"].format( - name=name, node_version=node.node_version, core_version=node.xray_version + name=name, node_version=node.node_version, core_version=node.core_version ) message["footer"]["text"] = message["footer"]["text"].format(id=node.id) data = { diff --git a/app/notification/telegram/node.py b/app/notification/telegram/node.py index e247bff6a..417de5dc4 100644 --- a/app/notification/telegram/node.py +++ b/app/notification/telegram/node.py @@ -41,7 +41,7 @@ async def remove_node(node: NodeResponse, by: str): async def connect_node(node: NodeNotification): data = messages.CONNECT_NODE.format( - name=escape(node.name), node_version=node.node_version, core_version=node.xray_version, id=node.id + name=escape(node.name), node_version=node.node_version, core_version=node.core_version, id=node.id ) settings: NotificationSettings = await notification_settings() if settings.notify_telegram: diff --git a/app/operation/__init__.py b/app/operation/__init__.py index 6fcb2e234..84056b88f 100644 --- a/app/operation/__init__.py +++ b/app/operation/__init__.py @@ -24,6 +24,7 @@ from app.models.user import UserCreate, UserModify from app.utils.helpers import ensure_datetime_timezone from app.utils.jwt import get_subscription_payload +from app.wireguard import ensure_single_wireguard_interface_for_groups class OperatorType(IntEnum): @@ -199,6 +200,12 @@ async def validate_all_groups(self, db, model: UserCreate | UserModify | UserTem if missing_ids: await self.raise_error("Group not found", 404) + if not isinstance(model, BulkGroup): + try: + await ensure_single_wireguard_interface_for_groups(groups, context="user") + except ValueError as exc: + await self.raise_error(str(exc), 400) + # Preserve the requested order and duplicate semantics. return [groups_by_id[group_id] for group_id in requested_group_ids] @@ -220,6 +227,13 @@ async def check_inbound_tags(self, tags: list[str]) -> None: if tag not in await core_manager.get_inbounds(): await self.raise_error(f"{tag} not found", 400) + async def check_host_inbound_tags(self, tags: list[str]) -> None: + await self.check_inbound_tags(tags) + for tag in tags: + inbound = await core_manager.get_inbound_by_tag(tag) + if inbound and inbound.get("protocol") == "wireguard": + await self.raise_error("Hosts are not supported for WireGuard interfaces", 400) + async def get_validated_core_config(self, db: AsyncSession, core_id) -> CoreConfig: """Dependency: Fetch core config or return not found error.""" db_core_config = await get_core_config_by_id(db, core_id) diff --git a/app/operation/core.py b/app/operation/core.py index e4fff7f32..091839911 100644 --- a/app/operation/core.py +++ b/app/operation/core.py @@ -24,7 +24,12 @@ class CoreOperation(BaseOperation): async def create_core(self, db: AsyncSession, new_core: CoreCreate, admin: AdminDetails) -> CoreResponse: try: - core_manager.validate_core(new_core.config, new_core.exclude_inbound_tags, new_core.fallbacks_inbound_tags) + core_manager.validate_core( + new_core.config, + new_core.exclude_inbound_tags, + new_core.fallbacks_inbound_tags, + new_core.backend_type, + ) db_core = await create_core_config(db, new_core) except Exception as e: await self.raise_error(message=e, code=400, db=db) @@ -82,7 +87,10 @@ async def modify_core( db_core = await self.get_validated_core_config(db, core_id) try: core_manager.validate_core( - modified_core.config, modified_core.exclude_inbound_tags, modified_core.fallbacks_inbound_tags + modified_core.config, + modified_core.exclude_inbound_tags, + modified_core.fallbacks_inbound_tags, + modified_core.backend_type, ) db_core = await modify_core_config(db, db_core, modified_core) except Exception as e: diff --git a/app/operation/group.py b/app/operation/group.py index 593a279e6..f034ff2f3 100644 --- a/app/operation/group.py +++ b/app/operation/group.py @@ -1,18 +1,23 @@ import asyncio +from types import SimpleNamespace + +from sqlalchemy import select +from sqlalchemy.orm import selectinload from app import notification from app.db import AsyncSession -from app.db.crud.bulk import add_groups_to_users, remove_groups_from_users +from app.db.crud.bulk import _create_group_filter, add_groups_to_users, remove_groups_from_users from app.db.crud.group import ( create_group, get_group, + get_groups_by_ids, get_groups_simple, modify_group, remove_group, GroupsSortingOptionsSimple, ) from app.db.crud.user import get_users -from app.db.models import Admin, UserStatus +from app.db.models import Admin, Group as DBGroup, User, UserStatus, users_groups_association from app.models.group import ( BulkGroup, Group, @@ -26,13 +31,70 @@ from app.node.sync import sync_users from app.operation import BaseOperation, OperatorType from app.utils.logger import get_logger +from app.wireguard import ensure_single_wireguard_interface_for_groups, ensure_single_wireguard_interface_for_tags logger = get_logger("group-operation") class GroupOperation(BaseOperation): + async def _validate_group_inbound_tags(self, inbound_tags: list[str]) -> None: + try: + await ensure_single_wireguard_interface_for_tags(inbound_tags, context="group") + except ValueError as exc: + await self.raise_error(str(exc), 400) + + async def _get_group_members_with_groups(self, db: AsyncSession, group_id: int) -> list[User]: + stmt = ( + select(User) + .join(users_groups_association, User.id == users_groups_association.c.user_id) + .where(users_groups_association.c.groups_id == group_id) + .options(selectinload(User.groups).selectinload(DBGroup.inbounds)) + ) + return list((await db.execute(stmt)).unique().scalars().all()) + + async def _validate_group_update_user_assignments( + self, + db: AsyncSession, + db_group: DBGroup, + modified_group: GroupModify, + ) -> None: + final_is_disabled = modified_group.is_disabled if modified_group.is_disabled is not None else db_group.is_disabled + final_inbound_tags = modified_group.inbound_tags if modified_group.inbound_tags is not None else db_group.inbound_tags + final_group = SimpleNamespace( + is_disabled=final_is_disabled, + inbounds=[SimpleNamespace(tag=tag) for tag in final_inbound_tags], + ) + + members = await self._get_group_members_with_groups(db, db_group.id) + for member in members: + effective_groups = [group for group in member.groups if group.id != db_group.id] + effective_groups.append(final_group) + try: + await ensure_single_wireguard_interface_for_groups(effective_groups, context="user") + except ValueError as exc: + await self.raise_error(str(exc), 400) + + async def _validate_bulk_group_addition(self, db: AsyncSession, bulk_model: BulkGroup) -> None: + groups_to_add = await get_groups_by_ids(db, list(bulk_model.group_ids), load_users=False, load_inbounds=True) + groups_by_id = {group.id: group for group in groups_to_add} + missing_group_ids = [group_id for group_id in bulk_model.group_ids if group_id not in groups_by_id] + if missing_group_ids: + await self.raise_error("Group not found", 404) + + final_filter = _create_group_filter(bulk_model) + stmt = select(User).where(final_filter).options(selectinload(User.groups).selectinload(DBGroup.inbounds)) + target_users = list((await db.execute(stmt)).unique().scalars().all()) + + for user in target_users: + final_groups = list(dict.fromkeys([*user.groups, *groups_to_add])) + try: + await ensure_single_wireguard_interface_for_groups(final_groups, context="user") + except ValueError as exc: + await self.raise_error(str(exc), 400) + async def create_group(self, db: AsyncSession, new_group: GroupCreate, admin: Admin) -> Group: await self.check_inbound_tags(new_group.inbound_tags) + await self._validate_group_inbound_tags(new_group.inbound_tags) db_group = await create_group(db, new_group) @@ -86,8 +148,11 @@ async def get_groups_simple( async def modify_group(self, db: AsyncSession, group_id: int, modified_group: GroupModify, admin: Admin) -> Group: db_group = await self.get_validated_group(db, group_id) - if modified_group.inbound_tags: + if modified_group.inbound_tags is not None: await self.check_inbound_tags(modified_group.inbound_tags) + await self._validate_group_inbound_tags(modified_group.inbound_tags) + if modified_group.inbound_tags is not None or modified_group.is_disabled is not None: + await self._validate_group_update_user_assignments(db, db_group, modified_group) db_group = await modify_group(db, db_group, modified_group) users = await get_users(db, group_ids=[db_group.id], status=[UserStatus.active, UserStatus.on_hold]) @@ -117,6 +182,7 @@ async def remove_group(self, db: AsyncSession, group_id: int, admin: Admin) -> N async def bulk_add_groups(self, db: AsyncSession, bulk_model: BulkGroup): await self.validate_all_groups(db, bulk_model) + await self._validate_bulk_group_addition(db, bulk_model) users, users_count = await add_groups_to_users(db, bulk_model) await sync_users(users) diff --git a/app/operation/host.py b/app/operation/host.py index 51d4fbe40..aab8bf3f5 100644 --- a/app/operation/host.py +++ b/app/operation/host.py @@ -46,7 +46,7 @@ async def validate_ds_host(self, db: AsyncSession, host: CreateHost, host_id: in async def create_host(self, db: AsyncSession, new_host: CreateHost, admin: AdminDetails) -> BaseHost: await self.validate_ds_host(db, new_host) - await self.check_inbound_tags([new_host.inbound_tag]) + await self.check_host_inbound_tags([new_host.inbound_tag]) db_host = await create_host(db, new_host) @@ -65,7 +65,7 @@ async def modify_host( await self.validate_ds_host(db, modified_host, host_id) if modified_host.inbound_tag: - await self.check_inbound_tags([modified_host.inbound_tag]) + await self.check_host_inbound_tags([modified_host.inbound_tag]) db_host = await self.get_validated_host(db, host_id) diff --git a/app/operation/node.py b/app/operation/node.py index 7dad5a8cd..902ec3ca6 100644 --- a/app/operation/node.py +++ b/app/operation/node.py @@ -3,6 +3,7 @@ from typing import AsyncIterator, Callable from PasarGuardNodeBridge import NodeAPIError, PasarGuardNode +from PasarGuardNodeBridge.common import service_pb2 as service from sqlalchemy.exc import IntegrityError from app import notification @@ -25,6 +26,7 @@ ) from app.db.crud.user import get_user, get_users_count_by_status from app.db.models import Node, NodeStatus, UserStatus +from app.models.core import CoreType from app.models.admin import AdminDetails from app.models.node import ( NodeCoreUpdate, @@ -230,16 +232,22 @@ async def connect_node(db_node: Node, users: list) -> dict | None: logger.info(f'Connecting to "{db_node.name}" node') core = await core_manager.get_core(db_node.core_config_id if db_node.core_config_id else 1) + backend_type = ( + service.BackendType.WIREGUARD if core.backend_type == CoreType.WIREGUARD else service.BackendType.XRAY + ) try: - info = await pg_node.start( - config=core.to_str(), - backend_type=0, - users=users, - keep_alive=db_node.keep_alive, - exclude_inbounds=core.exclude_inbound_tags, - ) - logger.info(f'Connected to "{db_node.name}" node v{info.node_version}, xray run on v{info.core_version}') + start_kwargs = { + "config": core.to_str(), + "backend_type": backend_type, + "users": users, + "keep_alive": db_node.keep_alive, + } + if core.backend_type == CoreType.XRAY: + start_kwargs["exclude_inbounds"] = core.exclude_inbound_tags + + info = await pg_node.start(**start_kwargs) + logger.info(f'Connected to "{db_node.name}" node v{info.node_version}, core run on v{info.core_version}') return { "node_id": db_node.id, diff --git a/app/operation/user.py b/app/operation/user.py index 9b0010662..3c0ba81f6 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -41,6 +41,7 @@ ) from app.db.models import User, UserStatus, UserTemplate from app.models.admin import AdminDetails +from app.models.proxy import ProxyTable from app.models.stats import Period, UserUsageStatsList from app.models.user import ( BulkUser, @@ -67,6 +68,7 @@ from app.settings import subscription_settings from app.utils.jwt import create_subscription_token from app.utils.logger import get_logger +from app.wireguard import prepare_wireguard_proxy_settings from config import SUBSCRIPTION_PATH logger = get_logger("user-operation") @@ -206,6 +208,13 @@ async def _persist_bulk_users( if not users_to_create: return [] + for user_to_create in users_to_create: + user_to_create.proxy_settings = await self._prepare_user_proxy_settings( + db, + groups, + user_to_create.proxy_settings, + ) + db_users = await create_users_bulk(db, users_to_create, groups, db_admin) subscription_urls: list[str] = [] @@ -232,12 +241,31 @@ async def update_user(self, db_user: User, include_subscription_url: bool = True ) return user + async def _prepare_user_proxy_settings( + self, + db: AsyncSession, + groups: list, + proxy_settings: ProxyTable, + *, + exclude_user_id: int | None = None, + ) -> ProxyTable: + try: + return await prepare_wireguard_proxy_settings( + db, + proxy_settings, + groups, + exclude_user_id=exclude_user_id, + ) + except ValueError as exc: + await self.raise_error(message=str(exc), code=400, db=db) + async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: AdminDetails) -> UserResponse: if new_user.next_plan is not None and new_user.next_plan.user_template_id is not None: await self.get_validated_user_template(db, new_user.next_plan.user_template_id) all_groups = await self.validate_all_groups(db, new_user) db_admin = await get_admin(db, admin.username, load_users=False, load_usage_logs=False) + new_user.proxy_settings = await self._prepare_user_proxy_settings(db, all_groups, new_user.proxy_settings) try: db_user = await create_user(db, new_user, all_groups, db_admin) @@ -264,6 +292,26 @@ async def _modify_user( old_status = db_user.status + effective_groups = validated_groups if validated_groups is not None else db_user.groups + current_proxy_settings = ProxyTable.model_validate(db_user.proxy_settings) + current_proxy_settings_data = current_proxy_settings.dict() + proxy_settings_to_prepare = ( + ProxyTable.model_validate(modified_user.proxy_settings.dict()) + if modified_user.proxy_settings is not None + else ProxyTable.model_validate(current_proxy_settings_data) + ) + prepared_proxy_settings = await self._prepare_user_proxy_settings( + db, + effective_groups, + proxy_settings_to_prepare, + exclude_user_id=db_user.id, + ) + if ( + modified_user.proxy_settings is not None + or prepared_proxy_settings.dict() != current_proxy_settings_data + ): + modified_user.proxy_settings = prepared_proxy_settings + db_user = await modify_user(db, db_user, modified_user, groups=validated_groups) user = await self.update_user(db_user) diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 26ea1a935..2d1c014fa 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -61,3 +61,43 @@ def get_x25519_public_key(private_key_b64: str) -> str: except (ValueError, binascii.Error): raise ValueError("Invalid private key.") + + +def validate_wireguard_key(key_b64: str, field_name: str = "wireguard key") -> str: + try: + key_bytes = base64.b64decode(add_base64_padding(key_b64.strip()), validate=True) + except (ValueError, binascii.Error) as exc: + raise ValueError(f"Invalid {field_name}.") from exc + + if len(key_bytes) != 32: + raise ValueError(f"Invalid {field_name}.") + + return base64.b64encode(key_bytes).decode("ascii") + + +def get_wireguard_public_key(private_key_b64: str) -> str: + normalized_private_key = validate_wireguard_key(private_key_b64, "wireguard private_key") + private_key_bytes = base64.b64decode(normalized_private_key, validate=True) + private_key = x25519.X25519PrivateKey.from_private_bytes(private_key_bytes) + public_key_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return base64.b64encode(public_key_bytes).decode("ascii") + + +def generate_wireguard_keypair() -> tuple[str, str]: + private_key = x25519.X25519PrivateKey.generate() + private_key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + public_key_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return ( + base64.b64encode(private_key_bytes).decode("ascii"), + base64.b64encode(public_key_bytes).decode("ascii"), + ) diff --git a/app/wireguard.py b/app/wireguard.py new file mode 100644 index 000000000..1f0e174c3 --- /dev/null +++ b/app/wireguard.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from ipaddress import IPv4Network, IPv6Network, ip_address, ip_interface, ip_network +from typing import Iterable + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.manager import core_manager +from app.db.models import Group, ProxyInbound, User, inbounds_groups_association, users_groups_association +from app.models.proxy import ProxyTable +from app.utils.crypto import generate_wireguard_keypair, get_wireguard_public_key + + +def _unique_preserve_order(values: Iterable[str]) -> list[str]: + unique_values: list[str] = [] + seen: set[str] = set() + for value in values: + if value in seen: + continue + seen.add(value) + unique_values.append(value) + return unique_values + + +async def get_wireguard_tags(tags: Iterable[str]) -> list[str]: + inbounds_by_tag = await core_manager.get_inbounds_by_tag() + return _unique_preserve_order( + tag for tag in tags if inbounds_by_tag.get(tag, {}).get("protocol") == "wireguard" + ) + + +async def get_wireguard_tags_from_groups(groups: Iterable[Group]) -> list[str]: + tags: list[str] = [] + for group in groups: + if getattr(group, "is_disabled", False): + continue + tags.extend(inbound.tag for inbound in group.inbounds) + return await get_wireguard_tags(tags) + + +async def ensure_single_wireguard_interface_for_tags(tags: Iterable[str], *, context: str) -> str | None: + wireguard_tags = await get_wireguard_tags(tags) + if len(wireguard_tags) > 1: + raise ValueError(f"{context} cannot be assigned to more than one WireGuard interface") + return wireguard_tags[0] if wireguard_tags else None + + +async def ensure_single_wireguard_interface_for_groups(groups: Iterable[Group], *, context: str) -> str | None: + wireguard_tags = await get_wireguard_tags_from_groups(groups) + if len(wireguard_tags) > 1: + raise ValueError(f"{context} cannot be assigned to more than one WireGuard interface") + return wireguard_tags[0] if wireguard_tags else None + + +def _networks_overlap(left: IPv4Network | IPv6Network, right: IPv4Network | IPv6Network) -> bool: + return left.version == right.version and left.overlaps(right) + + +async def _get_wireguard_inbound(interface_tag: str) -> dict: + inbound = await core_manager.get_inbound_by_tag(interface_tag) + if not inbound or inbound.get("protocol") != "wireguard": + raise ValueError(f"WireGuard interface '{interface_tag}' not found") + return inbound + + +def _get_interface_addresses(inbound: dict) -> list: + addresses = inbound.get("address") or [] + if not addresses: + raise ValueError(f"WireGuard interface '{inbound.get('tag', '')}' does not define any address ranges") + return [ip_interface(address) for address in addresses] + + +async def _get_existing_wireguard_peer_networks( + db: AsyncSession, + interface_tag: str, + *, + exclude_user_id: int | None = None, +) -> list[IPv4Network | IPv6Network]: + stmt = ( + select(User.id, User.proxy_settings) + .select_from(User) + .join(users_groups_association, User.id == users_groups_association.c.user_id) + .join(Group, users_groups_association.c.groups_id == Group.id) + .join(inbounds_groups_association, Group.id == inbounds_groups_association.c.group_id) + .join(ProxyInbound, inbounds_groups_association.c.inbound_id == ProxyInbound.id) + .where(and_(Group.is_disabled.is_(False), ProxyInbound.tag == interface_tag)) + ) + if exclude_user_id is not None: + stmt = stmt.where(User.id != exclude_user_id) + + rows = (await db.execute(stmt)).all() + networks: list[IPv4Network | IPv6Network] = [] + seen_user_ids: set[int] = set() + for user_id, proxy_settings in rows: + if user_id in seen_user_ids: + continue + seen_user_ids.add(user_id) + for peer_ip in proxy_settings.get("wireguard", {}).get("peer_ips", []) or []: + networks.append(ip_network(peer_ip, strict=False)) + return networks + + +async def validate_wireguard_peer_ips( + db: AsyncSession, + interface_tag: str, + peer_ips: list[str], + *, + exclude_user_id: int | None = None, +) -> None: + existing_networks = await _get_existing_wireguard_peer_networks( + db, + interface_tag, + exclude_user_id=exclude_user_id, + ) + inbound = await _get_wireguard_inbound(interface_tag) + interface_addresses = _get_interface_addresses(inbound) + validated_networks: list[IPv4Network | IPv6Network] = [] + for peer_ip in peer_ips: + candidate = ip_network(peer_ip, strict=False) + if not any(candidate.version == interface.ip.version and candidate.subnet_of(interface.network) for interface in interface_addresses): + raise ValueError(f"wireguard peer IP '{peer_ip}' is outside interface '{interface_tag}' address ranges") + if any(candidate.version == interface.ip.version and interface.ip in candidate for interface in interface_addresses): + raise ValueError(f"wireguard peer IP '{peer_ip}' overlaps the server address on interface '{interface_tag}'") + if any(_networks_overlap(candidate, validated) for validated in validated_networks): + raise ValueError(f"wireguard peer IP '{peer_ip}' overlaps another peer IP in the same user") + if any(_networks_overlap(candidate, existing) for existing in existing_networks): + raise ValueError(f"wireguard peer IP '{peer_ip}' is already in use on interface '{interface_tag}'") + validated_networks.append(candidate) + + +def _allocate_from_interface( + interface_cidr: str, + used_networks: list[IPv4Network | IPv6Network], +) -> str | None: + interface = ip_interface(interface_cidr) + network = interface.network + server_ip = interface.ip + + start = int(network.network_address) + end = int(network.broadcast_address) + for raw_candidate in range(start, end + 1): + candidate = ip_address(raw_candidate) + + if candidate.version == 4 and network.prefixlen < 31: + if candidate == network.network_address or candidate == network.broadcast_address: + continue + + if candidate == server_ip: + continue + + if any(candidate in existing_network for existing_network in used_networks if existing_network.version == candidate.version): + continue + + suffix = 32 if candidate.version == 4 else 128 + return f"{candidate}/{suffix}" + + return None + + +async def allocate_wireguard_peer_ips( + db: AsyncSession, + interface_tag: str, + *, + exclude_user_id: int | None = None, +) -> list[str]: + inbound = await _get_wireguard_inbound(interface_tag) + addresses = inbound.get("address") or [] + + used_networks = await _get_existing_wireguard_peer_networks( + db, + interface_tag, + exclude_user_id=exclude_user_id, + ) + + allocated_peer_ips: list[str] = [] + for address in addresses: + allocated = _allocate_from_interface(address, used_networks) + if not allocated: + raise ValueError(f"unable to allocate WireGuard peer IP for interface '{interface_tag}'") + + allocated_network = ip_network(allocated, strict=False) + used_networks.append(allocated_network) + allocated_peer_ips.append(str(allocated_network)) + + return allocated_peer_ips + + +async def prepare_wireguard_proxy_settings( + db: AsyncSession, + proxy_settings: ProxyTable, + groups: Iterable[Group], + *, + exclude_user_id: int | None = None, +) -> ProxyTable: + interface_tag = await ensure_single_wireguard_interface_for_groups(groups, context="user") + if not interface_tag: + return proxy_settings + + if proxy_settings.wireguard.public_key and not proxy_settings.wireguard.private_key: + raise ValueError("wireguard private_key is required when user is assigned to a WireGuard interface") + + if not proxy_settings.wireguard.private_key and not proxy_settings.wireguard.public_key: + private_key, public_key = generate_wireguard_keypair() + proxy_settings.wireguard.private_key = private_key + proxy_settings.wireguard.public_key = public_key + elif proxy_settings.wireguard.private_key and not proxy_settings.wireguard.public_key: + proxy_settings.wireguard.public_key = get_wireguard_public_key(proxy_settings.wireguard.private_key) + + if proxy_settings.wireguard.peer_ips: + await validate_wireguard_peer_ips( + db, + interface_tag, + proxy_settings.wireguard.peer_ips, + exclude_user_id=exclude_user_id, + ) + else: + proxy_settings.wireguard.peer_ips = await allocate_wireguard_peer_ips( + db, + interface_tag, + exclude_user_id=exclude_user_id, + ) + + return proxy_settings diff --git a/dashboard/src/components/cores/cores-list.tsx b/dashboard/src/components/cores/cores-list.tsx index eb8b92a15..003b93b27 100644 --- a/dashboard/src/components/cores/cores-list.tsx +++ b/dashboard/src/components/cores/cores-list.tsx @@ -56,7 +56,15 @@ export default function Cores({ isDialogOpen, onOpenChange, cores, onEditCore, o setEditingCore(core) form.reset({ name: core.name, + backend_type: core.backend_type || 'xray', config: JSON.stringify(core.config, null, 2), + fallback_id: core.fallbacks_inbound_tags + ? core.fallbacks_inbound_tags + .join(',') + .split(',') + .map((id: string) => id.trim()) + .filter((id: string) => id.trim() !== '') + : [], excluded_inbound_ids: core.exclude_inbound_tags ? core.exclude_inbound_tags .join(',') diff --git a/dashboard/src/components/dialogs/core-config-modal.tsx b/dashboard/src/components/dialogs/core-config-modal.tsx index 294a6239f..a4b84f583 100644 --- a/dashboard/src/components/dialogs/core-config-modal.tsx +++ b/dashboard/src/components/dialogs/core-config-modal.tsx @@ -16,6 +16,7 @@ import { useCreateCoreConfig, useModifyCoreConfig } from '@/service/api' import { isEmptyObject } from '@/utils/isEmptyObject.ts' import { generateMldsa65 } from '@/utils/mldsa65' import { queryClient } from '@/utils/query-client' +import { generateWireGuardKeyPair } from '@/utils/wireguard' import { encodeURLSafe } from '@stablelib/base64' import { generateKeyPair } from '@stablelib/x25519' import { debounce } from 'es-toolkit' @@ -40,6 +41,7 @@ interface ValidationResult { isValid: boolean error?: string } +type CoreBackendType = 'xray' | 'wireguard' // Add encryption methods enum const SHADOWSOCKS_ENCRYPTION_METHODS = [ { value: '2022-blake3-aes-128-gcm', label: '2022-blake3-aes-128-gcm', length: 16 }, @@ -92,6 +94,61 @@ const createDefaultVlessOptions = (): VlessBuilderOptions => ({ includeClientPadding: false, }) +const defaultXrayConfig = JSON.stringify( + { + log: { + loglevel: 'info', + }, + inbounds: [ + { + tag: 'Shadowsocks TCP', + listen: '0.0.0.0', + port: 1080, + protocol: 'shadowsocks', + settings: { + clients: [], + network: 'tcp,udp', + }, + }, + ], + outbounds: [ + { + protocol: 'freedom', + tag: 'DIRECT', + }, + { + protocol: 'blackhole', + tag: 'BLOCK', + }, + ], + routing: { + rules: [ + { + ip: ['geoip:private'], + outboundTag: 'BLOCK', + type: 'field', + }, + ], + }, + }, + null, + 2, +) + +const defaultWireGuardConfig = JSON.stringify( + { + interface_name: 'wg0', + private_key: 'REPLACE_WITH_SERVER_PRIVATE_KEY', + listen_port: 51820, + address: ['10.8.0.1/24'], + peer_keepalive_seconds: 25, + }, + null, + 2, +) + +const getDefaultCoreConfigString = (backendType: CoreBackendType) => (backendType === 'wireguard' ? defaultWireGuardConfig : defaultXrayConfig) + const MonacoEditor = lazy(() => import('@monaco-editor/react')) const MobileJsonAceEditor = lazy(() => import('@/components/common/mobile-json-ace-editor')) @@ -100,6 +157,8 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit const dir = useDirDetection() const isMobile = useIsMobile() const { resolvedTheme } = useTheme() + const backendType = (form.watch('backend_type') ?? 'xray') as CoreBackendType + const isXrayBackend = backendType === 'xray' const [validation, setValidation] = useState({ isValid: true }) const [isEditorReady, setIsEditorReady] = useState(false) const createCoreMutation = useCreateCoreConfig() @@ -157,29 +216,26 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit [editorInstance], ) - const validateJsonContent = useCallback( - (value: string, showToast = false) => { - try { - JSON.parse(value) - setValidation({ isValid: true }) - return true - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Invalid JSON' - setValidation({ - isValid: false, - error: errorMessage, + const validateJsonContent = useCallback((value: string, showToast = false) => { + try { + JSON.parse(value) + setValidation({ isValid: true }) + return true + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid JSON' + setValidation({ + isValid: false, + error: errorMessage, + }) + if (showToast) { + toast.error(errorMessage, { + duration: 3000, + position: 'bottom-right', }) - if (showToast) { - toast.error(errorMessage, { - duration: 3000, - position: 'bottom-right', - }) - } - return false } - }, - [], - ) + return false + } + }, []) // Handle fullscreen toggle with editor resize const handleToggleFullscreen = useCallback(() => { @@ -229,7 +285,11 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit debounce((value: string) => { try { const parsedConfig = JSON.parse(value) - if (parsedConfig.inbounds && Array.isArray(parsedConfig.inbounds)) { + const selectedBackendType = (form.getValues('backend_type') ?? 'xray') as CoreBackendType + if (selectedBackendType === 'wireguard') { + const interfaceName = typeof parsedConfig.interface_name === 'string' ? parsedConfig.interface_name.trim() : '' + setInboundTags(interfaceName ? [interfaceName] : []) + } else if (parsedConfig.inbounds && Array.isArray(parsedConfig.inbounds)) { const tags = parsedConfig.inbounds.filter((inbound: any) => typeof inbound.tag === 'string' && inbound.tag.trim() !== '').map((inbound: any) => inbound.tag) setInboundTags(tags) } else { @@ -239,7 +299,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit setInboundTags([]) } }, 300), - [], + [form], ) // Extract inbound tags from config JSON whenever config changes @@ -248,7 +308,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit if (configValue) { debouncedConfigChange(configValue) } - }, [form.watch('config'), debouncedConfigChange]) + }, [form.watch('config'), backendType, debouncedConfigChange]) const handleEditorDidMount = useCallback((editor: any) => { setIsEditorReady(true) @@ -448,46 +508,24 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit } } - const defaultConfig = JSON.stringify( - { - log: { - loglevel: 'info', - }, - inbounds: [ - { - tag: 'Shadowsocks TCP', - listen: '0.0.0.0', - port: 1080, - protocol: 'shadowsocks', - settings: { - clients: [], - network: 'tcp,udp', - }, - }, - ], - outbounds: [ - { - protocol: 'freedom', - tag: 'DIRECT', - }, - { - protocol: 'blackhole', - tag: 'BLOCK', - }, - ], - routing: { - rules: [ - { - ip: ['geoip:private'], - outboundTag: 'BLOCK', - type: 'field', - }, - ], - }, - }, - null, - 2, - ) + const defaultConfig = JSON.stringify(JSON.parse(defaultXrayConfig), null, 2) + + const loadDefaultTemplate = useCallback(() => { + const defaultTemplate = getDefaultCoreConfigString(backendType) + form.setValue('config', defaultTemplate, { shouldDirty: true, shouldValidate: true }) + validateJsonContent(defaultTemplate) + debouncedConfigChange(defaultTemplate) + }, [backendType, debouncedConfigChange, form, validateJsonContent]) + + const generateWireGuardKeys = useCallback(() => { + try { + const keyPair = generateWireGuardKeyPair() + showResultDialog('wireguardKeyPair', keyPair) + toast.success(t('coreConfigModal.wireguardKeyPairGenerated', { defaultValue: 'WireGuard keypair generated' })) + } catch (error) { + toast.error(t('coreConfigModal.wireguardKeyPairGenerationFailed', { defaultValue: 'Failed to generate WireGuard keypair' })) + } + }, [showResultDialog, t]) const onSubmit = async (values: CoreConfigFormValues) => { try { @@ -505,8 +543,9 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit return } - const fallbackTags = values.fallback_id || [] - const excludeInboundTags = values.excluded_inbound_ids || [] + const backendType = values.backend_type ?? 'xray' + const fallbackTags = backendType === 'xray' ? values.fallback_id || [] : [] + const excludeInboundTags = backendType === 'xray' ? values.excluded_inbound_ids || [] : [] if (editingCore && editingCoreId) { // Update existing core @@ -514,6 +553,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit coreId: editingCoreId, data: { name: values.name, + backend_type: backendType, config: configObj, fallbacks_inbound_tags: fallbackTags, exclude_inbound_tags: excludeInboundTags, @@ -527,6 +567,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit await createCoreMutation.mutateAsync({ data: { name: values.name, + backend_type: backendType, config: configObj, fallbacks_inbound_tags: fallbackTags, exclude_inbound_tags: excludeInboundTags, @@ -556,7 +597,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit // Handle validation errors if (error?.response?._data && !isEmptyObject(error?.response?._data)) { // For zod validation errors - const fields = ['name', 'config', 'fallback_id', 'excluded_inbound_ids'] + const fields = ['name', 'backend_type', 'config', 'fallback_id', 'excluded_inbound_ids'] // Show first error in a toast if (error?.response?._data?.detail) { @@ -575,19 +616,19 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit typeof message === 'string' ? message : t('validation.invalid', { - field: t(`coreConfigModal.${field}`, { defaultValue: field }), - defaultValue: `${field} is invalid`, - }), + field: t(`coreConfigModal.${field}`, { defaultValue: field }), + defaultValue: `${field} is invalid`, + }), }) } }) toast.error( firstMessage || - t('validation.invalid', { - field: t(`coreConfigModal.${firstField}`, { defaultValue: firstField }), - defaultValue: `${firstField} is invalid`, - }), + t('validation.invalid', { + field: t(`coreConfigModal.${firstField}`, { defaultValue: firstField }), + defaultValue: `${firstField} is invalid`, + }), ) } else if (typeof detail === 'string' && !Array.isArray(detail)) { toast.error(detail) @@ -639,6 +680,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit // Reset form for new core form.reset({ name: '', + backend_type: 'xray', config: defaultConfig, excluded_inbound_ids: [], fallback_id: [], @@ -647,6 +689,9 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit } else { // Set restart_nodes to true for editing form.setValue('restart_nodes', true) + if (!form.getValues('backend_type')) { + form.setValue('backend_type', 'xray') + } } // Force editor resize on mobile after modal opens @@ -861,14 +906,14 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit const renderVlessAdvancedModal = () => { return ( - + {t('coreConfigModal.vlessAdvancedSettings', { defaultValue: 'VLESS Advanced Settings' })} -
+
{/* Variant Selector */}
@@ -1114,6 +1159,26 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit
) + case 'wireguardKeyPair': + return ( +
+ + +
+ ) + case 'shortId': return ( - + @@ -1275,15 +1343,15 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit style={ isEditorFullscreen ? { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - } + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + } : { - display: 'flex', - flexDirection: 'column', - } + display: 'flex', + flexDirection: 'column', + } } > {isEditorFullscreen &&
} @@ -1299,7 +1367,11 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit {/* Header - hidden on mobile, visible on desktop */}
- Xray Core Configuration + + {isXrayBackend + ? t('coreConfigModal.xrayConfigurationTitle', { defaultValue: 'Xray Core Configuration' }) + : t('coreConfigModal.wireguardConfigurationTitle', { defaultValue: 'WireGuard Core Configuration' })} +
- - )) - ) : ( - {t('coreConfigModal.selectFallback')} - )} -
+ {t('coreConfigModal.backendType', { defaultValue: 'Backend type' })} + - {field.value && field.value.length > 0 && ( - - )} -
+ )} /> - {/* Form: Excluded inbound selectors */} - ( - - {t('coreConfigModal.excludedInbound')} -
-
- {field.value && field.value.length > 0 ? ( - field.value.map((tag: string) => ( - - {tag} - - - )) - ) : ( - {t('coreConfigModal.selectInbound')} - )} -
- { + if (!value || value.trim() === '') return + const currentValue = field.value || [] + if (!currentValue.includes(value)) { + field.onChange([...currentValue, value]) + } + }} + > + + + + + + + {inboundTags.length > 0 ? ( + inboundTags.map(tag => ( + + {tag} + + )) + ) : ( + + {t('coreConfigModal.noInboundsFound')} + + )} + + + {field.value && field.value.length > 0 && ( + )} - - - {field.value && field.value.length > 0 && ( - - )} -
- -
- )} - /> +
+ + + )} + /> + + {/* Form: Excluded inbound selectors */} + ( + + {t('coreConfigModal.excludedInbound')} +
+
+ {field.value && field.value.length > 0 ? ( + field.value.map((tag: string) => ( + + {tag} + + + )) + ) : ( + {t('coreConfigModal.selectInbound')} + )} +
+ + {field.value && field.value.length > 0 && ( + + )} +
+ +
+ )} + /> + + + {/* Enhanced TabsList with Text Overflow */} + + + Reality + - - {/* Enhanced TabsList with Text Overflow */} - - - Reality - - - - ShadowSocks - - - - VLESS - - - - {/* ============================================ + + ShadowSocks + + + + VLESS + + + + {/* ============================================ Reality TAB ============================================ */} - - {/* Action Buttons */} -
- - - {generatedKeyPair && } - {t('coreConfigModal.generateKeyPair')} - - - - - - {generatedShortId && } - {t('coreConfigModal.generateShortId')} - - - - - - {generatedMldsa65 && } - {t('coreConfigModal.generateMldsa65')} - - -
-
+ + {/* Action Buttons */} +
+ + + {generatedKeyPair && } + {t('coreConfigModal.generateKeyPair')} + + + + + + {generatedShortId && } + {t('coreConfigModal.generateShortId')} + + + + + + {generatedMldsa65 && } + {t('coreConfigModal.generateMldsa65')} + + +
+
- {/* ============================================ + {/* ============================================ Shadowsocks TAB ============================================ */} - - {/* Encryption Method Selector */} -
- - -
+ + {/* Encryption Method Selector */} +
+ + +
- {/* Action Buttons */} - - - {generatedShadowsocksPassword && } - {t('coreConfigModal.generateShadowsocksPassword')} - - -
- - {/* ============================================ + {/* Action Buttons */} + + + {generatedShadowsocksPassword && } + {t('coreConfigModal.generateShadowsocksPassword')} + + +
+ + {/* ============================================ VLESS TAB ============================================ */} - - {/* VLESS Buttons */} - - - {generatedVLESS && } - {t('coreConfigModal.generateVLESSEncryption')} - - - -
+ + {/* VLESS Buttons */} + + + {generatedVLESS && } + {t('coreConfigModal.generateVLESSEncryption')} + + + + + + )} + {!isXrayBackend && ( +
+ {t('coreConfigModal.wireguardXrayFieldsDisabled', { + defaultValue: 'Fallback and excluded inbound selectors are Xray-only. For WireGuard, the interface_name in the JSON is the single assignable tag.', + })} +
+ )}
{/* Form: Restart nodes toggle */} {!isEditorFullscreen && ( -
+
{editingCore && ( ( - + diff --git a/dashboard/src/components/dialogs/node-modal.tsx b/dashboard/src/components/dialogs/node-modal.tsx index 473f0629f..4919ec943 100644 --- a/dashboard/src/components/dialogs/node-modal.tsx +++ b/dashboard/src/components/dialogs/node-modal.tsx @@ -36,11 +36,14 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod const addNodeMutation = useCreateNode() const modifyNodeMutation = useModifyNode() const handleError = useDynamicErrorHandler() - const { data: cores, isLoading: isLoadingCores } = useGetCoresSimple({ all: true }, { - query: { - enabled: isDialogOpen, + const { data: cores, isLoading: isLoadingCores } = useGetCoresSimple( + { all: true }, + { + query: { + enabled: isDialogOpen, + }, }, - }) + ) const [statusChecking, setStatusChecking] = useState(false) const [errorDetails, setErrorDetails] = useState(null) const [autoCheck, setAutoCheck] = useState(false) @@ -93,7 +96,7 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod lastSynced.id === node.id && lastSynced.status === node.status && lastSynced.message === node.message && - lastSynced.xray_version === node.xray_version && + (lastSynced.core_version ?? lastSynced.xray_version) === (node.core_version ?? node.xray_version) && lastSynced.node_version === node.node_version && lastSynced.uplink === node.uplink && lastSynced.downlink === node.downlink && @@ -386,9 +389,7 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod {editingNode ? t('editNode.title') : t('nodeModal.title')} - - {t('nodeModal.description', { defaultValue: 'Configure node settings and connection details.' })} - + {t('nodeModal.description', { defaultValue: 'Configure node settings and connection details.' })} {/* Status Check Results - Positioned at the top of the modal */} @@ -513,11 +514,7 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod render={({ field }) => ( {t('nodeModal.coreConfig')} - field.onChange(parseInt(value))} value={field.value ? field.value.toString() : t('nodeModal.selectCoreConfig')} disabled={isLoadingCores}> diff --git a/dashboard/src/components/dialogs/update-core-modal.tsx b/dashboard/src/components/dialogs/update-core-modal.tsx index 8f3704b35..4b01803a2 100644 --- a/dashboard/src/components/dialogs/update-core-modal.tsx +++ b/dashboard/src/components/dialogs/update-core-modal.tsx @@ -5,7 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { queryClient } from '@/utils/query-client' -import { useUpdateCore, NodeResponse } from '@/service/api' +import { useGetCoreConfig, useUpdateCore, NodeResponse } from '@/service/api' import { useXrayReleases } from '@/hooks/use-xray-releases' import { LoaderButton } from '../ui/loader-button' import { Cpu, ExternalLink } from 'lucide-react' @@ -30,31 +30,39 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC const [customVersionError, setCustomVersionError] = useState('') const updateCoreMutation = useUpdateCore() const { latestVersion, releaseUrl, versions, isLoading: isLoadingReleases, hasUpdate } = useXrayReleases() + const { data: coreConfig } = useGetCoreConfig(node.core_config_id || 0, { + query: { + enabled: isOpen && !!node.core_config_id, + }, + }) - const currentVersion = node.xray_version - const showUpdateBadge = currentVersion && latestVersion && hasUpdate(currentVersion) + const coreBackendType = coreConfig?.backend_type || 'xray' + const isXrayBackend = coreBackendType === 'xray' + const currentVersion = node.core_version ?? node.xray_version + const showUpdateBadge = !!(isXrayBackend && currentVersion && latestVersion && hasUpdate(currentVersion)) React.useEffect(() => { if (isOpen) { setSelectedVersion('latest') setCustomVersion('') - setVersionMode('list') + setVersionMode(isXrayBackend ? 'list' : 'custom') setCustomVersionError('') } - }, [isOpen]) + }, [isOpen, isXrayBackend]) const validateCustomVersion = (version: string): boolean => { if (!version.trim()) { setCustomVersionError(t('nodeModal.customVersionRequired', { defaultValue: 'Version is required' })) return false } - // Allow versions with or without 'v' prefix, and basic semantic versioning pattern + const versionPattern = /^v?\d+\.\d+\.\d+(-[\w.]+)?$/ const cleanVersion = version.trim() if (!versionPattern.test(cleanVersion)) { setCustomVersionError(t('nodeModal.invalidVersionFormat', { defaultValue: 'Invalid version format. Expected: vX.X.X or X.X.X' })) return false } + setCustomVersionError('') return true } @@ -70,7 +78,7 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC try { let versionToSend: string - if (versionMode === 'custom') { + if (!isXrayBackend || versionMode === 'custom') { if (!validateCustomVersion(customVersion)) { return } @@ -79,29 +87,29 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC versionToSend = selectedVersion if (selectedVersion === 'latest') { if (!latestVersion) { - toast.error(t('nodeModal.updateCoreFailed', { - message: 'Latest version not available', - defaultValue: 'Failed to update Xray core: Latest version not available', - })) + toast.error( + t('nodeModal.updateCoreFailed', { + message: 'Latest version not available', + defaultValue: 'Failed to update core: Latest version not available', + }), + ) return } - // Use actual latest version instead of 'latest' string versionToSend = latestVersion } } - - // Ensure version has 'v' prefix for backend pattern vX.X.X + if (!versionToSend.startsWith('v')) { versionToSend = `v${versionToSend}` } - + const response = await updateCoreMutation.mutateAsync({ nodeId: node.id, data: { core_version: versionToSend, }, }) - const message = (response as any)?.detail || t('nodeModal.updateCoreSuccess', { defaultValue: 'Xray core updated successfully' }) + const message = (response as any)?.detail || t('nodeModal.updateCoreSuccess', { defaultValue: 'Core updated successfully' }) toast.success(message) onOpenChange(false) queryClient.invalidateQueries({ queryKey: ['/api/nodes'] }) @@ -111,61 +119,76 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC toast.error( t('nodeModal.updateCoreFailed', { message: error?.message || 'Unknown error', - defaultValue: 'Failed to update Xray core: {message}', + defaultValue: 'Failed to update core: {message}', }), ) } } + const renderCustomVersionInput = () => ( +
+ + handleCustomVersionChange(e.target.value)} + onBlur={() => { + if (customVersion) { + validateCustomVersion(customVersion) + } + }} + error={customVersionError} + isError={!!customVersionError} + className="font-mono" + /> +

+ {t('nodeModal.versionHint', { defaultValue: 'Enter a version in the format vX.X.X or X.X.X (e.g., v1.8.0)' })} +

+
+ ) + return ( - {t('nodeModal.updateCoreTitle', { defaultValue: 'Update Xray Core' })} + {t('nodeModal.updateCoreTitle', { defaultValue: 'Update Core' })} {t('nodeModal.updateCoreDescription', { nodeName: node.name, - defaultValue: `Update Xray core for node «${node.name}»`, + defaultValue: `Update core for node «${node.name}»`, })}
- {/* Version Info Section */} -
+
{currentVersion && (
- - {t('version.currentVersion', { defaultValue: 'Current Version' })} - + {t('version.currentVersion', { defaultValue: 'Current Version' })}
{currentVersion} {showUpdateBadge && ( - + {t('nodeModal.updateAvailable', { defaultValue: 'Update Available' })} )}
)} - {latestVersion && ( -
- - {t('nodeModal.latest', { defaultValue: 'Latest' })} - + {isXrayBackend && latestVersion && ( +
+ {t('nodeModal.latest', { defaultValue: 'Latest' })}
{latestVersion} {releaseUrl && ( - e.stopPropagation()} - > + e.stopPropagation()}> )} @@ -174,112 +197,88 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC )}
- {/* Version Selection */}
- setVersionMode(value as 'list' | 'custom')} className="w-full"> - - - {t('nodeModal.selectFromList', { defaultValue: 'Select from List' })} - - - {t('nodeModal.customVersion', { defaultValue: 'Custom Version' })} - - + {isXrayBackend ? ( + setVersionMode(value as 'list' | 'custom')} className="w-full"> + + + {t('nodeModal.selectFromList', { defaultValue: 'Select from List' })} + + + {t('nodeModal.customVersion', { defaultValue: 'Custom Version' })} + + - - {isLoadingReleases ? ( -
-
- {t('nodeModal.loadingReleases', { defaultValue: 'Loading releases...' })} + + {isLoadingReleases ? ( +
+
{t('nodeModal.loadingReleases', { defaultValue: 'Loading releases...' })}
-
- ) : ( - -
- {latestVersion && ( - - )} - {versions - .filter(release => release.version !== latestVersion) - .slice(0, 10) - .map(release => ( + ) : ( + +
+ {latestVersion && ( - ))} -
-
- )} - + )} + {versions + .filter(release => release.version !== latestVersion) + .slice(0, 10) + .map(release => ( + + ))} +
+
+ )} + - -
- - handleCustomVersionChange(e.target.value)} - onBlur={() => { - if (customVersion) { - validateCustomVersion(customVersion) - } - }} - error={customVersionError} - isError={!!customVersionError} - className="font-mono" - /> -

- {t('nodeModal.versionHint', { defaultValue: 'Enter a version in the format vX.X.X or X.X.X (e.g., v1.8.0)' })} -

-
-
- + + {renderCustomVersionInput()} + + + ) : ( +
+

+ {t('nodeModal.wireguardCoreVersionHint', { defaultValue: 'This node uses a non-Xray backend, so the version must be entered manually.' })} +

+ {renderCustomVersionInput()} +
+ )}
@@ -292,9 +291,9 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC onClick={handleUpdate} disabled={ updateCoreMutation.isPending || - isLoadingReleases || - (versionMode === 'list' && !latestVersion) || - (versionMode === 'custom' && (!customVersion.trim() || !!customVersionError)) + (isXrayBackend && isLoadingReleases) || + (isXrayBackend && versionMode === 'list' && !latestVersion) || + ((!isXrayBackend || versionMode === 'custom') && (!customVersion.trim() || !!customVersionError)) } isLoading={updateCoreMutation.isPending} loadingText={t('nodeModal.updating', { defaultValue: 'Updating...' })} diff --git a/dashboard/src/components/dialogs/user-modal.tsx b/dashboard/src/components/dialogs/user-modal.tsx index 79326479e..176da4152 100644 --- a/dashboard/src/components/dialogs/user-modal.tsx +++ b/dashboard/src/components/dialogs/user-modal.tsx @@ -38,6 +38,7 @@ import { dateUtils, useRelativeExpiryDate } from '@/utils/dateFormatter' import { formatOffsetDateTime, parseDateInput, toDisplayDate, toUnixSeconds } from '@/utils/dateTimeParsing' import { formatBytes, gbToBytes } from '@/utils/formatByte' import { invalidateUserMetricsQueries, upsertUserInUsersCache } from '@/utils/usersCache' +import { generateWireGuardKeyPair } from '@/utils/wireguard' import { useQuery, useQueryClient } from '@tanstack/react-query' import { CalendarClock, CalendarPlus, ChevronDown, EllipsisVertical, Info, Layers, Link2Off, ListStart, Lock, Network, PieChart, RefreshCcw, User, Users } from 'lucide-react' import React, { useEffect, useState } from 'react' @@ -1021,29 +1022,11 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse status: values.status, } - // Check if proxy settings are filled - const hasProxySettings = values.proxy_settings && Object.values(values.proxy_settings).some(settings => settings && Object.values(settings).some(value => value !== undefined && value !== '')) - setLoading(true) // Clean proxy settings to ensure proper enum values - const cleanedProxySettings = hasProxySettings - ? { - ...values.proxy_settings, - vless: values.proxy_settings?.vless - ? { - ...values.proxy_settings.vless, - flow: values.proxy_settings.vless.flow || undefined, - } - : undefined, - shadowsocks: values.proxy_settings?.shadowsocks - ? { - ...values.proxy_settings.shadowsocks, - method: values.proxy_settings.shadowsocks.method || undefined, - } - : undefined, - } - : undefined + const cleanedProxySettings = cleanProxySettings(values.proxy_settings) + const hasProxySettings = !!cleanedProxySettings const normalizedDataLimitGb = Number(preparedValues.data_limit ?? 0) const hasDataLimit = Number.isFinite(normalizedDataLimitGb) && normalizedDataLimitGb > 0 @@ -1202,6 +1185,91 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse } } + const generateWireGuardProxySettings = React.useCallback(() => { + const keyPair = generateWireGuardKeyPair() + form.setValue('proxy_settings.wireguard.private_key', keyPair.privateKey, { shouldDirty: true, shouldValidate: true }) + form.setValue('proxy_settings.wireguard.public_key', keyPair.publicKey, { shouldDirty: true, shouldValidate: true }) + form.trigger(['proxy_settings.wireguard.private_key', 'proxy_settings.wireguard.public_key']) + handleFieldChange('proxy_settings.wireguard.private_key', keyPair.privateKey) + handleFieldChange('proxy_settings.wireguard.public_key', keyPair.publicKey) + toast.success(t('userDialog.proxySettings.wireguardGenerated', { defaultValue: 'WireGuard keypair generated' })) + }, [form, handleFieldChange, t]) + + const parseWireGuardPeerIps = React.useCallback((value: string) => { + return value + .split(/[\n,]+/) + .map(item => item.trim()) + .filter(Boolean) + }, []) + + const hasMeaningfulProxyValue = React.useCallback((value: unknown): boolean => { + if (Array.isArray(value)) { + return value.some(item => hasMeaningfulProxyValue(item)) + } + if (value && typeof value === 'object') { + return Object.values(value).some(item => hasMeaningfulProxyValue(item)) + } + return value !== undefined && value !== null && value !== '' + }, []) + + const cleanProxySettings = React.useCallback( + (proxySettings: any) => { + if (!proxySettings) return undefined + + const cleanedSettings = Object.entries(proxySettings).reduce( + (acc, [protocol, settings]) => { + if (!settings || typeof settings !== 'object') { + return acc + } + + const cleanedProtocolSettings = Object.entries(settings as Record).reduce( + (protocolAcc, [key, value]) => { + if (Array.isArray(value)) { + const cleanedList = value.map(item => (typeof item === 'string' ? item.trim() : item)).filter(item => hasMeaningfulProxyValue(item)) + + if (cleanedList.length > 0) { + protocolAcc[key] = cleanedList + } + return protocolAcc + } + + if (typeof value === 'string') { + const trimmedValue = value.trim() + if (trimmedValue) { + protocolAcc[key] = trimmedValue + } + return protocolAcc + } + + if (value !== undefined && value !== null) { + protocolAcc[key] = value + } + + return protocolAcc + }, + {} as Record, + ) + + if (protocol === 'vless' && !cleanedProtocolSettings.flow) { + delete cleanedProtocolSettings.flow + } + if (protocol === 'shadowsocks' && !cleanedProtocolSettings.method) { + delete cleanedProtocolSettings.method + } + + if (Object.keys(cleanedProtocolSettings).length > 0) { + acc[protocol] = cleanedProtocolSettings + } + return acc + }, + {} as Record>, + ) + + return Object.keys(cleanedSettings).length > 0 ? cleanedSettings : undefined + }, + [hasMeaningfulProxyValue], + ) + // Add this button component after the username generate button const GenerateProxySettingsButton = () => ( +
+ + + + )} + /> + ( + + WireGuard {t('userDialog.proxySettings.publicKey', { defaultValue: 'Public key' })} + + { + field.onChange(e) + form.trigger('proxy_settings.wireguard.public_key') + handleFieldChange('proxy_settings.wireguard.public_key', e.target.value) + }} + /> + + + + )} + /> + ( + + WireGuard {t('userDialog.proxySettings.peerIps', { defaultValue: 'Peer IPs' })} + +