Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
9c3b78f
feat(core): add WireGuard core management support
x0sina Apr 2, 2026
e812b5c
Merge branch 'dev' into wgcore
x0sina Apr 2, 2026
dab5896
fix: bulk group validation, async wireguard loading, and core simple …
x0sina Apr 2, 2026
b20a762
fix wireguard core creation and streamline core config modal
x0sina Apr 2, 2026
b4caab9
feat(wireguard): implement WireGuard keypair generation and enhance i…
x0sina Apr 2, 2026
126e62e
feat(wireguard): add WireGuard host overrides and enhance subscriptio…
x0sina Apr 2, 2026
7b3eaa9
feat(wireguard): refactor WireGuard link generation and configuration…
x0sina Apr 3, 2026
2d72c80
feat(wireguard): add WireGuard outbound configuration and correspondi…
x0sina Apr 3, 2026
0021818
feat(wireguard): enhance WireGuard settings validation and update UI …
x0sina Apr 3, 2026
b4b66ec
fix(core): use enum instead on raw string
M03ED Apr 3, 2026
36dd7a4
fix: remove random ai shits
M03ED Apr 3, 2026
0ee65fa
fix: enum value
M03ED Apr 3, 2026
1222147
fix: remove extra validator
M03ED Apr 3, 2026
8a1e4ac
fix: format
M03ED Apr 3, 2026
a885e92
fix: tests
M03ED Apr 3, 2026
1ec1d14
fix: typo
M03ED Apr 3, 2026
3815170
fix: typo
M03ED Apr 3, 2026
2fdbde6
fix
M03ED Apr 3, 2026
f84dd85
fix
M03ED Apr 3, 2026
0abe8a1
feat(singbox): generate WireGuard subscriptions using endpoint format
x0sina Apr 3, 2026
b41d502
fix(tests): relax WireGuard endpoint assertions for custom templates
x0sina Apr 3, 2026
23da7ab
fix(node): pass backend_type to node bridge start
x0sina Apr 3, 2026
cb81545
fix(dashboard): sync ui with recent api changes
x0sina Apr 3, 2026
12d093d
feat(clash): add WireGuard outbound support
x0sina Apr 3, 2026
7969d24
fix(locales): correct WireGuard description and update routing settin…
x0sina Apr 3, 2026
eb2c711
fix(locales): update response headers terminology in multiple languages
x0sina Apr 3, 2026
dbe35fe
fix(locales): translate 'AllowedIPs' to local languages in WireGuard …
x0sina Apr 3, 2026
e82859e
fix(dependencies): update @react-router/dev to 7.14.0 and remove unus…
x0sina Apr 3, 2026
429f083
fix(singbox): update wireguard outbound schema
x0sina Apr 4, 2026
85c3b85
fix(dashboard): correct paddings
x0sina Apr 4, 2026
e9d8076
fix(settings): align mobile typography and spacing across dashboard s…
x0sina Apr 4, 2026
caed41e
fix(wireguard): disable default peer keepalive
x0sina Apr 4, 2026
a2e7b8c
tests(singbox): align WireGuard subscription assertion with outbound …
x0sina Apr 4, 2026
d32f17f
feat(wireguard): support per-interface WireGuard peer IP allocation f…
x0sina Apr 5, 2026
510daf5
fix(hosts): resolve UI glitch when switching between xray and wiregua…
x0sina Apr 5, 2026
a0abd88
perf: defer inbound details fetch until host modal is opened
x0sina Apr 5, 2026
4b5beae
fix(dashboard): simplify update core modal and improve template UI
x0sina Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/core/abstract_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 type(self) -> str:
raise NotImplementedError

@property
@abstractmethod
def inbounds_by_tag(self) -> dict:
Expand Down
52 changes: 51 additions & 1 deletion app/core/hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from app.db import GetDB
from app.db.crud.host import get_host_by_id, get_hosts, upsert_inbounds
from app.db.models import ProxyHostSecurity
from app.models.host import BaseHost, TransportSettings
from app.models.host import BaseHost, TransportSettings, WireGuardHostOverrides
from app.models.subscription import (
GRPCTransportConfig,
KCPTransportConfig,
Expand Down Expand Up @@ -53,6 +53,56 @@ async def _prepare_subscription_inbound_data(
network = inbound_config.get("network", "tcp")
path = host.path or inbound_config.get("path", "")

if protocol == "wireguard":
endpoint_addresses = list(host.address) if host.address else ["{SERVER_IP}"]

if host.port:
port_list = [host.port]
else:
listen_port = inbound_config.get("listen_port")
port_list = [listen_port] if listen_port else []

wg_over: WireGuardHostOverrides | None = host.wireguard_overrides
if isinstance(wg_over, dict):
wg_over = WireGuardHostOverrides.model_validate(wg_over)

psk = inbound_config.get("pre_shared_key", "")

default_allowed = ["0.0.0.0/0", "::/0"]
allowed_ips = (
list(wg_over.allowed_ips)
if wg_over and wg_over.allowed_ips is not None and len(wg_over.allowed_ips) > 0
else list(default_allowed)
)

keepalive = inbound_config.get("peer_keepalive_seconds")
if wg_over and wg_over.keepalive_seconds is not None:
keepalive = wg_over.keepalive_seconds if wg_over.keepalive_seconds > 0 else None

mtu = wg_over.mtu if wg_over else None
reserved = wg_over.reserved.strip() if wg_over and wg_over.reserved else None

return SubscriptionInboundData(
remark=host.remark,
inbound_tag=host.inbound_tag,
protocol=protocol,
address=endpoint_addresses,
port=port_list,
network=network,
tls_config=TLSConfig(),
transport_config=TCPTransportConfig(path="", host=[]),
mux_settings=None,
wireguard_public_key=inbound_config.get("public_key", ""),
wireguard_pre_shared_key=psk,
wireguard_local_address=inbound_config.get("address", []) or [],
wireguard_allowed_ips=allowed_ips,
wireguard_keepalive=keepalive,
wireguard_mtu=mtu,
wireguard_reserved=reserved,
priority=host.priority,
status=list(host.status) if host.status else None,
)

sni_list = list(host.sni) if host.sni else inbound_config.get("sni", [])
host_list = list(host.host) if host.host else inbound_config.get("host", [])
address_list = list(host.address) if host.address else []
Expand Down
69 changes: 52 additions & 17 deletions app/core/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@

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.db.models import CoreConfig, CoreType
from app.nats import is_nats_enabled
from app.nats.client import setup_nats_kv
from app.nats.message import MessageTopic
Expand All @@ -24,6 +25,10 @@
class CoreManager:
STATE_CACHE_KEY = "state"
KV_BUCKET_NAME = "core_manager_state"
CORE_CLASSES = {
CoreType.xray: XRayConfig,
CoreType.wg: WireGuardConfig,
}

def __init__(self):
self._cores: dict[int, AbstractCore] = {}
Expand Down Expand Up @@ -87,8 +92,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
Expand All @@ -113,14 +117,31 @@ async def _reload_from_cache(self):
def _core_payload_from_db(self, db_core_config: CoreConfig) -> dict:
return {
"id": db_core_config.id,
"type": db_core_config.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_type(cls, type: CoreType | None) -> CoreType:
if not type:
return CoreType.xray
return type

def _get_core_class(self, type: CoreType | None):
normalized_type = self._normalize_type(type)
return self.CORE_CLASSES[normalized_type]

def _core_from_json(self, data: dict) -> AbstractCore:
type = data.get("type")
core_class = self._get_core_class(type)
return core_class.from_json(data)

async def _apply_core_payload(self, payload: dict):
try:
core_id = payload["id"]
type = payload.get("type", CoreType.xray)
config = payload["config"]
except Exception:
await self._reload_from_cache()
Expand All @@ -130,13 +151,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, type, exclude, fallbacks):
self.id = cid
self.config = cfg
self.type = type
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, type, exclude_tags, fallback_tags))

async def _handle_core_message(self, data: dict):
"""Handle incoming core messages from router."""
Expand All @@ -160,13 +182,17 @@ async def _publish_invalidation(self, message: dict):
"""Publish core update message via global router."""
await router.publish(MessageTopic.CORE, message)

@staticmethod
def validate_core(
config: dict, exclude_inbounds: set[str] | None = None, fallbacks_inbounds: set[str] | None = None
self,
config: dict,
exclude_inbounds: set[str] | None = None,
fallbacks_inbounds: set[str] | None = None,
type: 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 = self._get_core_class(type)
return core_class(config, exclude_inbounds.copy(), fallbacks_inbounds.copy())

async def initialize(self, db):
# Register handler with global router
Expand All @@ -181,15 +207,18 @@ async def initialize(self, db):
return

core_configs, _ = await get_core_configs(db)
backends: dict[int, AbstractCore] = {}
cores: dict[int, AbstractCore] = {}
for config in core_configs:
backend_config = self.validate_core(
config.config, config.exclude_inbound_tags, config.fallbacks_inbound_tags
core_config = self.validate_core(
config.config,
config.exclude_inbound_tags,
config.fallbacks_inbound_tags,
config.type,
)
backends[config.id] = backend_config
cores[config.id] = core_config

async with self._lock:
self._cores = backends
self._cores = cores

await self.update_inbounds()
await self._persist_state()
Expand All @@ -207,12 +236,15 @@ async def update_inbounds(self):
await self.get_inbounds_by_tag.cache.clear()

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
core_config = self.validate_core(
db_core_config.config,
db_core_config.exclude_inbound_tags,
db_core_config.fallbacks_inbound_tags,
db_core_config.type,
)

async with self._lock:
self._cores.update({db_core_config.id: backend_config})
self._cores.update({db_core_config.id: core_config})

await self.update_inbounds()
await self._persist_state()
Expand All @@ -224,7 +256,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.type,
)
try:
await self._publish_invalidation({"action": "update", "core": self._core_payload_from_db(db_core_config)})
Expand Down
148 changes: 148 additions & 0 deletions app/core/wireguard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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._type = CoreType.wg
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 type(self) -> str:
return self._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", 0)
if peer_keepalive_seconds is None:
peer_keepalive_seconds = 0
if not isinstance(peer_keepalive_seconds, int):
raise ValueError("peer_keepalive_seconds must be an integer")
if peer_keepalive_seconds < 0:
raise ValueError("peer_keepalive_seconds must be a non-negative integer")
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", ""),
"private_key": self.get("private_key", ""),
"pre_shared_key": self.get("pre_shared_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 {
"type": self.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)
Loading
Loading