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..8c83b25f0 --- /dev/null +++ b/app/db/crud/client_template.py @@ -0,0 +1,251 @@ +from collections import defaultdict +from collections.abc import Mapping +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 +from app.subscription.default_templates import DEFAULT_TEMPLATE_CONTENTS_BY_LEGACY_KEY + +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(), + }, +) + + +def get_default_client_template_contents() -> dict[str, str]: + return DEFAULT_TEMPLATE_CONTENTS_BY_LEGACY_KEY.copy() + + +def merge_client_template_values(values: Mapping[str, str] | None = None) -> dict[str, str]: + merged = get_default_client_template_contents() + if not values: + return merged + + for key, value in values.items(): + if key in merged and value: + merged[key] = value + + return merged + + +async def get_client_template_values(db: AsyncSession) -> dict[str, str]: + defaults = get_default_client_template_contents() + 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 defaults + + 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 merge_client_template_values(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..aba442311 100644 --- a/app/templates/user_agent/default.json +++ b/app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py @@ -1,3 +1,228 @@ +"""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": [], + "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", @@ -101,4 +326,156 @@ "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..d9385c90b 100644 --- a/app/subscription/base.py +++ b/app/subscription/base.py @@ -4,20 +4,30 @@ 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 + +from .default_templates import DEFAULT_GRPC_USER_AGENT_TEMPLATE, DEFAULT_USER_AGENT_TEMPLATE class BaseSubscription: - def __init__(self): + def __init__( + self, + user_agent_template_content: str | None = None, + grpc_user_agent_template_content: str | None = None, + ): + if user_agent_template_content is None: + user_agent_template_content = DEFAULT_USER_AGENT_TEMPLATE + if grpc_user_agent_template_content is None: + grpc_user_agent_template_content = DEFAULT_GRPC_USER_AGENT_TEMPLATE + 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..ab9fabc9a 100644 --- a/app/subscription/clash.py +++ b/app/subscription/clash.py @@ -10,18 +10,25 @@ 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 +from .default_templates import DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE 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 or DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE self.data = { "proxies": [], "proxy-groups": [], @@ -55,7 +62,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 +298,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/default_templates.py b/app/subscription/default_templates.py new file mode 100644 index 000000000..36a82b4c2 --- /dev/null +++ b/app/subscription/default_templates.py @@ -0,0 +1,228 @@ +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": [], + "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 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1" + ] +} +""" + +DEFAULT_GRPC_USER_AGENT_TEMPLATE = """{ + "list": [ + "grpc-dotnet/2.41.0 (.NET 6.0.1; CLR 6.0.1; 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-ruby/1.62.0 grpc-c/39.0.0 (osx; chttp2)" + ] +} +""" + +DEFAULT_TEMPLATE_CONTENTS_BY_LEGACY_KEY = { + "CLASH_SUBSCRIPTION_TEMPLATE": DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE, + "XRAY_SUBSCRIPTION_TEMPLATE": DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE, + "SINGBOX_SUBSCRIPTION_TEMPLATE": DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE, + "USER_AGENT_TEMPLATE": DEFAULT_USER_AGENT_TEMPLATE, + "GRPC_USER_AGENT_TEMPLATE": DEFAULT_GRPC_USER_AGENT_TEMPLATE, +} 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..db0a99332 100644 --- a/app/subscription/singbox.py +++ b/app/subscription/singbox.py @@ -8,17 +8,27 @@ 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 +from .default_templates import DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE 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, + ) + if singbox_template_content is None: + singbox_template_content = DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE + 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..2b575b11b 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -11,18 +11,28 @@ 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 +from .default_templates import DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE 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) + if xray_template_content is None: + xray_template_content = DEFAULT_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/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/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/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/tests/api/conftest.py b/tests/api/conftest.py index 835aca985..382130c16 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -5,13 +5,14 @@ from app.db.models import Settings -from . import TestSession, client +from . import GetTestDB, TestSession, client @pytest.fixture(autouse=True) def mock_db_session(monkeypatch: pytest.MonkeyPatch): db_session = MagicMock(spec=TestSession) monkeypatch.setattr("app.settings.GetDB", db_session) + monkeypatch.setattr("app.subscription.client_templates.GetDB", GetTestDB) return db_session diff --git a/tests/api/helpers.py b/tests/api/helpers.py index 93ff38d7c..5d9ae255b 100644 --- a/tests/api/helpers.py +++ b/tests/api/helpers.py @@ -6,10 +6,9 @@ from fastapi import status +from config import NATS_ENABLED, ROLE from tests.api import client from tests.api.sample_data import XRAY_CONFIG -from config import ROLE, NATS_ENABLED - _WAIT_FOR_INBOUNDS = ROLE.requires_nats and NATS_ENABLED _INBOUNDS_RETRIES = 10 @@ -77,6 +76,30 @@ def delete_core(access_token: str, core_id: int) -> None: assert response.status_code in (status.HTTP_204_NO_CONTENT, status.HTTP_403_FORBIDDEN) +def create_client_template( + access_token: str, + *, + name: str | None = None, + template_type: str = "xray_subscription", + content: str = '{"outbounds": [{"tag":"direct","protocol":"freedom","settings":{}}],"inbounds":[{"tag":"proxy","protocol":"vmess","settings":{"clients":[{"id":"uuid","alterId":0,"email":"}', + is_default: bool = False, +) -> dict: + payload = { + "name": name or unique_name("client_template"), + "template_type": template_type, + "content": content, + "is_default": is_default, + } + response = client.post("/api/client_template", headers=auth_headers(access_token), json=payload) + assert response.status_code == status.HTTP_201_CREATED + return response.json() + + +def delete_client_template(access_token: str, template_id: int) -> None: + response = client.delete(f"/api/client_template/{template_id}", headers=auth_headers(access_token)) + assert response.status_code in (status.HTTP_204_NO_CONTENT, status.HTTP_403_FORBIDDEN) + + def get_inbounds(access_token: str) -> list[str]: def _fetch() -> tuple[int, list[str]]: response = client.get("/api/inbounds", headers=auth_headers(access_token)) diff --git a/tests/api/test_client_template.py b/tests/api/test_client_template.py new file mode 100644 index 000000000..f68908d68 --- /dev/null +++ b/tests/api/test_client_template.py @@ -0,0 +1,111 @@ +from fastapi import status + +from tests.api import client +from tests.api.helpers import create_client_template, unique_name + + +def test_client_template_create_and_get(access_token): + created = create_client_template( + access_token, + name=unique_name("tmpl_clash"), + template_type="clash_subscription", + content="proxies: []\nproxy-groups: []\nrules: []\n", + ) + + assert created["name"] + assert created["template_type"] == "clash_subscription" + assert created["content"] + assert isinstance(created["is_default"], bool) + assert isinstance(created["is_system"], bool) + + response = client.get( + f"/api/client_template/{created['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == created["id"] + + +def test_client_template_can_switch_default(access_token): + first = create_client_template( + access_token, + name=unique_name("tmpl_sb_first"), + template_type="singbox_subscription", + content='{"outbounds": [{"type": "direct", "tag": "a"}],"inbounds":[{"type": "socks5","tag":"b","settings":{"clients":[{"username":"user","password":"pass"}]}}]}', + ) + second = create_client_template( + access_token, + name=unique_name("tmpl_sb_second"), + template_type="singbox_subscription", + content='{"outbounds": [{"type": "direct", "tag": "a"}],"inbounds":[{"type": "socks5","tag":"b","settings":{"clients":[{"username":"user","password":"pass"}]}}]}', + is_default=True, + ) + + first_after = client.get( + f"/api/client_template/{first['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ).json() + second_after = client.get( + f"/api/client_template/{second['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ).json() + + assert first_after["is_default"] is False + assert second_after["is_default"] is True + + +def test_client_template_cannot_delete_first_template(access_token): + response = client.get( + "/api/client_templates", + params={"template_type": "grpc_user_agent"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + templates = response.json()["templates"] + + if templates: + first = min(templates, key=lambda template: template["id"]) + else: + first = create_client_template( + access_token, + name=unique_name("tmpl_grpc_first"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent"]}', + ) + + response = client.delete( + f"/api/client_template/{first['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_client_template_can_delete_non_first_template(access_token): + response = client.get( + "/api/client_templates", + params={"template_type": "grpc_user_agent"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + templates = response.json()["templates"] + + if not templates: + create_client_template( + access_token, + name=unique_name("tmpl_grpc_seed_first"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent-seed"]}', + ) + + second = create_client_template( + access_token, + name=unique_name("tmpl_grpc_second"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent-2"]}', + ) + + response = client.delete( + f"/api/client_template/{second['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT