Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/core/hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,9 @@ async def _prepare_subscription_inbound_data(
fragment_settings=host.fragment_settings.model_dump() if host.fragment_settings else None,
noise_settings=host.noise_settings.model_dump() if host.noise_settings else None,
finalmask=finalmask,
subscription_templates=host.subscription_templates.model_dump(exclude_none=True)
if host.subscription_templates
else None,
priority=host.priority,
status=list(host.status) if host.status else None,
)
Expand Down
2 changes: 1 addition & 1 deletion app/db/crud/general.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import datetime, timezone, timedelta
from typing import Optional

from sqlalchemy import DateTime, String, cast, func, or_, select, text
from sqlalchemy import String, func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.models import JWT, System
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""add subscription_templates to hosts

Revision ID: f1c2d3e4b5a6
Revises: 20e2a5cf1e40
Create Date: 2026-02-08 00:00:00.000000

"""

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "f1c2d3e4b5a6"
down_revision = "20e2a5cf1e40"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("hosts", sa.Column("subscription_templates", sa.JSON(), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("hosts", "subscription_templates")
# ### end Alembic commands ###

1 change: 1 addition & 0 deletions app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ class ProxyHost(Base):
http_headers: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
transport_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
mux_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
subscription_templates: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
status: Mapped[Optional[list[UserStatus]]] = mapped_column(
EnumArray(UserStatus, 60), default=list, server_default=""
)
Expand Down
15 changes: 15 additions & 0 deletions app/models/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,20 @@ class TransportSettings(BaseModel):
websocket_settings: WebSocketSettings | None = Field(default=None)


class SubscriptionTemplates(BaseModel):
"""
Per-host subscription template overrides.

Keys are subscription formats (e.g. xray). Values are template paths resolved via the
existing template loader (`CUSTOM_TEMPLATES_DIRECTORY` first, then `app/templates`).
"""

xray: str | None = Field(default=None)

# Allow future keys without requiring immediate backend changes
model_config = ConfigDict(extra="allow")


class FormatVariables(dict):
def __missing__(self, key):
return key.join("{}")
Expand All @@ -232,6 +246,7 @@ class BaseHost(BaseModel):
mux_settings: MuxSettings | None = Field(default=None)
fragment_settings: FragmentSettings | None = Field(default=None)
noise_settings: NoiseSettings | None = Field(default=None)
subscription_templates: SubscriptionTemplates | None = Field(default=None)
random_user_agent: bool = Field(default=False)
use_sni_as_host: bool = Field(default=False)
vless_route: str | None = Field(default=None, pattern=r"^$|^[0-9a-fA-F]{4}$")
Expand Down
3 changes: 3 additions & 0 deletions app/models/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ class SubscriptionInboundData(BaseModel):
noise_settings: dict[str, Any] | None = Field(None)
finalmask: dict[str, Any] | None = Field(None)

# Per-host subscription template overrides (extensible; e.g. {"xray": "xray/custom.json"})
subscription_templates: dict[str, Any] | None = Field(None)

# Priority and status
priority: int = Field(0)
status: list[str] | None = Field(None)
Expand Down
26 changes: 26 additions & 0 deletions app/operation/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
remove_host,
)
from app.core.hosts import host_manager
from app.templates import get_subscription_templates
from app.utils.logger import get_logger

from app import notification
Expand All @@ -25,6 +26,28 @@ class HostOperation(BaseOperation):
async def get_hosts(self, db: AsyncSession, offset: int = 0, limit: int = 0) -> list[BaseHost]:
return await get_hosts(db=db, offset=offset, limit=limit)

async def validate_subscription_templates(self, host: CreateHost, db: AsyncSession):
"""
Validate per-host subscription template overrides.

Checks that provided template paths are among the available templates
returned by get_subscription_templates().
"""
st = getattr(host, "subscription_templates", None)
if not st or not getattr(st, "xray", None):
return

template_path = (st.xray or "").strip()
if not template_path:
return await self.raise_error("subscription_templates.xray cannot be empty", 400, db=db)

available = get_subscription_templates()
valid_xray = available.get("xray", [])
if template_path not in valid_xray:
return await self.raise_error(
f'Xray template "{template_path}" is not available. Valid options: {valid_xray}', 400, db=db
)

async def validate_ds_host(self, db: AsyncSession, host: CreateHost, host_id: int | None = None) -> ProxyHost:
if (
host.transport_settings
Expand All @@ -45,6 +68,7 @@ async def validate_ds_host(self, db: AsyncSession, host: CreateHost, host_id: in

async def create_host(self, db: AsyncSession, new_host: CreateHost, admin: AdminDetails) -> BaseHost:
await self.validate_ds_host(db, new_host)
await self.validate_subscription_templates(new_host, db)

await self.check_inbound_tags([new_host.inbound_tag])

Expand All @@ -63,6 +87,7 @@ async def modify_host(
self, db: AsyncSession, host_id: int, modified_host: CreateHost, admin: AdminDetails
) -> BaseHost:
await self.validate_ds_host(db, modified_host, host_id)
await self.validate_subscription_templates(modified_host, db)

if modified_host.inbound_tag:
await self.check_inbound_tags([modified_host.inbound_tag])
Expand Down Expand Up @@ -96,6 +121,7 @@ async def modify_hosts(
) -> list[BaseHost]:
for host in modified_hosts:
await self.validate_ds_host(db, host, host.id)
await self.validate_subscription_templates(host, db)

old_host: ProxyHost | None = None
if host.id is not None:
Expand Down
10 changes: 10 additions & 0 deletions app/routers/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from app.models.host import BaseHost, CreateHost
from app.operation import OperatorType
from app.operation.host import HostOperation
from app.templates import get_subscription_templates
from app.utils import responses

from .authentication import check_sudo_admin
Expand All @@ -13,6 +14,15 @@
router = APIRouter(tags=["Host"], prefix="/api/host", responses={401: responses._401, 403: responses._403})


@router.get("/subscription-templates", response_model=dict[str, list[str]])
async def list_subscription_templates(_: AdminDetails = Depends(check_sudo_admin)):
"""
List available custom subscription template files grouped by format (xray, clash, singbox).
Scans CUSTOM_TEMPLATES_DIRECTORY only; built-in defaults are excluded.
"""
return get_subscription_templates()


@router.get("/{host_id}", response_model=BaseHost)
async def get_host(host_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)):
"""
Expand Down
17 changes: 13 additions & 4 deletions app/subscription/xray.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class XrayConfiguration(BaseSubscription):
def __init__(self):
super().__init__()
self.config = []
self.template = render_template(XRAY_SUBSCRIPTION_TEMPLATE)
self._template_cache: dict[str, str] = {}

# Registry for transport handlers
self.transport_handlers = {
Expand All @@ -47,8 +47,14 @@ def __init__(self):
"shadowsocks": self._build_shadowsocks,
}

def add_config(self, remarks, outbounds):
json_template = json.loads(self.template)
def _get_template(self, template_path: str) -> str:
if template_path not in self._template_cache:
self._template_cache[template_path] = render_template(template_path)
return self._template_cache[template_path]

def add_config(self, remarks, outbounds, template_path: str | None = None):
template_path = template_path or XRAY_SUBSCRIPTION_TEMPLATE
json_template = json.loads(self._get_template(template_path))
json_template["remarks"] = remarks
json_template["outbounds"] = outbounds + json_template["outbounds"]
self.config.append(json_template)
Expand Down Expand Up @@ -80,7 +86,10 @@ def add(self, remark: str, address: str, inbound: SubscriptionInboundData, setti
# Shadowsocks returns just a dict
all_outbounds = [result]

self.add_config(remarks=remark, outbounds=all_outbounds)
template_path = None
if inbound.subscription_templates and isinstance(inbound.subscription_templates, dict):
template_path = inbound.subscription_templates.get("xray")
self.add_config(remarks=remark, outbounds=all_outbounds, template_path=template_path)

# ========== Transport Handlers (Registry Methods) ==========

Expand Down
40 changes: 39 additions & 1 deletion app/templates/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import os
from datetime import datetime as dt, timezone as tz
from typing import Union

import jinja2

from config import CUSTOM_TEMPLATES_DIRECTORY
from config import (
CLASH_SUBSCRIPTION_TEMPLATE,
CUSTOM_TEMPLATES_DIRECTORY,
SINGBOX_SUBSCRIPTION_TEMPLATE,
XRAY_SUBSCRIPTION_TEMPLATE,
)

from .filters import CUSTOM_FILTERS

Expand All @@ -19,3 +25,35 @@

def render_template(template: str, context: Union[dict, None] = None) -> str:
return env.get_template(template).render(context or {})


DEFAULT_TEMPLATES = {
XRAY_SUBSCRIPTION_TEMPLATE,
CLASH_SUBSCRIPTION_TEMPLATE,
SINGBOX_SUBSCRIPTION_TEMPLATE,
}


def get_subscription_templates() -> dict[str, list[str]]:
"""List available custom subscription template files grouped by format.

Only scans CUSTOM_TEMPLATES_DIRECTORY (if configured).
Default templates are excluded since they are already the global fallback.
"""
if not CUSTOM_TEMPLATES_DIRECTORY:
return {"xray": [], "clash": [], "singbox": []}

formats = {"xray": ".json", "clash": ".yml", "singbox": ".json"}
result: dict[str, list[str]] = {}
for fmt, ext in formats.items():
templates: list[str] = []
fmt_dir = os.path.join(CUSTOM_TEMPLATES_DIRECTORY, fmt)
if os.path.isdir(fmt_dir):
for root, _, files in os.walk(fmt_dir):
for f in sorted(files):
if f.endswith(ext):
rel = os.path.relpath(os.path.join(root, f), CUSTOM_TEMPLATES_DIRECTORY)
if rel not in DEFAULT_TEMPLATES:
templates.append(rel)
result[fmt] = sorted(templates)
return result
5 changes: 4 additions & 1 deletion dashboard/public/statics/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1074,7 +1074,10 @@
"routingSettings": "Routing",
"vlessRoute": "VLESS Route",
"vlessRoutePlaceholder": "e.g. abcd",
"vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing."
"vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing.",
"subscriptionTemplates": "Subscription Templates",
"subscriptionTemplates.xray": "Xray Template",
"subscriptionTemplates.xray.info": "Override the Xray subscription template for this host. Select \"None\" to use the global default."
},
"inbound": "Inbound",
"inbounds": "Inbounds",
Expand Down
5 changes: 4 additions & 1 deletion dashboard/public/statics/locales/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,10 @@
"routingSettings": "Routing",
"vlessRoute": "VLESS Route",
"vlessRoutePlaceholder": "e.g. abcd",
"vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing."
"vlessRoute.info": "مقدار مسیر اختیاری برای هاست‌های VLESS. باید دقیقاً ۴ کاراکتر شانزده‌شانزدهی (0-9، a-f، A-F) باشد. برای استفاده از مسیریابی پیش‌فرض خالی بگذارید.",
"subscriptionTemplates": "قالب‌های اشتراک",
"subscriptionTemplates.xray": "قالب Xray",
"subscriptionTemplates.xray.info": "قالب اشتراک Xray را برای این هاست تغییر دهید. برای استفاده از قالب پیش‌فرض «پیش‌فرض» را انتخاب کنید."
},
"inbound": "ورودی",
"inbounds": "ورودی‌ها",
Expand Down
5 changes: 4 additions & 1 deletion dashboard/public/statics/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1232,7 +1232,10 @@
"routingSettings": "Routing",
"vlessRoute": "VLESS Route",
"vlessRoutePlaceholder": "e.g. abcd",
"vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing."
"vlessRoute.info": "Необязательное значение маршрута для хостов VLESS. Должно быть ровно 4 шестнадцатеричных символа (0-9, a-f, A-F). Оставьте пустым, чтобы использовать маршрут по умолчанию.",
"subscriptionTemplates": "Шаблоны подписок",
"subscriptionTemplates.xray": "Шаблон Xray",
"subscriptionTemplates.xray.info": "Переопределить шаблон подписки Xray для этого хоста. Выберите «По умолчанию» для использования глобального шаблона."
},
"enable": "Включить",
"host": {
Expand Down
5 changes: 4 additions & 1 deletion dashboard/public/statics/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,10 @@
"routingSettings": "Routing",
"vlessRoute": "VLESS Route",
"vlessRoutePlaceholder": "e.g. abcd",
"vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing."
"vlessRoute.info": "VLESS 主机的可选路由值。必须恰好为 4 个十六进制字符(0-9、a-f、A-F)。留空以使用默认路由。",
"subscriptionTemplates": "订阅模板",
"subscriptionTemplates.xray": "Xray 模板",
"subscriptionTemplates.xray.info": "覆盖此主机的 Xray 订阅模板。选择「默认」以使用全局模板。"
},
"inbound": "入站",
"inbounds": "入站",
Expand Down
47 changes: 46 additions & 1 deletion dashboard/src/components/dialogs/host-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { VariablesList, VariablesPopover } from '@/components/ui/variables-popover'
import useDirDetection from '@/hooks/use-dir-detection'
import { cn } from '@/lib/utils'
import { UserStatus, getHosts, getInbounds } from '@/service/api'
import { UserStatus, getHosts, getInbounds, useGetSubscriptionTemplates } from '@/service/api'
import { queryClient } from '@/utils/query-client'
import { useQuery } from '@tanstack/react-query'
import { Cable, Check, ChevronsLeftRightEllipsis, Copy, Edit, GlobeLock, Info, Loader2, Lock, Network, Plus, Route, Trash2, X } from 'lucide-react'
Expand Down Expand Up @@ -428,6 +428,8 @@ const HostModal: React.FC<HostModalProps> = ({ isDialogOpen, onOpenChange, onSub
const dir = useDirDetection()
const [_isSubmitting, setIsSubmitting] = useState(false)
const xPaddingObfsEnabled = form.watch('transport_settings.xhttp_settings.x_padding_obfs_mode') === true
const { data: subscriptionTemplates } = useGetSubscriptionTemplates()
const hasCustomTemplates = subscriptionTemplates && Object.values(subscriptionTemplates).some((list: string[]) => list.length > 0)

// Optimized noise settings handlers with useCallback for performance
const addNoiseSetting = useCallback(() => {
Expand Down Expand Up @@ -648,6 +650,49 @@ const HostModal: React.FC<HostModalProps> = ({ isDialogOpen, onOpenChange, onSub
)}
/>

{hasCustomTemplates && (
<FormField
control={form.control}
name="subscription_templates.xray"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>{t('hostsDialog.subscriptionTemplates.xray')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-4 w-4 p-0 hover:bg-transparent">
<Info className="h-4 w-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-3" side="right" align="start" sideOffset={5}>
<p className="text-[11px] text-muted-foreground">{t('hostsDialog.subscriptionTemplates.xray.info')}</p>
</PopoverContent>
</Popover>
</div>
<Select
onValueChange={v => field.onChange(v === '_default' ? null : v)}
value={field.value || '_default'}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="_default">{t('hostsDialog.inboundDefault')}</SelectItem>
{subscriptionTemplates?.xray?.map(tpl => (
<SelectItem key={tpl} value={tpl}>
{tpl}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}

<FormField
control={form.control}
name="status"
Expand Down
Loading