From b4af4b9060be854fa950d7c09fcda53c59d3f452 Mon Sep 17 00:00:00 2001 From: mustfqyl <232841901+mustfqyl@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:54:53 +0300 Subject: [PATCH] feat(subscription): support standalone Xray profiles --- app/db/crud/client_template.py | 9 ++- app/models/client_template.py | 1 + app/operation/client_template.py | 22 ++++++-- app/subscription/client_templates.py | 7 +++ app/subscription/share.py | 13 ++++- app/subscription/xray.py | 20 +++++++ .../use-client-templates-list-columns.tsx | 1 + .../dialogs/client-template-modal.tsx | 34 +++++++----- .../templates/forms/client-template-form.ts | 55 +++++++++++++++++++ dashboard/src/service/api/index.ts | 1 + tests/api/test_client_template.py | 41 ++++++++++++++ tests/api/test_user.py | 30 ++++++++++ tests/test_subscription_xray_standalone.py | 38 +++++++++++++ 13 files changed, 247 insertions(+), 25 deletions(-) create mode 100644 tests/test_subscription_xray_standalone.py diff --git a/app/db/crud/client_template.py b/app/db/crud/client_template.py index 35fea3b7a..9491473ce 100644 --- a/app/db/crud/client_template.py +++ b/app/db/crud/client_template.py @@ -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} @@ -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( @@ -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: diff --git a/app/models/client_template.py b/app/models/client_template.py index 29dd67dab..02953391a 100644 --- a/app/models/client_template.py +++ b/app/models/client_template.py @@ -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" diff --git a/app/operation/client_template.py b/app/operation/client_template.py index 6c218aefa..df8ab944a 100644 --- a/app/operation/client_template.py +++ b/app/operation/client_template.py @@ -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): @@ -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) @@ -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( @@ -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 @@ -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 ) diff --git a/app/subscription/client_templates.py b/app/subscription/client_templates.py index b7edae517..e36b899bf 100644 --- a/app/subscription/client_templates.py +++ b/app/subscription/client_templates.py @@ -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: diff --git a/app/subscription/share.py b/app/subscription/share.py index 9790713c5..6ea3f14d5 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -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 ( @@ -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}"') @@ -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, ) @@ -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: @@ -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() diff --git a/app/subscription/xray.py b/app/subscription/xray.py index fdd421fa7..302771c7d 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -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, diff --git a/dashboard/src/features/templates/components/use-client-templates-list-columns.tsx b/dashboard/src/features/templates/components/use-client-templates-list-columns.tsx index ac130f9ea..57c295401 100644 --- a/dashboard/src/features/templates/components/use-client-templates-list-columns.tsx +++ b/dashboard/src/features/templates/components/use-client-templates-list-columns.tsx @@ -9,6 +9,7 @@ import ClientTemplateMarkers from '@/features/templates/components/client-templa const TEMPLATE_TYPE_LABELS: Record = { clash_subscription: 'Clash', xray_subscription: 'Xray', + xray_standalone: 'Xray Standalone', singbox_subscription: 'SingBox', user_agent: 'User Agent', grpc_user_agent: 'gRPC UA', diff --git a/dashboard/src/features/templates/dialogs/client-template-modal.tsx b/dashboard/src/features/templates/dialogs/client-template-modal.tsx index 7037bd832..7f4e5929d 100644 --- a/dashboard/src/features/templates/dialogs/client-template-modal.tsx +++ b/dashboard/src/features/templates/dialogs/client-template-modal.tsx @@ -21,6 +21,7 @@ import { toast } from 'sonner' const TEMPLATE_TYPE_LABELS: Record = { [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', @@ -50,6 +51,7 @@ export default function ClientTemplateModal({ isDialogOpen, onOpenChange, form, const [validation, setValidation] = useState({ isValid: true }) const templateType = form.watch('template_type') + const isStandaloneXray = templateType === ClientTemplateType.xray_standalone const isYaml = isYamlType(templateType) const validateContent = useCallback( @@ -252,21 +254,23 @@ export default function ClientTemplateModal({ isDialogOpen, onOpenChange, form, )} /> - ( - -
- {t('clientTemplates.isDefault', { defaultValue: 'Set as default' })} -

{t('clientTemplates.isDefaultDescription', { defaultValue: 'Use this template automatically for matching output type.' })}

-
- - - -
- )} - /> + {!isStandaloneXray && ( + ( + +
+ {t('clientTemplates.isDefault', { defaultValue: 'Set as default' })} +

{t('clientTemplates.isDefaultDescription', { defaultValue: 'Use this template automatically for matching output type.' })}

+
+ + + +
+ )} + /> + )} diff --git a/dashboard/src/features/templates/forms/client-template-form.ts b/dashboard/src/features/templates/forms/client-template-form.ts index 7f0f19149..db07877fd 100644 --- a/dashboard/src/features/templates/forms/client-template-form.ts +++ b/dashboard/src/features/templates/forms/client-template-form.ts @@ -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, @@ -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: { diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index bd40e4c6c..fbf64cef4 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -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', diff --git a/tests/api/test_client_template.py b/tests/api/test_client_template.py index 9c979ca25..bfed220d4 100644 --- a/tests/api/test_client_template.py +++ b/tests/api/test_client_template.py @@ -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) diff --git a/tests/api/test_user.py b/tests/api/test_user.py index a892d3db8..a26d874e9 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -1316,6 +1316,36 @@ def test_xray_subscription_includes_wireguard_outbound(access_token): delete_core(access_token, core["id"]) +def test_xray_subscription_includes_standalone_profile_without_host(access_token): + core, groups = setup_groups(access_token, 1) + username = unique_name("xray_standalone_user") + user = create_user(access_token, group_ids=[groups[0]["id"]], payload={"username": username}) + template = create_client_template( + access_token, + name=unique_name("xray_standalone_profile"), + template_type="xray_standalone", + content=json.dumps( + { + "remarks": "Serverless {USERNAME}", + "inbounds": [{"tag": "socks", "protocol": "socks", "port": 10808, "settings": {}}], + "outbounds": [{"tag": "DIRECT", "protocol": "freedom", "settings": {"fragment": {}}}], + "routing": {"rules": []}, + } + ), + ) + + try: + response = client.get(f"{user['subscription_url']}/xray") + assert response.status_code == status.HTTP_200_OK + + standalone = next(config for config in response.json() if config.get("remarks") == f"Serverless {username}") + assert standalone["outbounds"] == [{"tag": "DIRECT", "protocol": "freedom", "settings": {"fragment": {}}}] + finally: + delete_client_template(access_token, template["id"]) + delete_user(access_token, user["username"]) + cleanup_groups(access_token, core, groups) + + def test_xray_subscription_uses_host_specific_template_override(access_token): # Use a unique inbound tag so other tests' hosts can't affect config count. unique_inbound = unique_name("xray_override_inbound") diff --git a/tests/test_subscription_xray_standalone.py b/tests/test_subscription_xray_standalone.py new file mode 100644 index 000000000..c34a9c177 --- /dev/null +++ b/tests/test_subscription_xray_standalone.py @@ -0,0 +1,38 @@ +import json + +from app.subscription.xray import XrayConfiguration + + +def test_standalone_xray_profile_is_appended_without_proxy_injection(): + configuration = XrayConfiguration() + template = json.dumps( + { + "remarks": "Serverless {USERNAME}", + "inbounds": [{"tag": "socks", "protocol": "socks", "port": 10808, "settings": {}}], + "outbounds": [{"tag": "DIRECT", "protocol": "freedom", "settings": {"fragment": {}}}], + "routing": {"rules": []}, + } + ) + + configuration.add_standalone(template, {"USERNAME": "alice"}) + rendered = json.loads(configuration.render()) + + assert len(rendered) == 1 + assert rendered[0]["remarks"] == "Serverless alice" + assert rendered[0]["outbounds"] == [{"tag": "DIRECT", "protocol": "freedom", "settings": {"fragment": {}}}] + + +def test_standalone_xray_profile_formats_nested_template_variables(): + configuration = XrayConfiguration() + template = json.dumps( + { + "remarks": "{PROFILE_NAME}", + "inbounds": [{"tag": "socks", "protocol": "socks", "port": 10808, "settings": {}}], + "outbounds": [{"tag": "DIRECT", "protocol": "freedom", "settings": {}}], + "routing": {"rules": [{"domain": ["full:{USERNAME}.example.com"], "outboundTag": "DIRECT"}]}, + } + ) + + configuration.add_standalone(template, {"PROFILE_NAME": "Local bypass", "USERNAME": "alice"}) + + assert configuration.config[0]["routing"]["rules"][0]["domain"] == ["full:alice.example.com"]