diff --git a/.env.example b/.env.example
index b66c2a0b8..a492521b6 100644
--- a/.env.example
+++ b/.env.example
@@ -19,11 +19,10 @@ UVICORN_PORT = 8000
# USER_SUBSCRIPTION_CLIENTS_LIMIT = 10
# CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/"
-# CLASH_SUBSCRIPTION_TEMPLATE="clash/my-custom-template.yml"
# SUBSCRIPTION_PAGE_TEMPLATE="subscription/index.html"
# HOME_PAGE_TEMPLATE="home/index.html"
-# XRAY_SUBSCRIPTION_TEMPLATE="xray/default.json"
-# SINGBOX_SUBSCRIPTION_TEMPLATE="singbox/default.json"
+# Core subscription templates are stored in DB table `core_templates`
+# and managed via `/api/core_template`.
## External config to import into v2ray format subscription
# EXTERNAL_CONFIG = "config://..."
diff --git a/app/app_factory.py b/app/app_factory.py
index d7df2e648..fa6358937 100644
--- a/app/app_factory.py
+++ b/app/app_factory.py
@@ -10,6 +10,7 @@
from app.nats.message import MessageTopic
from app.nats.router import router
from app.settings import handle_settings_message
+from app.subscription.client_templates import handle_client_template_message
from app.utils.logger import get_logger
from app.version import __version__
from config import DOCS, ROLE, SUBSCRIPTION_PATH
@@ -24,12 +25,14 @@ def _use_route_names_as_operation_ids(app: FastAPI) -> None:
route.operation_id = route.name
-def _register_nats_handlers(enable_router: bool, enable_settings: bool):
+def _register_nats_handlers(enable_router: bool, enable_settings: bool, enable_client_templates: bool):
if enable_router:
on_startup(router.start)
on_shutdown(router.stop)
if enable_settings:
router.register_handler(MessageTopic.SETTING, handle_settings_message)
+ if enable_client_templates:
+ router.register_handler(MessageTopic.CLIENT_TEMPLATE, handle_client_template_message)
def _register_scheduler_hooks():
@@ -105,7 +108,8 @@ def _validate_paths():
enable_router = ROLE.runs_panel or ROLE.runs_node or ROLE.runs_scheduler
enable_settings = ROLE.runs_panel or ROLE.runs_scheduler
- _register_nats_handlers(enable_router, enable_settings)
+ enable_client_templates = ROLE.runs_panel or ROLE.runs_scheduler
+ _register_nats_handlers(enable_router, enable_settings, enable_client_templates)
_register_scheduler_hooks()
_register_jobs()
diff --git a/app/db/crud/__init__.py b/app/db/crud/__init__.py
index 9197113af..f59f23678 100644
--- a/app/db/crud/__init__.py
+++ b/app/db/crud/__init__.py
@@ -1,5 +1,6 @@
from .admin import get_admin
from .core import get_core_config_by_id
+from .client_template import get_client_template_by_id
from .group import get_group_by_id
from .host import get_host_by_id
from .node import get_node_by_id
@@ -10,6 +11,7 @@
__all__ = [
"get_admin",
"get_core_config_by_id",
+ "get_client_template_by_id",
"get_group_by_id",
"get_host_by_id",
"get_node_by_id",
diff --git a/app/db/crud/client_template.py b/app/db/crud/client_template.py
new file mode 100644
index 000000000..c5c8e8dc6
--- /dev/null
+++ b/app/db/crud/client_template.py
@@ -0,0 +1,232 @@
+from collections import defaultdict
+from enum import Enum
+
+from sqlalchemy import func, select, update
+from sqlalchemy.exc import IntegrityError, SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import ClientTemplate
+from app.models.client_template import ClientTemplateCreate, ClientTemplateModify, ClientTemplateType
+
+TEMPLATE_TYPE_TO_LEGACY_KEY: dict[ClientTemplateType, str] = {
+ ClientTemplateType.clash_subscription: "CLASH_SUBSCRIPTION_TEMPLATE",
+ ClientTemplateType.xray_subscription: "XRAY_SUBSCRIPTION_TEMPLATE",
+ ClientTemplateType.singbox_subscription: "SINGBOX_SUBSCRIPTION_TEMPLATE",
+ ClientTemplateType.user_agent: "USER_AGENT_TEMPLATE",
+ ClientTemplateType.grpc_user_agent: "GRPC_USER_AGENT_TEMPLATE",
+}
+
+ClientTemplateSortingOptionsSimple = Enum(
+ "ClientTemplateSortingOptionsSimple",
+ {
+ "id": ClientTemplate.id.asc(),
+ "-id": ClientTemplate.id.desc(),
+ "name": ClientTemplate.name.asc(),
+ "-name": ClientTemplate.name.desc(),
+ "type": ClientTemplate.template_type.asc(),
+ "-type": ClientTemplate.template_type.desc(),
+ },
+)
+
+
+async def get_client_template_values(db: AsyncSession) -> dict[str, str]:
+ try:
+ rows = (
+ await db.execute(
+ select(
+ ClientTemplate.id,
+ ClientTemplate.template_type,
+ ClientTemplate.content,
+ ClientTemplate.is_default,
+ ).order_by(ClientTemplate.template_type.asc(), ClientTemplate.id.asc())
+ )
+ ).all()
+ except SQLAlchemyError:
+ return {}
+
+ by_type: dict[str, list[tuple[int, str, bool]]] = defaultdict(list)
+ for row in rows:
+ by_type[row.template_type].append((row.id, row.content, row.is_default))
+
+ values: dict[str, str] = {}
+ for template_type, legacy_key in TEMPLATE_TYPE_TO_LEGACY_KEY.items():
+ type_rows = by_type.get(template_type.value, [])
+ if not type_rows:
+ continue
+
+ selected_content = ""
+ for _, content, is_default in type_rows:
+ if is_default:
+ selected_content = content
+ break
+
+ if not selected_content:
+ selected_content = type_rows[0][1]
+
+ if selected_content:
+ values[legacy_key] = selected_content
+
+ return values
+
+
+async def get_client_template_by_id(db: AsyncSession, template_id: int) -> ClientTemplate | None:
+ return (await db.execute(select(ClientTemplate).where(ClientTemplate.id == template_id))).unique().scalar_one_or_none()
+
+
+async def get_client_templates(
+ db: AsyncSession,
+ template_type: ClientTemplateType | None = None,
+ offset: int | None = None,
+ limit: int | None = None,
+) -> tuple[list[ClientTemplate], int]:
+ query = select(ClientTemplate)
+ if template_type is not None:
+ query = query.where(ClientTemplate.template_type == template_type.value)
+
+ total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0
+
+ query = query.order_by(ClientTemplate.template_type.asc(), ClientTemplate.id.asc())
+ if offset:
+ query = query.offset(offset)
+ if limit:
+ query = query.limit(limit)
+
+ rows = (await db.execute(query)).scalars().all()
+ return rows, total
+
+
+async def get_client_templates_simple(
+ db: AsyncSession,
+ offset: int | None = None,
+ limit: int | None = None,
+ search: str | None = None,
+ template_type: ClientTemplateType | None = None,
+ sort: list[ClientTemplateSortingOptionsSimple] | None = None,
+ skip_pagination: bool = False,
+) -> tuple[list[tuple[int, str, str, bool]], int]:
+ stmt = select(ClientTemplate.id, ClientTemplate.name, ClientTemplate.template_type, ClientTemplate.is_default)
+
+ if search:
+ stmt = stmt.where(ClientTemplate.name.ilike(f"%{search.strip()}%"))
+
+ if template_type is not None:
+ stmt = stmt.where(ClientTemplate.template_type == template_type.value)
+
+ if sort:
+ sort_list = []
+ for s in sort:
+ if isinstance(s.value, tuple):
+ sort_list.extend(s.value)
+ else:
+ sort_list.append(s.value)
+ stmt = stmt.order_by(*sort_list)
+ else:
+ stmt = stmt.order_by(ClientTemplate.template_type.asc(), ClientTemplate.id.asc())
+
+ total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
+
+ if not skip_pagination:
+ if offset:
+ stmt = stmt.offset(offset)
+ if limit:
+ stmt = stmt.limit(limit)
+ else:
+ stmt = stmt.limit(10000)
+
+ rows = (await db.execute(stmt)).all()
+ return rows, total
+
+
+async def count_client_templates_by_type(db: AsyncSession, template_type: ClientTemplateType) -> int:
+ count_stmt = select(func.count()).select_from(ClientTemplate).where(ClientTemplate.template_type == template_type.value)
+ return (await db.execute(count_stmt)).scalar() or 0
+
+
+async def get_first_template_by_type(
+ db: AsyncSession,
+ template_type: ClientTemplateType,
+ exclude_id: int | None = None,
+) -> ClientTemplate | None:
+ stmt = (
+ select(ClientTemplate)
+ .where(ClientTemplate.template_type == template_type.value)
+ .order_by(ClientTemplate.id.asc())
+ )
+ if exclude_id is not None:
+ stmt = stmt.where(ClientTemplate.id != exclude_id)
+ return (await db.execute(stmt)).scalars().first()
+
+
+async def set_default_template(db: AsyncSession, db_template: ClientTemplate) -> ClientTemplate:
+ await db.execute(
+ update(ClientTemplate)
+ .where(ClientTemplate.template_type == db_template.template_type)
+ .values(is_default=False)
+ )
+ db_template.is_default = True
+ await db.commit()
+ await db.refresh(db_template)
+ return db_template
+
+
+async def create_client_template(db: AsyncSession, client_template: ClientTemplateCreate) -> ClientTemplate:
+ type_count = await count_client_templates_by_type(db, client_template.template_type)
+ is_first_for_type = type_count == 0
+ should_be_default = client_template.is_default or is_first_for_type
+
+ if should_be_default:
+ await db.execute(
+ update(ClientTemplate)
+ .where(ClientTemplate.template_type == client_template.template_type.value)
+ .values(is_default=False)
+ )
+
+ db_template = ClientTemplate(
+ name=client_template.name,
+ template_type=client_template.template_type.value,
+ content=client_template.content,
+ is_default=should_be_default,
+ is_system=is_first_for_type,
+ )
+ db.add(db_template)
+ try:
+ await db.commit()
+ except IntegrityError:
+ await db.rollback()
+ raise
+ await db.refresh(db_template)
+ return db_template
+
+
+async def modify_client_template(
+ db: AsyncSession,
+ db_template: ClientTemplate,
+ modified_template: ClientTemplateModify,
+) -> ClientTemplate:
+ template_data = modified_template.model_dump(exclude_none=True)
+
+ if modified_template.is_default is True:
+ await db.execute(
+ update(ClientTemplate)
+ .where(ClientTemplate.template_type == db_template.template_type)
+ .values(is_default=False)
+ )
+ db_template.is_default = True
+
+ if "name" in template_data:
+ db_template.name = template_data["name"]
+ if "content" in template_data:
+ db_template.content = template_data["content"]
+
+ try:
+ await db.commit()
+ except IntegrityError:
+ await db.rollback()
+ raise
+ await db.refresh(db_template)
+ return db_template
+
+
+async def remove_client_template(db: AsyncSession, db_template: ClientTemplate) -> None:
+ await db.delete(db_template)
+ await db.commit()
diff --git a/app/templates/user_agent/default.json b/app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py
similarity index 56%
rename from app/templates/user_agent/default.json
rename to app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py
index abb85644c..4bc6e2f25 100644
--- a/app/templates/user_agent/default.json
+++ b/app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py
@@ -1,4 +1,234 @@
-{
+"""add client_templates table
+
+Revision ID: e8c6a4f1d2b7
+Revises: 20e2a5cf1e40
+Create Date: 2026-02-20 15:45:00.000000
+
+"""
+
+import os
+from pathlib import Path
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "e8c6a4f1d2b7"
+down_revision = "2f3179c6dc49"
+branch_labels = None
+depends_on = None
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[4]
+
+
+DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE = """mode: rule
+mixed-port: 7890
+ipv6: true
+
+tun:
+ enable: true
+ stack: mixed
+ dns-hijack:
+ - "any:53"
+ auto-route: true
+ auto-detect-interface: true
+ strict-route: true
+
+dns:
+ enable: true
+ listen: :1053
+ ipv6: true
+ nameserver:
+ - 'https://1.1.1.1/dns-query#PROXY'
+ proxy-server-nameserver:
+ - '178.22.122.100'
+ - '78.157.42.100'
+
+sniffer:
+ enable: true
+ override-destination: true
+ sniff:
+ HTTP:
+ ports: [80, 8080-8880]
+ TLS:
+ ports: [443, 8443]
+ QUIC:
+ ports: [443, 8443]
+
+{{ conf | except("proxy-groups", "port", "mode", "rules") | yaml }}
+
+proxy-groups:
+- name: 'PROXY'
+ type: 'select'
+ proxies:
+ - 'Fastest'
+ {{ proxy_remarks | yaml | indent(2) }}
+
+- name: 'Fastest'
+ type: 'url-test'
+ proxies:
+ {{ proxy_remarks | yaml | indent(2) }}
+
+rules:
+ - MATCH,PROXY"""
+
+DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE = """{
+ "log": {
+ "access": "",
+ "error": "",
+ "loglevel": "warning"
+ },
+ "inbounds": [
+ {
+ "tag": "socks",
+ "port": 10808,
+ "listen": "0.0.0.0",
+ "protocol": "socks",
+ "sniffing": {
+ "enabled": true,
+ "destOverride": [
+ "http",
+ "tls"
+ ],
+ "routeOnly": false
+ },
+ "settings": {
+ "auth": "noauth",
+ "udp": true,
+ "allowTransparent": false
+ }
+ },
+ {
+ "tag": "http",
+ "port": 10809,
+ "listen": "0.0.0.0",
+ "protocol": "http",
+ "sniffing": {
+ "enabled": true,
+ "destOverride": [
+ "http",
+ "tls"
+ ],
+ "routeOnly": false
+ },
+ "settings": {
+ "auth": "noauth",
+ "udp": true,
+ "allowTransparent": false
+ }
+ }
+ ],
+ "outbounds": [
+ {
+ "protocol": "freedom",
+ "tag": "DIRECT"
+ },
+ {
+ "protocol": "blackhole",
+ "tag": "BLOCK"
+ }
+ ],
+ "dns": {
+ "servers": [
+ "1.1.1.1",
+ "8.8.8.8"
+ ]
+ },
+ "routing": {
+ "domainStrategy": "AsIs",
+ "rules": []
+ }
+}"""
+
+DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE = """{
+ "log": {
+ "level": "warn",
+ "timestamp": false
+ },
+ "dns": {
+ "servers": [
+ {
+ "tag": "dns-remote",
+ "address": "1.1.1.2",
+ "detour": "proxy"
+ },
+ {
+ "tag": "dns-local",
+ "address": "local",
+ "detour": "direct"
+ }
+ ],
+ "rules": [
+ {
+ "outbound": "any",
+ "server": "dns-local"
+ }
+ ],
+ "final": "dns-remote"
+ },
+ "inbounds": [
+ {
+ "type": "tun",
+ "tag": "tun-in",
+ "interface_name": "sing-tun",
+ "address": [
+ "172.19.0.1/30",
+ "fdfe:dcba:9876::1/126"
+ ],
+ "auto_route": true,
+ "route_exclude_address": [
+ "192.168.0.0/16",
+ "10.0.0.0/8",
+ "169.254.0.0/16",
+ "172.16.0.0/12",
+ "fe80::/10",
+ "fc00::/7"
+ ]
+ }
+ ],
+ "outbounds": [
+ {
+ "type": "selector",
+ "tag": "proxy",
+ "outbounds": null,
+ "interrupt_exist_connections": true
+ },
+ {
+ "type": "urltest",
+ "tag": "Best Latency",
+ "outbounds": null
+ },
+ {
+ "type": "direct",
+ "tag": "direct"
+ }
+ ],
+ "route": {
+ "rules": [
+ {
+ "inbound": "tun-in",
+ "action": "sniff"
+ },
+ {
+ "protocol": "dns",
+ "action": "hijack-dns"
+ }
+ ],
+ "final": "proxy",
+ "auto_detect_interface": true,
+ "override_android_vpn": true
+ },
+ "experimental": {
+ "cache_file": {
+ "enabled": true,
+ "store_rdrc": true
+ }
+ }
+}"""
+
+DEFAULT_USER_AGENT_TEMPLATE = """{
"list":[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
@@ -101,4 +331,153 @@
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/117.0.0.0 Mobile Safari/537.36"
]
-}
\ No newline at end of file
+}"""
+
+DEFAULT_GRPC_USER_AGENT_TEMPLATE = """{
+ "list": [
+ "grpc-dotnet/2.41.0 (.NET 6.0.1; CLR 6.0.1; net6.0; windows; x64)",
+ "grpc-dotnet/2.41.0 (.NET 6.0.0-preview.7.21377.19; CLR 6.0.0; net6.0; osx; x64)",
+ "grpc-dotnet/2.41.0 (Mono 6.12.0.140; CLR 4.0.30319; netstandard2.0; osx; x64)",
+ "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; linux; arm64)",
+ "grpc-dotnet/2.41.0 (.NET 5.0.8; CLR 5.0.8; net5.0; linux; arm64)",
+ "grpc-dotnet/2.41.0 (.NET Core; CLR 3.1.4; netstandard2.1; linux; arm64)",
+ "grpc-dotnet/2.41.0 (.NET Framework; CLR 4.0.30319.42000; netstandard2.0; windows; x86)",
+ "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; windows; x64)",
+ "grpc-python-asyncio/1.62.1 grpc-c/39.0.0 (linux; chttp2)",
+ "grpc-go/1.58.1",
+ "grpc-java-okhttp/1.55.1",
+ "grpc-node/1.7.1 grpc-c/1.7.1 (osx; chttp2)",
+ "grpc-node/1.24.2 grpc-c/8.0.0 (linux; chttp2; ganges)",
+ "grpc-c++/1.16.0 grpc-c/6.0.0 (linux; nghttp2; hw)",
+ "grpc-node/1.19.0 grpc-c/7.0.0 (linux; chttp2; gold)",
+ "grpc-ruby/1.62.0 grpc-c/39.0.0 (osx; chttp2)]"
+ ]
+}"""
+
+
+def _template_content_or_default(
+ env_key: str,
+ path_from_project_root: str,
+ default_content: str,
+) -> str:
+ env_value = os.getenv(env_key)
+ if env_value:
+ return env_value
+
+ custom_templates_directory = os.getenv("CUSTOM_TEMPLATES_DIRECTORY")
+ if custom_templates_directory:
+ custom_base_path = Path(custom_templates_directory).expanduser()
+ project_relative_path = Path(path_from_project_root)
+ try:
+ custom_relative_path = project_relative_path.relative_to("app/templates")
+ except ValueError:
+ custom_relative_path = project_relative_path
+
+ custom_file_path = custom_base_path / custom_relative_path
+ try:
+ if custom_file_path.exists():
+ return custom_file_path.read_text(encoding="utf-8")
+ except OSError:
+ pass
+
+ file_path = PROJECT_ROOT / path_from_project_root
+ try:
+ if file_path.exists():
+ return file_path.read_text(encoding="utf-8")
+ except OSError:
+ pass
+ return default_content
+
+
+def upgrade() -> None:
+ op.create_table(
+ "client_templates",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(length=64), nullable=False),
+ sa.Column("template_type", sa.String(length=32), nullable=False),
+ sa.Column("content", sa.Text(), nullable=False),
+ sa.Column("is_default", sa.Boolean(), server_default="0", nullable=False),
+ sa.Column("is_system", sa.Boolean(), server_default="0", nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("template_type", "name"),
+ )
+ op.create_index("ix_client_templates_template_type", "client_templates", ["template_type"], unique=False)
+
+ clash_template_content = _template_content_or_default(
+ "CLASH_SUBSCRIPTION_TEMPLATE",
+ "app/templates/clash/default.yml",
+ DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE,
+ )
+ xray_template_content = _template_content_or_default(
+ "XRAY_SUBSCRIPTION_TEMPLATE",
+ "app/templates/xray/default.json",
+ DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE,
+ )
+ singbox_template_content = _template_content_or_default(
+ "SINGBOX_SUBSCRIPTION_TEMPLATE",
+ "app/templates/singbox/default.json",
+ DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE,
+ )
+ user_agent_template_content = _template_content_or_default(
+ "USER_AGENT_TEMPLATE",
+ "app/templates/user_agent/default.json",
+ DEFAULT_USER_AGENT_TEMPLATE,
+ )
+ grpc_user_agent_template_content = _template_content_or_default(
+ "GRPC_USER_AGENT_TEMPLATE",
+ "app/templates/user_agent/grpc.json",
+ DEFAULT_GRPC_USER_AGENT_TEMPLATE,
+ )
+
+ op.bulk_insert(
+ sa.table(
+ "client_templates",
+ sa.Column("name", sa.String),
+ sa.Column("template_type", sa.String),
+ sa.Column("content", sa.Text),
+ sa.Column("is_default", sa.Boolean),
+ sa.Column("is_system", sa.Boolean),
+ ),
+ [
+ {
+ "name": "Default Clash Subscription",
+ "template_type": "clash_subscription",
+ "content": clash_template_content,
+ "is_default": True,
+ "is_system": True,
+ },
+ {
+ "name": "Default Xray Subscription",
+ "template_type": "xray_subscription",
+ "content": xray_template_content,
+ "is_default": True,
+ "is_system": True,
+ },
+ {
+ "name": "Default Singbox Subscription",
+ "template_type": "singbox_subscription",
+ "content": singbox_template_content,
+ "is_default": True,
+ "is_system": True,
+ },
+ {
+ "name": "Default User-Agent Template",
+ "template_type": "user_agent",
+ "content": user_agent_template_content,
+ "is_default": True,
+ "is_system": True,
+ },
+ {
+ "name": "Default gRPC User-Agent Template",
+ "template_type": "grpc_user_agent",
+ "content": grpc_user_agent_template_content,
+ "is_default": True,
+ "is_system": True,
+ },
+ ],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_client_templates_template_type", table_name="client_templates")
+ op.drop_table("client_templates")
diff --git a/app/db/models.py b/app/db/models.py
index 026ad903e..1deecd020 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -13,6 +13,7 @@
ForeignKey,
Index,
String,
+ Text,
Table,
UniqueConstraint,
and_,
@@ -721,6 +722,21 @@ class CoreConfig(Base):
fallbacks_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set)
+class ClientTemplate(Base):
+ __tablename__ = "client_templates"
+ __table_args__ = (
+ UniqueConstraint("template_type", "name"),
+ Index("ix_client_templates_template_type", "template_type"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(64), nullable=False)
+ template_type: Mapped[str] = mapped_column(String(32), nullable=False)
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ is_default: Mapped[bool] = mapped_column(default=False, server_default="0")
+ is_system: Mapped[bool] = mapped_column(default=False, server_default="0")
+
+
class NodeStat(Base):
__tablename__ = "node_stats"
diff --git a/app/models/client_template.py b/app/models/client_template.py
new file mode 100644
index 000000000..62a5f5586
--- /dev/null
+++ b/app/models/client_template.py
@@ -0,0 +1,94 @@
+from enum import StrEnum
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+
+class ClientTemplateType(StrEnum):
+ clash_subscription = "clash_subscription"
+ xray_subscription = "xray_subscription"
+ singbox_subscription = "singbox_subscription"
+ user_agent = "user_agent"
+ grpc_user_agent = "grpc_user_agent"
+
+
+class ClientTemplateBase(BaseModel):
+ name: str = Field(max_length=64)
+ template_type: ClientTemplateType
+ content: str
+ is_default: bool = Field(default=False)
+
+ @field_validator("name")
+ @classmethod
+ def validate_name(cls, value: str) -> str:
+ stripped = value.strip()
+ if not stripped:
+ raise ValueError("name can't be empty")
+ return stripped
+
+ @field_validator("content")
+ @classmethod
+ def validate_content(cls, value: str) -> str:
+ if not value or not value.strip():
+ raise ValueError("content can't be empty")
+ return value
+
+
+class ClientTemplateCreate(ClientTemplateBase):
+ pass
+
+
+class ClientTemplateModify(BaseModel):
+ name: str | None = Field(default=None, max_length=64)
+ content: str | None = None
+ is_default: bool | None = None
+
+ @field_validator("name")
+ @classmethod
+ def validate_name(cls, value: str | None) -> str | None:
+ if value is None:
+ return value
+ stripped = value.strip()
+ if not stripped:
+ raise ValueError("name can't be empty")
+ return stripped
+
+ @field_validator("content")
+ @classmethod
+ def validate_content(cls, value: str | None) -> str | None:
+ if value is None:
+ return value
+ if not value.strip():
+ raise ValueError("content can't be empty")
+ return value
+
+
+class ClientTemplateResponse(BaseModel):
+ id: int
+ name: str
+ template_type: ClientTemplateType
+ content: str
+ is_default: bool
+ is_system: bool
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class ClientTemplateResponseList(BaseModel):
+ count: int
+ templates: list[ClientTemplateResponse] = []
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class ClientTemplateSimple(BaseModel):
+ id: int
+ name: str
+ template_type: ClientTemplateType
+ is_default: bool
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class ClientTemplatesSimpleResponse(BaseModel):
+ templates: list[ClientTemplateSimple]
+ total: int
diff --git a/app/nats/message.py b/app/nats/message.py
index 63e4658c7..ff84d7a33 100644
--- a/app/nats/message.py
+++ b/app/nats/message.py
@@ -10,6 +10,7 @@ class MessageTopic(str, Enum):
CORE = "core"
HOST = "host"
SETTING = "setting"
+ CLIENT_TEMPLATE = "client_template"
NODE = "node" # For future use
diff --git a/app/operation/__init__.py b/app/operation/__init__.py
index 51eec682f..6fcb2e234 100644
--- a/app/operation/__init__.py
+++ b/app/operation/__init__.py
@@ -8,6 +8,7 @@
from app.db.crud import (
get_admin,
get_core_config_by_id,
+ get_client_template_by_id,
get_group_by_id,
get_host_by_id,
get_node_by_id,
@@ -17,7 +18,7 @@
from app.db.crud.admin import get_admin_by_id
from app.db.crud.group import get_groups_by_ids
from app.db.crud.user import get_user_by_id
-from app.db.models import Admin as DBAdmin, CoreConfig, Group, Node, ProxyHost, User, UserTemplate
+from app.db.models import Admin as DBAdmin, CoreConfig, ClientTemplate, Group, Node, ProxyHost, User, UserTemplate
from app.models.admin import AdminDetails
from app.models.group import BulkGroup
from app.models.user import UserCreate, UserModify
@@ -225,3 +226,9 @@ async def get_validated_core_config(self, db: AsyncSession, core_id) -> CoreConf
if not db_core_config:
await self.raise_error(message="Core config not found", code=404)
return db_core_config
+
+ async def get_validated_client_template(self, db: AsyncSession, template_id: int) -> ClientTemplate:
+ db_client_template = await get_client_template_by_id(db, template_id)
+ if not db_client_template:
+ await self.raise_error(message="Client template not found", code=404)
+ return db_client_template
diff --git a/app/operation/client_template.py b/app/operation/client_template.py
new file mode 100644
index 000000000..8a7e01afe
--- /dev/null
+++ b/app/operation/client_template.py
@@ -0,0 +1,201 @@
+import json
+
+import yaml
+from sqlalchemy.exc import IntegrityError
+
+from app.db import AsyncSession
+from app.db.crud.client_template import (
+ ClientTemplateSortingOptionsSimple,
+ count_client_templates_by_type,
+ create_client_template,
+ get_client_templates,
+ get_client_templates_simple,
+ get_first_template_by_type,
+ modify_client_template,
+ remove_client_template,
+ set_default_template,
+)
+from app.models.admin import AdminDetails
+from app.models.client_template import (
+ ClientTemplateCreate,
+ ClientTemplateModify,
+ ClientTemplateResponse,
+ ClientTemplateResponseList,
+ ClientTemplateSimple,
+ ClientTemplatesSimpleResponse,
+ ClientTemplateType,
+)
+from app.nats.message import MessageTopic
+from app.nats.router import router
+from app.subscription.client_templates import refresh_client_templates_cache
+from app.templates import render_template_string
+from app.utils.logger import get_logger
+
+from . import BaseOperation
+
+logger = get_logger("client-template-operation")
+
+
+class ClientTemplateOperation(BaseOperation):
+ @staticmethod
+ async def _sync_client_template_cache() -> None:
+ await refresh_client_templates_cache()
+ await router.publish(MessageTopic.CLIENT_TEMPLATE, {"action": "refresh"})
+
+ async def _validate_template_content(self, template_type: ClientTemplateType, content: str) -> None:
+ try:
+ if template_type == ClientTemplateType.clash_subscription:
+ rendered = render_template_string(
+ content,
+ {
+ "conf": {"proxies": [], "proxy-groups": [], "rules": []},
+ "proxy_remarks": [],
+ },
+ )
+ yaml.safe_load(rendered)
+ return
+
+ rendered = render_template_string(content)
+ parsed = json.loads(rendered)
+ if template_type in (ClientTemplateType.user_agent, ClientTemplateType.grpc_user_agent):
+ if not isinstance(parsed, dict):
+ raise ValueError("User-Agent template content must render to a JSON object")
+ if (_list := parsed.get("list")) is None or not isinstance(_list, list):
+ raise ValueError("User-Agent template content must contain a 'list' field with an array of strings")
+ if not _list:
+ raise ValueError("User-Agent template content must contain at least one User-Agent string")
+ if template_type in (ClientTemplateType.xray_subscription, ClientTemplateType.singbox_subscription):
+ if not isinstance(parsed, dict):
+ raise ValueError("Subscription template content must render to a JSON object")
+ if (inb := parsed.get("inbounds")) is None or not isinstance(inb, list):
+ raise ValueError(
+ "Subscription template content must contain a 'inbounds' field with an array of proxy objects"
+ )
+ if not inb:
+ raise ValueError("Subscription template content must contain at least one inbound proxy")
+ if (out := parsed.get("outbounds")) is None or not isinstance(out, list):
+ raise ValueError(
+ "Subscription template content must contain a 'outbounds' field with an array of proxy objects"
+ )
+ if not out:
+ raise ValueError("Subscription template content must contain at least one outbound proxy")
+ except Exception as exc:
+ await self.raise_error(message=f"Invalid template content: {str(exc)}", code=400)
+
+ async def create_client_template(
+ self,
+ db: AsyncSession,
+ new_template: ClientTemplateCreate,
+ admin: AdminDetails,
+ ) -> ClientTemplateResponse:
+ await self._validate_template_content(new_template.template_type, new_template.content)
+
+ try:
+ db_template = await create_client_template(db, new_template)
+ except IntegrityError:
+ await self.raise_error("Template with this name already exists for this type", 409, db=db)
+
+ logger.info(
+ f'Client template "{db_template.name}" ({db_template.template_type}) created by admin "{admin.username}"'
+ )
+ await self._sync_client_template_cache()
+ return ClientTemplateResponse.model_validate(db_template)
+
+ async def get_client_templates(
+ self,
+ db: AsyncSession,
+ template_type: ClientTemplateType | None = None,
+ offset: int | None = None,
+ limit: int | None = None,
+ ) -> ClientTemplateResponseList:
+ templates, count = await get_client_templates(db, template_type=template_type, offset=offset, limit=limit)
+ return ClientTemplateResponseList(templates=templates, count=count)
+
+ async def get_client_templates_simple(
+ self,
+ db: AsyncSession,
+ offset: int | None = None,
+ limit: int | None = None,
+ search: str | None = None,
+ template_type: ClientTemplateType | None = None,
+ sort: str | None = None,
+ all: bool = False,
+ ) -> ClientTemplatesSimpleResponse:
+ sort_list = []
+ if sort is not None:
+ opts = sort.strip(",").split(",")
+ for opt in opts:
+ try:
+ enum_member = ClientTemplateSortingOptionsSimple[opt]
+ sort_list.append(enum_member)
+ except KeyError:
+ await self.raise_error(message=f'"{opt}" is not a valid sort option', code=400)
+
+ rows, total = await get_client_templates_simple(
+ db=db,
+ offset=offset,
+ limit=limit,
+ search=search,
+ template_type=template_type,
+ sort=sort_list if sort_list else None,
+ skip_pagination=all,
+ )
+
+ templates = [
+ ClientTemplateSimple(id=row[0], name=row[1], template_type=row[2], is_default=row[3]) for row in rows
+ ]
+ return ClientTemplatesSimpleResponse(templates=templates, total=total)
+
+ async def modify_client_template(
+ self,
+ db: AsyncSession,
+ template_id: int,
+ modified_template: ClientTemplateModify,
+ admin: AdminDetails,
+ ) -> ClientTemplateResponse:
+ db_template = await self.get_validated_client_template(db, template_id)
+
+ if modified_template.content is not None:
+ await self._validate_template_content(
+ ClientTemplateType(db_template.template_type), modified_template.content
+ )
+
+ if modified_template.is_default is False and db_template.is_default:
+ await self.raise_error(
+ message="Cannot unset default template directly. Set another template as default instead.",
+ code=400,
+ )
+
+ try:
+ db_template = await modify_client_template(db, db_template, modified_template)
+ except IntegrityError:
+ await self.raise_error("Template with this name already exists for this type", 409, db=db)
+
+ logger.info(
+ f'Client template "{db_template.name}" ({db_template.template_type}) modified by admin "{admin.username}"'
+ )
+ await self._sync_client_template_cache()
+ return ClientTemplateResponse.model_validate(db_template)
+
+ async def remove_client_template(self, db: AsyncSession, template_id: int, admin: AdminDetails) -> None:
+ db_template = await self.get_validated_client_template(db, template_id)
+ template_type = ClientTemplateType(db_template.template_type)
+
+ if db_template.is_system:
+ await self.raise_error(message="Cannot delete system template", code=403)
+
+ template_count = await count_client_templates_by_type(db, template_type)
+ if template_count <= 1:
+ await self.raise_error(message="Cannot delete the last template for this type", code=403)
+
+ replacement = None
+ if db_template.is_default:
+ replacement = await get_first_template_by_type(db, template_type, exclude_id=db_template.id)
+
+ await remove_client_template(db, db_template)
+
+ if replacement is not None:
+ await set_default_template(db, replacement)
+
+ logger.info(f'Client template "{db_template.name}" ({template_type.value}) deleted by admin "{admin.username}"')
+ await self._sync_client_template_cache()
diff --git a/app/operation/subscription.py b/app/operation/subscription.py
index e2d0e048a..93a321c31 100644
--- a/app/operation/subscription.py
+++ b/app/operation/subscription.py
@@ -115,7 +115,9 @@ def create_info_response_headers(user: UsersResponseWithInbounds, sub_settings:
# Only include headers that have values
return {k: v for k, v in headers.items() if v}
- async def fetch_config(self, user: UsersResponseWithInbounds, client_type: ConfigFormat) -> tuple[str, str]:
+ async def fetch_config(
+ self, user: UsersResponseWithInbounds, client_type: ConfigFormat
+ ) -> tuple[str, str]:
# Get client configuration
config = client_config.get(client_type)
sub_settings = await subscription_settings()
diff --git a/app/routers/__init__.py b/app/routers/__init__.py
index ccf06c431..03df78fbe 100644
--- a/app/routers/__init__.py
+++ b/app/routers/__init__.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter
-from . import admin, core, group, home, host, node, settings, subscription, system, user, user_template
+from . import admin, core, client_template, group, home, host, node, settings, subscription, system, user, user_template
api_router = APIRouter()
@@ -11,6 +11,7 @@
settings.router,
group.router,
core.router,
+ client_template.router,
host.router,
node.router,
user.router,
diff --git a/app/routers/client_template.py b/app/routers/client_template.py
new file mode 100644
index 000000000..d2d2638e6
--- /dev/null
+++ b/app/routers/client_template.py
@@ -0,0 +1,96 @@
+from fastapi import APIRouter, Depends, status
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.client_template import (
+ ClientTemplateCreate,
+ ClientTemplateModify,
+ ClientTemplateResponse,
+ ClientTemplateResponseList,
+ ClientTemplatesSimpleResponse,
+ ClientTemplateType,
+)
+from app.operation import OperatorType
+from app.operation.client_template import ClientTemplateOperation
+from app.utils import responses
+
+from .authentication import check_sudo_admin, get_current
+
+router = APIRouter(
+ tags=["Client Template"],
+ prefix="/api/client_template",
+ responses={401: responses._401, 403: responses._403},
+)
+
+client_template_operator = ClientTemplateOperation(OperatorType.API)
+
+
+@router.post("", response_model=ClientTemplateResponse, status_code=status.HTTP_201_CREATED)
+async def create_client_template(
+ new_template: ClientTemplateCreate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ return await client_template_operator.create_client_template(db, new_template, admin)
+
+
+@router.get("/{template_id}", response_model=ClientTemplateResponse)
+async def get_client_template(
+ template_id: int,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(get_current),
+):
+ return await client_template_operator.get_validated_client_template(db, template_id)
+
+
+@router.put("/{template_id}", response_model=ClientTemplateResponse)
+async def modify_client_template(
+ template_id: int,
+ modified_template: ClientTemplateModify,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ return await client_template_operator.modify_client_template(db, template_id, modified_template, admin)
+
+
+@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def remove_client_template(
+ template_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ await client_template_operator.remove_client_template(db, template_id, admin)
+ return {}
+
+
+@router.get("s", response_model=ClientTemplateResponseList)
+async def get_client_templates(
+ template_type: ClientTemplateType | None = None,
+ offset: int | None = None,
+ limit: int | None = None,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(get_current),
+):
+ return await client_template_operator.get_client_templates(db, template_type=template_type, offset=offset, limit=limit)
+
+
+@router.get("s/simple", response_model=ClientTemplatesSimpleResponse)
+async def get_client_templates_simple(
+ template_type: ClientTemplateType | None = None,
+ offset: int | None = None,
+ limit: int | None = None,
+ search: str | None = None,
+ sort: str | None = None,
+ all: bool = False,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(get_current),
+):
+ return await client_template_operator.get_client_templates_simple(
+ db=db,
+ template_type=template_type,
+ offset=offset,
+ limit=limit,
+ search=search,
+ sort=sort,
+ all=all,
+ )
diff --git a/app/subscription/base.py b/app/subscription/base.py
index 7841f4c7a..8c5cf15bb 100644
--- a/app/subscription/base.py
+++ b/app/subscription/base.py
@@ -4,20 +4,24 @@
import re
from enum import Enum
-from app.templates import render_template
-from config import GRPC_USER_AGENT_TEMPLATE, USER_AGENT_TEMPLATE
+from app.templates import render_template_string
class BaseSubscription:
- def __init__(self):
+ def __init__(
+ self,
+ user_agent_template_content: str | None = None,
+ grpc_user_agent_template_content: str | None = None,
+ ):
+
self.proxy_remarks = []
- user_agent_data = json.loads(render_template(USER_AGENT_TEMPLATE))
+ user_agent_data = json.loads(render_template_string(user_agent_template_content))
if "list" in user_agent_data and isinstance(user_agent_data["list"], list):
self.user_agent_list = user_agent_data["list"]
else:
self.user_agent_list = []
- grpc_user_agent_data = json.loads(render_template(GRPC_USER_AGENT_TEMPLATE))
+ grpc_user_agent_data = json.loads(render_template_string(grpc_user_agent_template_content))
if "list" in grpc_user_agent_data and isinstance(grpc_user_agent_data["list"], list):
self.grpc_user_agent_data = grpc_user_agent_data["list"]
diff --git a/app/subscription/clash.py b/app/subscription/clash.py
index cb7363afe..b8416e67c 100644
--- a/app/subscription/clash.py
+++ b/app/subscription/clash.py
@@ -10,18 +10,24 @@
TLSConfig,
WebSocketTransportConfig,
)
-from app.templates import render_template
+from app.templates import render_template_string
from app.utils.helpers import yml_uuid_representer
-from config import (
- CLASH_SUBSCRIPTION_TEMPLATE,
-)
from . import BaseSubscription
class ClashConfiguration(BaseSubscription):
- def __init__(self):
- super().__init__()
+ def __init__(
+ self,
+ clash_template_content: str | None = None,
+ user_agent_template_content: str | None = None,
+ grpc_user_agent_template_content: str | None = None,
+ ):
+ super().__init__(
+ user_agent_template_content=user_agent_template_content,
+ grpc_user_agent_template_content=grpc_user_agent_template_content,
+ )
+ self.clash_template_content = clash_template_content
self.data = {
"proxies": [],
"proxy-groups": [],
@@ -55,7 +61,10 @@ def render(self, reverse=False):
yaml.add_representer(UUID, yml_uuid_representer)
return yaml.dump(
yaml.safe_load(
- render_template(CLASH_SUBSCRIPTION_TEMPLATE, {"conf": self.data, "proxy_remarks": self.proxy_remarks}),
+ render_template_string(
+ self.clash_template_content,
+ {"conf": self.data, "proxy_remarks": self.proxy_remarks},
+ ),
),
sort_keys=False,
allow_unicode=True,
@@ -288,8 +297,17 @@ def add(self, remark: str, address: str, inbound: SubscriptionInboundData, setti
class ClashMetaConfiguration(ClashConfiguration):
- def __init__(self):
- super().__init__()
+ def __init__(
+ self,
+ clash_template_content: str | None = None,
+ user_agent_template_content: str | None = None,
+ grpc_user_agent_template_content: str | None = None,
+ ):
+ super().__init__(
+ clash_template_content=clash_template_content,
+ user_agent_template_content=user_agent_template_content,
+ grpc_user_agent_template_content=grpc_user_agent_template_content,
+ )
# Override protocol handlers to include vless
self.protocol_handlers = {
"vmess": self._build_vmess,
diff --git a/app/subscription/client_templates.py b/app/subscription/client_templates.py
new file mode 100644
index 000000000..aae8e3bd7
--- /dev/null
+++ b/app/subscription/client_templates.py
@@ -0,0 +1,19 @@
+from aiocache import cached
+
+from app.db import GetDB
+from app.db.crud.client_template import get_client_template_values
+
+
+@cached()
+async def subscription_client_templates() -> dict[str, str]:
+ async with GetDB() as db:
+ return await get_client_template_values(db)
+
+
+async def refresh_client_templates_cache() -> None:
+ await subscription_client_templates.cache.clear()
+
+
+async def handle_client_template_message(_: dict) -> None:
+ """Handle client template update messages from NATS router."""
+ await refresh_client_templates_cache()
diff --git a/app/subscription/links.py b/app/subscription/links.py
index 7a4025b08..6d6b948ae 100644
--- a/app/subscription/links.py
+++ b/app/subscription/links.py
@@ -20,8 +20,15 @@
class StandardLinks(BaseSubscription):
- def __init__(self):
- super().__init__()
+ def __init__(
+ self,
+ user_agent_template_content: str | None = None,
+ grpc_user_agent_template_content: str | None = None,
+ ):
+ super().__init__(
+ user_agent_template_content=user_agent_template_content,
+ grpc_user_agent_template_content=grpc_user_agent_template_content,
+ )
self.links = []
# Registry pattern for transport handlers
diff --git a/app/subscription/share.py b/app/subscription/share.py
index d3cb5c446..544a360d8 100644
--- a/app/subscription/share.py
+++ b/app/subscription/share.py
@@ -12,6 +12,7 @@
from app.models.subscription import SubscriptionInboundData
from app.models.user import UsersResponseWithInbounds
from app.utils.system import get_public_ip, get_public_ipv6, readable_size
+from app.subscription.client_templates import subscription_client_templates
from . import (
ClashConfiguration,
@@ -33,15 +34,35 @@
"on_hold": "🔌",
}
-
-config_format_handler = {
- "links": StandardLinks,
- "clash": ClashMetaConfiguration,
- "clash_meta": ClashMetaConfiguration,
- "sing_box": SingBoxConfiguration,
- "outline": OutlineConfiguration,
- "xray": XrayConfiguration,
-}
+def _build_subscription_config(
+ config_format: str,
+ client_templates: dict[str, str],
+) -> StandardLinks | XrayConfiguration | SingBoxConfiguration | ClashConfiguration | ClashMetaConfiguration | OutlineConfiguration | None:
+ common_kwargs = {
+ "user_agent_template_content": client_templates["USER_AGENT_TEMPLATE"],
+ "grpc_user_agent_template_content": client_templates["GRPC_USER_AGENT_TEMPLATE"],
+ }
+
+ if config_format == "links":
+ return StandardLinks(**common_kwargs)
+ if config_format in ("clash", "clash_meta"):
+ return ClashMetaConfiguration(
+ clash_template_content=client_templates["CLASH_SUBSCRIPTION_TEMPLATE"],
+ **common_kwargs,
+ )
+ if config_format == "sing_box":
+ return SingBoxConfiguration(
+ singbox_template_content=client_templates["SINGBOX_SUBSCRIPTION_TEMPLATE"],
+ **common_kwargs,
+ )
+ if config_format == "outline":
+ return OutlineConfiguration()
+ if config_format == "xray":
+ return XrayConfiguration(
+ xray_template_content=client_templates["XRAY_SUBSCRIPTION_TEMPLATE"],
+ **common_kwargs,
+ )
+ return None
async def generate_subscription(
@@ -51,13 +72,21 @@ async def generate_subscription(
reverse: bool = False,
randomize_order: bool = False,
) -> str:
- conf = config_format_handler.get(config_format, None)
+ client_templates = await subscription_client_templates()
+ conf = _build_subscription_config(config_format, client_templates)
if conf is None:
raise ValueError(f'Unsupported format "{config_format}"')
format_variables = setup_format_variables(user)
- config = await process_inbounds_and_tags(user, format_variables, conf(), reverse, randomize_order=randomize_order)
+ config = await process_inbounds_and_tags(
+ user,
+ format_variables,
+ conf,
+ client_templates,
+ reverse,
+ randomize_order=randomize_order,
+ )
if as_base64:
config = base64.b64encode(config.encode()).decode()
@@ -251,6 +280,7 @@ async def _prepare_download_settings(
format_variables: dict,
inbounds: list[str],
proxies: dict,
+ client_templates: dict[str, str],
conf: StandardLinks
| XrayConfiguration
| SingBoxConfiguration
@@ -269,7 +299,11 @@ async def _prepare_download_settings(
download_copy.address = download_copy.address.format_map(format_variables)
if isinstance(conf, StandardLinks):
- xc = XrayConfiguration()
+ xc = XrayConfiguration(
+ xray_template_content=client_templates["XRAY_SUBSCRIPTION_TEMPLATE"],
+ user_agent_template_content=client_templates["USER_AGENT_TEMPLATE"],
+ grpc_user_agent_template_content=client_templates["GRPC_USER_AGENT_TEMPLATE"],
+ )
return xc._download_config(download_copy, link_format=True)
return download_copy
@@ -284,6 +318,7 @@ async def process_inbounds_and_tags(
| ClashConfiguration
| ClashMetaConfiguration
| OutlineConfiguration,
+ client_templates: dict[str, str],
reverse=False,
randomize_order: bool = False,
) -> list | str:
@@ -310,6 +345,7 @@ async def process_inbounds_and_tags(
format_variables,
user.inbounds,
proxy_settings,
+ client_templates,
conf,
)
if hasattr(inbound_copy.transport_config, "download_settings"):
diff --git a/app/subscription/singbox.py b/app/subscription/singbox.py
index e4b6ac0b7..55a4646bb 100644
--- a/app/subscription/singbox.py
+++ b/app/subscription/singbox.py
@@ -8,17 +8,24 @@
TLSConfig,
WebSocketTransportConfig,
)
-from app.templates import render_template
+from app.templates import render_template_string
from app.utils.helpers import UUIDEncoder
-from config import SINGBOX_SUBSCRIPTION_TEMPLATE
from . import BaseSubscription
class SingBoxConfiguration(BaseSubscription):
- def __init__(self):
- super().__init__()
- self.config = json.loads(render_template(SINGBOX_SUBSCRIPTION_TEMPLATE))
+ def __init__(
+ self,
+ singbox_template_content: str | None = None,
+ user_agent_template_content: str | None = None,
+ grpc_user_agent_template_content: str | None = None,
+ ):
+ super().__init__(
+ user_agent_template_content=user_agent_template_content,
+ grpc_user_agent_template_content=grpc_user_agent_template_content,
+ )
+ self.config = json.loads(render_template_string(singbox_template_content))
# Registry for transport handlers
self.transport_handlers = {
diff --git a/app/subscription/xray.py b/app/subscription/xray.py
index b4f5965a7..558ab2713 100644
--- a/app/subscription/xray.py
+++ b/app/subscription/xray.py
@@ -11,18 +11,25 @@
WebSocketTransportConfig,
XHTTPTransportConfig,
)
-from app.templates import render_template
+from app.templates import render_template_string
from app.utils.helpers import UUIDEncoder
-from config import XRAY_SUBSCRIPTION_TEMPLATE
from . import BaseSubscription
class XrayConfiguration(BaseSubscription):
- def __init__(self):
- super().__init__()
+ def __init__(
+ self,
+ xray_template_content: str | None = None,
+ user_agent_template_content: str | None = None,
+ grpc_user_agent_template_content: str | None = None,
+ ):
+ super().__init__(
+ user_agent_template_content=user_agent_template_content,
+ grpc_user_agent_template_content=grpc_user_agent_template_content,
+ )
self.config = []
- self.template = render_template(XRAY_SUBSCRIPTION_TEMPLATE)
+ self.template = render_template_string(xray_template_content)
# Registry for transport handlers
self.transport_handlers = {
diff --git a/app/templates/__init__.py b/app/templates/__init__.py
index 02a9d0b54..9b006e5c7 100644
--- a/app/templates/__init__.py
+++ b/app/templates/__init__.py
@@ -19,3 +19,7 @@
def render_template(template: str, context: Union[dict, None] = None) -> str:
return env.get_template(template).render(context or {})
+
+
+def render_template_string(template_content: str, context: Union[dict, None] = None) -> str:
+ return env.from_string(template_content).render(context or {})
diff --git a/app/templates/clash/README.md b/app/templates/clash/README.md
deleted file mode 100644
index 56e05057b..000000000
--- a/app/templates/clash/README.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# Clash Template
-
-## Usage
-
-- Can be used to send completely prepared config to users depend on your usage.
-
-## Config Template
-
-- With the config template, you can change things like routing and rules.
-
-## How To Use
-
-First of all, you need to set a directory for all of your templates (home, subscription page, etc.).
-
-```shell
-CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/"
-```
-
-Make sure you put all of your templates in this folder.\
-If you are using Docker, make sure Docker has access to this folder.\
-Then, we need to make a directory for our Clash template.
-
-```shell
-mkdir -p /var/lib/pasarguard/templates/clash
-```
-
-After that, put your templates (config and settings) in the directory.\
-Now, change these variables with your files' names.
-
-```shell
-CLASH_SUBSCRIPTION_TEMPLATE = "clash/default.yml"
-```
-
-Now, restart your PasarGuard and enjoy.
-
-If you have already changed your env variables, and you want to just update the template files, there is no need to restart PasarGuard.
-
-## Docs
-
-you can use these docs to find out how to modify template files
-
-[Mihomo Docs](https://wiki.metacubex.one/en/)
diff --git a/app/templates/clash/default.yml b/app/templates/clash/default.yml
deleted file mode 100644
index 344267ebd..000000000
--- a/app/templates/clash/default.yml
+++ /dev/null
@@ -1,50 +0,0 @@
-mode: rule
-mixed-port: 7890
-ipv6: true
-
-tun:
- enable: true
- stack: mixed
- dns-hijack:
- - "any:53"
- auto-route: true
- auto-detect-interface: true
- strict-route: true
-
-dns:
- enable: true
- listen: :1053
- ipv6: true
- nameserver:
- - 'https://1.1.1.1/dns-query#PROXY'
- proxy-server-nameserver:
- - '178.22.122.100'
- - '78.157.42.100'
-
-sniffer:
- enable: true
- override-destination: true
- sniff:
- HTTP:
- ports: [80, 8080-8880]
- TLS:
- ports: [443, 8443]
- QUIC:
- ports: [443, 8443]
-
-{{ conf | except("proxy-groups", "port", "mode", "rules") | yaml }}
-
-proxy-groups:
-- name: 'PROXY'
- type: 'select'
- proxies:
- - '⚡️ Fastest'
- {{ proxy_remarks | yaml | indent(2) }}
-
-- name: '⚡️ Fastest'
- type: 'url-test'
- proxies:
- {{ proxy_remarks | yaml | indent(2) }}
-
-rules:
- - MATCH,PROXY
diff --git a/app/templates/singbox/README.md b/app/templates/singbox/README.md
deleted file mode 100644
index 7daa7ced1..000000000
--- a/app/templates/singbox/README.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# Sing-box Template
-
-## Usage
-
-- Can be used to send completely prepared config to users depend on your usage.
-
-## Config Template
-
-- With the config template, you can change things like routing and rules.
-
-## How To Use
-
-First of all, you need to set a directory for all of your templates (home, subscription page, etc.).
-
-```shell
-CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/"
-```
-
-Make sure you put all of your templates in this folder.\
-If you are using Docker, make sure Docker has access to this folder.\
-Then, we need to make a directory for our Sing-box template.
-
-```shell
-mkdir -p /var/lib/pasarguard/templates/singbox
-```
-
-After that, put your templates (config and settings) in the directory.\
-Now, change these variables with your files' names.
-
-```shell
-SINGBOX_SUBSCRIPTION_TEMPLATE="singbox/default.json"
-```
-
-Now, restart your PasarGuard and enjoy.
-
-If you have already changed your env variables, and you want to just update the template files, there is no need to restart PasarGuard.
-
-## Docs
-
-you can use sing-box official documentation to find out how to modify template files
-
-[Sing-Box documentation](https://sing-box.sagernet.org/configuration/)
diff --git a/app/templates/singbox/default.json b/app/templates/singbox/default.json
deleted file mode 100644
index e5594628d..000000000
--- a/app/templates/singbox/default.json
+++ /dev/null
@@ -1,85 +0,0 @@
-{
- "log": {
- "level": "warn",
- "timestamp": false
- },
- "dns": {
- "servers": [
- {
- "tag": "dns-remote",
- "address": "1.1.1.2",
- "detour": "proxy"
- },
- {
- "tag": "dns-local",
- "address": "local",
- "detour": "direct"
- }
- ],
- "rules": [
- {
- "outbound": "any",
- "server": "dns-local"
- }
- ],
- "final": "dns-remote"
- },
- "inbounds": [
- {
- "type": "tun",
- "tag": "tun-in",
- "interface_name": "sing-tun",
- "address": [
- "172.19.0.1/30",
- "fdfe:dcba:9876::1/126"
- ],
- "auto_route": true,
- "route_exclude_address": [
- "192.168.0.0/16",
- "10.0.0.0/8",
- "169.254.0.0/16",
- "172.16.0.0/12",
- "fe80::/10",
- "fc00::/7"
- ]
- }
- ],
- "outbounds": [
- {
- "type": "selector",
- "tag": "proxy",
- "outbounds": null,
- "interrupt_exist_connections": true
- },
- {
- "type": "urltest",
- "tag": "Best Latency",
- "outbounds": null
- },
- {
- "type": "direct",
- "tag": "direct"
- }
- ],
- "route": {
- "rules": [
- {
- "inbound": "tun-in",
- "action": "sniff"
- },
- {
- "protocol": "dns",
- "action": "hijack-dns"
- }
- ],
- "final": "proxy",
- "auto_detect_interface": true,
- "override_android_vpn": true
- },
- "experimental": {
- "cache_file": {
- "enabled": true,
- "store_rdrc": true
- }
- }
-}
diff --git a/app/templates/user_agent/grpc.json b/app/templates/user_agent/grpc.json
deleted file mode 100644
index 821671ea4..000000000
--- a/app/templates/user_agent/grpc.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "list": [
- "grpc-dotnet/2.41.0 (.NET 6.0.1; CLR 6.0.1; net6.0; windows; x64)",
- "grpc-dotnet/2.41.0 (.NET 6.0.0-preview.7.21377.19; CLR 6.0.0; net6.0; osx; x64)",
- "grpc-dotnet/2.41.0 (Mono 6.12.0.140; CLR 4.0.30319; netstandard2.0; osx; x64)",
- "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; linux; arm64)",
- "grpc-dotnet/2.41.0 (.NET 5.0.8; CLR 5.0.8; net5.0; linux; arm64)",
- "grpc-dotnet/2.41.0 (.NET Core; CLR 3.1.4; netstandard2.1; linux; arm64)",
- "grpc-dotnet/2.41.0 (.NET Framework; CLR 4.0.30319.42000; netstandard2.0; windows; x86)",
- "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; windows; x64)",
- "grpc-python-asyncio/1.62.1 grpc-c/39.0.0 (linux; chttp2)",
- "grpc-go/1.58.1",
- "grpc-java-okhttp/1.55.1",
- "grpc-node/1.7.1 grpc-c/1.7.1 (osx; chttp2)",
- "grpc-node/1.24.2 grpc-c/8.0.0 (linux; chttp2; ganges)",
- "grpc-c++/1.16.0 grpc-c/6.0.0 (linux; nghttp2; hw)",
- "grpc-node/1.19.0 grpc-c/7.0.0 (linux; chttp2; gold)",
- "grpc-ruby/1.62.0 grpc-c/39.0.0 (osx; chttp2)]"
- ]
-}
\ No newline at end of file
diff --git a/app/templates/xray/README.md b/app/templates/xray/README.md
deleted file mode 100644
index e4cbc67f1..000000000
--- a/app/templates/xray/README.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# Xray Template
-
-## Usage
-
-- Can be used to send completely prepared config to users and avoid application default values.
-
-## Config Template
-
-- With the config template, you can change things like routing and rules.
-
-## How To Use
-
-First of all, you need to set a directory for all of your templates (home, subscription page, etc.).
-
-```shell
-CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/"
-```
-
-Make sure you put all of your templates in this folder.\
-If you are using Docker, make sure Docker has access to this folder.\
-Then, we need to make a directory for our Xray template.
-
-```shell
-mkdir -p /var/lib/pasarguard/templates/xray
-```
-
-After that, put your templates (config and settings) in the directory.\
-Now, change these variables with your files' names.
-
-```shell
-XRAY_SUBSCRIPTION_TEMPLATE="xray/default.json"
-```
-
-Now, restart your PasarGuard and enjoy.
-
-If you have already changed your env variables, and you want to just update the template files, there is no need to restart PasarGuard.
-
-## Docs
-
-you can use these docs to find out how to modify template files
-
-[Xray Docs](https://xtls.github.io/en/) \
-[Xray Examples](https://github.com/XTLS/Xray-examples) \
-[Xray Examples](https://github.com/chika0801/Xray-examples) Unofficial
diff --git a/app/templates/xray/default.json b/app/templates/xray/default.json
deleted file mode 100644
index d842a9f79..000000000
--- a/app/templates/xray/default.json
+++ /dev/null
@@ -1,58 +0,0 @@
-{
- "log": {
- "access": "",
- "error": "",
- "loglevel": "warning"
- },
- "inbounds": [
- {
- "tag": "socks",
- "port": 10808,
- "listen": "0.0.0.0",
- "protocol": "socks",
- "sniffing": {
- "enabled": true,
- "destOverride": [
- "http",
- "tls"
- ],
- "routeOnly": false
- },
- "settings": {
- "auth": "noauth",
- "udp": true,
- "allowTransparent": false
- }
- },
- {
- "tag": "http",
- "port": 10809,
- "listen": "0.0.0.0",
- "protocol": "http",
- "sniffing": {
- "enabled": true,
- "destOverride": [
- "http",
- "tls"
- ],
- "routeOnly": false
- },
- "settings": {
- "auth": "noauth",
- "udp": true,
- "allowTransparent": false
- }
- }
- ],
- "outbounds": [],
- "dns": {
- "servers": [
- "1.1.1.1",
- "8.8.8.8"
- ]
- },
- "routing": {
- "domainStrategy": "AsIs",
- "rules": []
- }
-}
\ No newline at end of file
diff --git a/config.py b/config.py
index b56fec2f1..666914e69 100644
--- a/config.py
+++ b/config.py
@@ -75,15 +75,6 @@
SUBSCRIPTION_PAGE_TEMPLATE = config("SUBSCRIPTION_PAGE_TEMPLATE", default="subscription/index.html")
HOME_PAGE_TEMPLATE = config("HOME_PAGE_TEMPLATE", default="home/index.html")
-CLASH_SUBSCRIPTION_TEMPLATE = config("CLASH_SUBSCRIPTION_TEMPLATE", default="clash/default.yml")
-
-SINGBOX_SUBSCRIPTION_TEMPLATE = config("SINGBOX_SUBSCRIPTION_TEMPLATE", default="singbox/default.json")
-
-XRAY_SUBSCRIPTION_TEMPLATE = config("XRAY_SUBSCRIPTION_TEMPLATE", default="xray/default.json")
-
-USER_AGENT_TEMPLATE = config("USER_AGENT_TEMPLATE", default="user_agent/default.json")
-GRPC_USER_AGENT_TEMPLATE = config("GRPC_USER_AGENT_TEMPLATE", default="user_agent/grpc.json")
-
EXTERNAL_CONFIG = config("EXTERNAL_CONFIG", default="", cast=str)
USERS_AUTODELETE_DAYS = config("USERS_AUTODELETE_DAYS", default=-1, cast=int)
diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json
index 558bcd743..15f070ed4 100644
--- a/dashboard/public/statics/locales/en.json
+++ b/dashboard/public/statics/locales/en.json
@@ -644,7 +644,8 @@
"warning": "Warning",
"remove": "Remove",
"modify": "Modify",
- "templates.userTemplates": "Templates",
+ "templates.userTemplates": "User Templates",
+ "templates.clientTemplates": "Client Templates",
"editUserTemplateModal.title": "Modify User Template",
"userTemplateModal.title": "Create User Template",
"templates": {
@@ -684,6 +685,30 @@
"noTemplatesDescription": "Get started by creating your first user template.",
"noSearchResults": "No templates match your search criteria. Try adjusting your search terms."
},
+ "clientTemplates": {
+ "title": "Client Templates",
+ "description": "Manage subscription and user-agent templates",
+ "addTemplate": "Create Template",
+ "editTemplate": "Edit Client Template",
+ "templateType": "Template Type",
+ "selectType": "Select type",
+ "content": "Content",
+ "contentPlaceholder": "Template content...",
+ "namePlaceholder": "Template name",
+ "isDefault": "Set as default",
+ "createSuccess": "Template \"{{name}}\" created successfully",
+ "updateSuccess": "Template \"{{name}}\" updated successfully",
+ "deleteSuccess": "Template \"{{name}}\" deleted successfully",
+ "deleteFailed": "Failed to delete template \"{{name}}\"",
+ "duplicateSuccess": "Template \"{{name}}\" duplicated successfully",
+ "duplicateFailed": "Failed to duplicate template \"{{name}}\"",
+ "saveFailed": "Failed to save template",
+ "deleteTitle": "Delete Client Template",
+ "deletePrompt": "Are you sure you want to delete {{name}}? This action cannot be undone.",
+ "noTemplates": "No client templates",
+ "noTemplatesDescription": "Create a client template to customize subscription output formats.",
+ "noSearchResults": "No client templates match your search."
+ },
"core.configuration": "Configuration",
"core.generalErrorMessage": "Something went wrong, please check the configuration",
"core.logs": "Logs",
diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json
index 5ed20abdf..876dcc186 100644
--- a/dashboard/public/statics/locales/fa.json
+++ b/dashboard/public/statics/locales/fa.json
@@ -532,7 +532,8 @@
"warning": "هشدار",
"remove": "حذف",
"modify": "ویرایش",
- "templates.userTemplates": "قالبها",
+ "templates.userTemplates": "قالبهای کاربر",
+ "templates.clientTemplates": "قالبهای کلاینت",
"editUserTemplateModal.title": "ویرایش قالب کاربر",
"userTemplateModal.title": "ایجاد قالب کاربر",
"templates": {
@@ -572,6 +573,30 @@
"noTemplatesDescription": "با ایجاد اولین قالب کاربر شروع کنید.",
"noSearchResults": "هیچ قالبی با معیارهای جستجوی شما مطابقت ندارد. لطفاً عبارات جستجوی خود را تغییر دهید."
},
+ "clientTemplates": {
+ "title": "قالبهای کلاینت",
+ "description": "مدیریت قالبهای اشتراک و عامل کاربر",
+ "addTemplate": "ایجاد قالب",
+ "editTemplate": "ویرایش قالب کلاینت",
+ "templateType": "نوع قالب",
+ "selectType": "انتخاب نوع",
+ "content": "محتوا",
+ "contentPlaceholder": "محتوای قالب...",
+ "namePlaceholder": "نام قالب",
+ "isDefault": "تنظیم به عنوان پیشفرض",
+ "createSuccess": "قالب «{{name}}» با موفقیت ایجاد شد",
+ "updateSuccess": "قالب «{{name}}» با موفقیت بهروزرسانی شد",
+ "deleteSuccess": "قالب «{{name}}» با موفقیت حذف شد",
+ "deleteFailed": "حذف قالب «{{name}}» ناموفق بود",
+ "duplicateSuccess": "قالب «{{name}}» با موفقیت تکثیر شد",
+ "duplicateFailed": "تکثیر قالب «{{name}}» ناموفق بود",
+ "saveFailed": "ذخیره قالب ناموفق بود",
+ "deleteTitle": "حذف قالب کلاینت",
+ "deletePrompt": "آیا مطمئن هستید که میخواهید {{name}} را حذف کنید؟ این عمل قابل بازگشت نیست.",
+ "noTemplates": "قالب کلاینتی وجود ندارد",
+ "noTemplatesDescription": "یک قالب کلاینت ایجاد کنید تا فرمتهای خروجی اشتراک را سفارشی کنید.",
+ "noSearchResults": "هیچ قالب کلاینتی با جستجوی شما مطابقت ندارد."
+ },
"core.configuration": "پیکربندی",
"core.generalErrorMessage": "مشکلی پیش آمده، لطفا پیکربندی را بررسی کنید",
"core.logs": "گزارش",
diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json
index 1e6370eeb..f4d149246 100644
--- a/dashboard/public/statics/locales/ru.json
+++ b/dashboard/public/statics/locales/ru.json
@@ -630,7 +630,8 @@
"warning": "Предупреждение",
"remove": "Удалить",
"modify": "Изменить",
- "templates.userTemplates": "Шаблоны",
+ "templates.userTemplates": "Шаблоны пользователей",
+ "templates.clientTemplates": "Шаблоны клиентов",
"editUserTemplateModal.title": "Редактировать шаблон пользователя",
"userTemplateModal.title": "Создать шаблон пользователя",
"templates": {
@@ -670,6 +671,30 @@
"noTemplatesDescription": "Начните с создания первого шаблона пользователя.",
"noSearchResults": "Нет шаблонов, соответствующих вашим критериям поиска. Попробуйте изменить условия поиска."
},
+ "clientTemplates": {
+ "title": "Шаблоны клиентов",
+ "description": "Управление шаблонами подписок и пользовательских агентов",
+ "addTemplate": "Создать шаблон",
+ "editTemplate": "Редактировать шаблон клиента",
+ "templateType": "Тип шаблона",
+ "selectType": "Выберите тип",
+ "content": "Содержимое",
+ "contentPlaceholder": "Содержимое шаблона...",
+ "namePlaceholder": "Название шаблона",
+ "isDefault": "Установить по умолчанию",
+ "createSuccess": "Шаблон «{{name}}» успешно создан",
+ "updateSuccess": "Шаблон «{{name}}» успешно обновлён",
+ "deleteSuccess": "Шаблон «{{name}}» успешно удалён",
+ "deleteFailed": "Не удалось удалить шаблон «{{name}}»",
+ "duplicateSuccess": "Шаблон «{{name}}» успешно продублирован",
+ "duplicateFailed": "Не удалось продублировать шаблон «{{name}}»",
+ "saveFailed": "Не удалось сохранить шаблон",
+ "deleteTitle": "Удалить шаблон клиента",
+ "deletePrompt": "Вы уверены, что хотите удалить {{name}}? Это действие нельзя отменить.",
+ "noTemplates": "Нет шаблонов клиентов",
+ "noTemplatesDescription": "Создайте шаблон клиента для настройки форматов вывода подписок.",
+ "noSearchResults": "Нет шаблонов клиентов, соответствующих вашему запросу."
+ },
"core.configuration": "Конфигурация",
"core.generalErrorMessage": "Что-то пошло не так, пожалуйста, проверьте конфигурацию",
"core.logs": "Логи",
diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json
index 6dace73b1..4a414dfd7 100644
--- a/dashboard/public/statics/locales/zh.json
+++ b/dashboard/public/statics/locales/zh.json
@@ -644,7 +644,8 @@
"warning": "警告",
"remove": "删除",
"modify": "修改",
- "templates.userTemplates": "模板",
+ "templates.userTemplates": "用户模板",
+ "templates.clientTemplates": "客户端模板",
"editUserTemplateModal.title": "编辑用户模板",
"userTemplateModal.title": "创建用户模板",
"templates": {
@@ -684,6 +685,30 @@
"noTemplatesDescription": "开始创建您的第一个用户模板。",
"noSearchResults": "没有模板匹配您的搜索条件。请尝试调整搜索词。"
},
+ "clientTemplates": {
+ "title": "客户端模板",
+ "description": "管理订阅和用户代理模板",
+ "addTemplate": "创建模板",
+ "editTemplate": "编辑客户端模板",
+ "templateType": "模板类型",
+ "selectType": "选择类型",
+ "content": "内容",
+ "contentPlaceholder": "模板内容...",
+ "namePlaceholder": "模板名称",
+ "isDefault": "设为默认",
+ "createSuccess": "模板「{{name}}」创建成功",
+ "updateSuccess": "模板「{{name}}」更新成功",
+ "deleteSuccess": "模板「{{name}}」删除成功",
+ "deleteFailed": "删除模板「{{name}}」失败",
+ "duplicateSuccess": "模板「{{name}}」复制成功",
+ "duplicateFailed": "复制模板「{{name}}」失败",
+ "saveFailed": "保存模板失败",
+ "deleteTitle": "删除客户端模板",
+ "deletePrompt": "确定要删除 {{name}} 吗?此操作无法撤销。",
+ "noTemplates": "暂无客户端模板",
+ "noTemplatesDescription": "创建客户端模板以自定义订阅输出格式。",
+ "noSearchResults": "没有匹配的客户端模板。"
+ },
"core.configuration": "配置",
"core.generalErrorMessage": "配置有误, 请检查",
"core.logs": "日志",
diff --git a/dashboard/src/components/common/mobile-yaml-ace-editor.tsx b/dashboard/src/components/common/mobile-yaml-ace-editor.tsx
new file mode 100644
index 000000000..96bc2d7de
--- /dev/null
+++ b/dashboard/src/components/common/mobile-yaml-ace-editor.tsx
@@ -0,0 +1,43 @@
+import AceEditor from 'react-ace'
+import 'ace-builds/src-noconflict/mode-yaml'
+import 'ace-builds/src-noconflict/theme-monokai'
+import 'ace-builds/src-noconflict/theme-tomorrow_night'
+
+interface MobileYamlAceEditorProps {
+ value: string
+ theme?: string
+ onChange: (value: string) => void
+ onLoad?: (editor: any) => void
+}
+
+export default function MobileYamlAceEditor({ value, theme, onChange, onLoad }: MobileYamlAceEditorProps) {
+ return (
+