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
9 changes: 6 additions & 3 deletions app/db/crud/client_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ async def get_client_template_values(db: AsyncSession) -> dict[str, str]:
async def get_client_template_contents_by_type(db: AsyncSession, template_type: ClientTemplateType) -> dict[int, str]:
rows = (
await db.execute(
select(ClientTemplate.id, ClientTemplate.content).where(ClientTemplate.template_type == template_type.value)
select(ClientTemplate.id, ClientTemplate.content)
.where(ClientTemplate.template_type == template_type.value)
.order_by(ClientTemplate.id.asc())
)
).all()
return {row.id: row.content for row in rows}
Expand Down Expand Up @@ -209,7 +211,8 @@ async def clear_host_subscription_template_overrides(db: AsyncSession, 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
is_standalone_xray = client_template.template_type == ClientTemplateType.xray_standalone
should_be_default = not is_standalone_xray and (client_template.is_default or is_first_for_type)

if should_be_default:
await db.execute(
Expand All @@ -223,7 +226,7 @@ async def create_client_template(db: AsyncSession, client_template: ClientTempla
template_type=client_template.template_type.value,
content=client_template.content,
is_default=should_be_default,
is_system=is_first_for_type,
is_system=is_first_for_type and not is_standalone_xray,
)
db.add(db_template)
try:
Expand Down
1 change: 1 addition & 0 deletions app/models/client_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
class ClientTemplateType(StrEnum):
clash_subscription = "clash_subscription"
xray_subscription = "xray_subscription"
xray_standalone = "xray_standalone"
singbox_subscription = "singbox_subscription"
user_agent = "user_agent"
grpc_user_agent = "grpc_user_agent"
Expand Down
22 changes: 16 additions & 6 deletions app/operation/client_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ async def _validate_template_content(self, template_type: ClientTemplateType, co
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 template_type in (
ClientTemplateType.xray_subscription,
ClientTemplateType.xray_standalone,
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):
Expand All @@ -84,6 +88,10 @@ async def _validate_template_content(self, template_type: ClientTemplateType, co
)
if not out:
raise ValueError("Subscription template content must contain at least one outbound proxy")
if template_type == ClientTemplateType.xray_standalone:
remarks = parsed.get("remarks")
if not isinstance(remarks, str) or not remarks.strip():
raise ValueError("Standalone Xray template content must contain a non-empty 'remarks' field")
except Exception as exc:
await self.raise_error(message=f"Invalid template content: {exc!s}", code=400)

Expand Down Expand Up @@ -132,11 +140,13 @@ async def modify_client_template(
admin: AdminDetails,
) -> ClientTemplateResponse:
db_template = await self.get_validated_client_template(db, template_id)
template_type = ClientTemplateType(db_template.template_type)

if template_type == ClientTemplateType.xray_standalone and modified_template.is_default:
await self.raise_error(message="Standalone Xray profiles cannot be set as default", code=400)

if modified_template.content is not None:
await self._validate_template_content(
ClientTemplateType(db_template.template_type), modified_template.content
)
await self._validate_template_content(template_type, modified_template.content)

if modified_template.is_default is False and db_template.is_default:
await self.raise_error(
Expand All @@ -163,7 +173,7 @@ async def remove_client_template(self, db: AsyncSession, template_id: int, admin
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:
if template_type != ClientTemplateType.xray_standalone and template_count <= 1:
await self.raise_error(message="Cannot delete the last template for this type", code=403)

replacement = None
Expand Down Expand Up @@ -214,7 +224,7 @@ async def bulk_remove_client_templates(
# Validate we won't leave any type without templates
for template_type, templates_of_type in templates_by_type.items():
total_count = await count_client_templates_by_type(db, template_type)
if total_count <= len(templates_of_type):
if template_type != ClientTemplateType.xray_standalone and total_count <= len(templates_of_type):
await self.raise_error(
message=f"Cannot delete the last template for type {template_type.value}", code=403
)
Expand Down
7 changes: 7 additions & 0 deletions app/subscription/client_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@ async def subscription_xray_templates() -> dict[int, str]:
return await get_client_template_contents_by_type(db, ClientTemplateType.xray_subscription)


@cached()
async def subscription_standalone_xray_templates() -> dict[int, str]:
async with GetDB() as db:
return await get_client_template_contents_by_type(db, ClientTemplateType.xray_standalone)


async def refresh_client_templates_cache() -> None:
await subscription_client_templates.cache.clear()
await subscription_xray_templates.cache.clear()
await subscription_standalone_xray_templates.cache.clear()


async def handle_client_template_message(_: dict) -> None:
Expand Down
13 changes: 12 additions & 1 deletion app/subscription/share.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
from app.models.subscription import SubscriptionInboundData
from app.models.user import UsersResponseWithInbounds
from app.settings import subscription_settings
from app.subscription.client_templates import subscription_client_templates, subscription_xray_templates
from app.subscription.client_templates import (
subscription_client_templates,
subscription_standalone_xray_templates,
subscription_xray_templates,
)
from app.utils.system import readable_size

from . import (
Expand Down Expand Up @@ -85,6 +89,7 @@ async def generate_subscription(
) -> str | bytes:
client_templates = await subscription_client_templates()
xray_template_overrides = await subscription_xray_templates() if config_format == "xray" else None
standalone_xray_templates = await subscription_standalone_xray_templates() if config_format == "xray" else None
conf = _build_subscription_config(config_format, client_templates)
if conf is None:
raise ValueError(f'Unsupported format "{config_format}"')
Expand All @@ -99,6 +104,7 @@ async def generate_subscription(
conf,
client_templates,
xray_template_overrides=xray_template_overrides,
standalone_xray_templates=standalone_xray_templates,
randomize_order=randomize_order,
custom_variables=custom_variables,
)
Expand Down Expand Up @@ -419,6 +425,7 @@ async def process_inbounds_and_tags(
| WireGuardConfiguration,
client_templates: dict[str, str],
xray_template_overrides: dict[int, str] | None = None,
standalone_xray_templates: dict[int, str] | None = None,
randomize_order: bool = False,
custom_variables: list | tuple | None = None,
) -> str | bytes:
Expand Down Expand Up @@ -484,6 +491,10 @@ def _resolve_host_xray_template_content(inbound: SubscriptionInboundData) -> str
settings=settings,
)

if isinstance(conf, XrayConfiguration) and standalone_xray_templates:
for template_content in standalone_xray_templates.values():
conf.add_standalone(template_content, format_variables)

return conf.render()


Expand Down
20 changes: 20 additions & 0 deletions app/subscription/xray.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ def add_config(self, remarks, outbounds, template_content: str | None = None):
def render(self):
return json.dumps(self.config, indent=4, cls=UUIDEncoder)

@staticmethod
def _format_standalone_value(value, format_variables: dict):
if isinstance(value, str):
try:
return value.format_map(format_variables)
except ValueError, KeyError:
return value
if isinstance(value, list):
return [XrayConfiguration._format_standalone_value(item, format_variables) for item in value]
if isinstance(value, dict):
return {
key: XrayConfiguration._format_standalone_value(item, format_variables) for key, item in value.items()
}
return value

def add_standalone(self, template_content: str, format_variables: dict) -> None:
"""Add a complete client-only Xray profile without injecting a proxy outbound."""
profile = json.loads(template_content)
self.config.append(self._format_standalone_value(profile, format_variables))

def add(
self,
remark: str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import ClientTemplateMarkers from '@/features/templates/components/client-templa
const TEMPLATE_TYPE_LABELS: Record<string, string> = {
clash_subscription: 'Clash',
xray_subscription: 'Xray',
xray_standalone: 'Xray Standalone',
singbox_subscription: 'SingBox',
user_agent: 'User Agent',
grpc_user_agent: 'gRPC UA',
Expand Down
34 changes: 19 additions & 15 deletions dashboard/src/features/templates/dialogs/client-template-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { toast } from 'sonner'
const TEMPLATE_TYPE_LABELS: Record<string, string> = {
[ClientTemplateType.clash_subscription]: 'Clash Subscription',
[ClientTemplateType.xray_subscription]: 'Xray Subscription',
[ClientTemplateType.xray_standalone]: 'Standalone Xray Profile',
[ClientTemplateType.singbox_subscription]: 'SingBox Subscription',
[ClientTemplateType.user_agent]: 'User Agent',
[ClientTemplateType.grpc_user_agent]: 'gRPC User Agent',
Expand Down Expand Up @@ -50,6 +51,7 @@ export default function ClientTemplateModal({ isDialogOpen, onOpenChange, form,
const [validation, setValidation] = useState<ValidationResult>({ isValid: true })

const templateType = form.watch('template_type')
const isStandaloneXray = templateType === ClientTemplateType.xray_standalone
const isYaml = isYamlType(templateType)

const validateContent = useCallback(
Expand Down Expand Up @@ -252,21 +254,23 @@ export default function ClientTemplateModal({ isDialogOpen, onOpenChange, form,
)}
/>

<FormField
control={form.control}
name="is_default"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-1">
<FormLabel className="cursor-pointer">{t('clientTemplates.isDefault', { defaultValue: 'Set as default' })}</FormLabel>
<p className="text-muted-foreground text-xs">{t('clientTemplates.isDefaultDescription', { defaultValue: 'Use this template automatically for matching output type.' })}</p>
</div>
<FormControl>
<Switch checked={!!field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
{!isStandaloneXray && (
<FormField
control={form.control}
name="is_default"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-1">
<FormLabel className="cursor-pointer">{t('clientTemplates.isDefault', { defaultValue: 'Set as default' })}</FormLabel>
<p className="text-muted-foreground text-xs">{t('clientTemplates.isDefaultDescription', { defaultValue: 'Use this template automatically for matching output type.' })}</p>
</div>
<FormControl>
<Switch checked={!!field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
</div>
</div>
</div>
Expand Down
55 changes: 55 additions & 0 deletions dashboard/src/features/templates/forms/client-template-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const clientTemplateFormSchema = z.object({
template_type: z.enum([
ClientTemplateType.clash_subscription,
ClientTemplateType.xray_subscription,
ClientTemplateType.xray_standalone,
ClientTemplateType.singbox_subscription,
ClientTemplateType.user_agent,
ClientTemplateType.grpc_user_agent,
Expand Down Expand Up @@ -117,6 +118,60 @@ rules:
2,
),

[ClientTemplateType.xray_standalone]: JSON.stringify(
{
remarks: 'Fragment / Serverless Bypass',
log: {
loglevel: 'warning',
},
inbounds: [
{
tag: 'socks',
port: 10808,
listen: '127.0.0.1',
protocol: 'socks',
sniffing: { enabled: true, destOverride: ['http', 'tls', 'quic'] },
settings: { auth: 'noauth', udp: true },
},
{
tag: 'http',
port: 10809,
listen: '127.0.0.1',
protocol: 'http',
sniffing: { enabled: true, destOverride: ['http', 'tls'] },
settings: {},
},
],
outbounds: [
{
tag: 'DIRECT',
protocol: 'freedom',
settings: {
fragment: {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
},
},
},
{
tag: 'BLOCK',
protocol: 'blackhole',
settings: {},
},
],
dns: {
servers: ['1.1.1.1', '8.8.8.8'],
},
routing: {
domainStrategy: 'AsIs',
rules: [],
},
},
null,
2,
),

[ClientTemplateType.singbox_subscription]: JSON.stringify(
{
log: {
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/service/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3066,6 +3066,7 @@ export type ClientTemplateType = (typeof ClientTemplateType)[keyof typeof Client
export const ClientTemplateType = {
clash_subscription: 'clash_subscription',
xray_subscription: 'xray_subscription',
xray_standalone: 'xray_standalone',
singbox_subscription: 'singbox_subscription',
user_agent: 'user_agent',
grpc_user_agent: 'grpc_user_agent',
Expand Down
41 changes: 41 additions & 0 deletions tests/api/test_client_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,47 @@ def test_client_template_can_delete_non_first_template(access_token):
assert response.status_code == status.HTTP_204_NO_CONTENT


def test_standalone_xray_template_can_be_the_only_template_and_deleted(access_token):
content = (
'{"remarks":"Serverless","inbounds":[{"tag":"socks","protocol":"socks","port":10808,'
'"settings":{}}],"outbounds":[{"tag":"DIRECT","protocol":"freedom","settings":{}}]}'
)
created = create_client_template(
access_token,
name=unique_name("tmpl_xray_standalone"),
template_type="xray_standalone",
content=content,
)

assert created["template_type"] == "xray_standalone"
assert created["is_default"] is False
assert created["is_system"] is False

response = client.delete(
f"/api/client_template/{created['id']}",
headers=auth_headers(access_token),
)
assert response.status_code == status.HTTP_204_NO_CONTENT


def test_standalone_xray_template_requires_remarks(access_token):
response = client.post(
"/api/client_template",
headers=auth_headers(access_token),
json={
"name": unique_name("tmpl_xray_standalone_no_remarks"),
"template_type": "xray_standalone",
"content": (
'{"inbounds":[{"tag":"socks","protocol":"socks","port":10808,"settings":{}}],'
'"outbounds":[{"tag":"DIRECT","protocol":"freedom","settings":{}}]}'
),
},
)

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "remarks" in response.json()["detail"]


def test_client_template_delete_clears_associated_host_override(access_token):
core = create_core(access_token)
inbound_list = get_inbounds(access_token)
Expand Down
Loading