From 16a63b2cc443f148c5a01a514efc03d36fc1201c Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 24 Feb 2026 20:04:30 +0330 Subject: [PATCH 1/2] feat(subscriptions): Enhance subscription generation to create dedicated connections per host address --- app/core/hosts.py | 2 +- app/subscription/share.py | 79 +++++++++++++++++++++++++-------------- tests/api/test_user.py | 41 ++++++++++++++++++++ 3 files changed, 93 insertions(+), 29 deletions(-) diff --git a/app/core/hosts.py b/app/core/hosts.py index 3b8cf2a25..77028199a 100644 --- a/app/core/hosts.py +++ b/app/core/hosts.py @@ -40,7 +40,7 @@ async def _prepare_subscription_inbound_data( """ Prepare host data - creates small config instances ONCE. Merges inbound config with host config. - Random selection happens in share.py on every request! + Final per-request selection and address expansion happens in share.py. """ # Get inbound configuration inbound_config = await core_manager.get_inbound_by_tag(host.inbound_tag) diff --git a/app/subscription/share.py b/app/subscription/share.py index d3cb5c446..48966346f 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -170,11 +170,15 @@ async def filter_hosts(hosts: list[SubscriptionInboundData], user_status: UserSt async def process_host( - inbound: SubscriptionInboundData, format_variables: dict, inbounds: list[str], proxies: dict + inbound: SubscriptionInboundData, + format_variables: dict, + inbounds: list[str], + proxies: dict, + selected_address: str | None = None, ) -> None | tuple[SubscriptionInboundData, dict]: """ Process host data for subscription generation. - Now only does random selection and user-specific formatting! + Does per-request value selection and user-specific formatting. All merging and data preparation is done in hosts.py. """ @@ -209,8 +213,12 @@ async def process_host( req_host = req_host.replace("*", salt) address = "" - if inbound.address: + if selected_address is not None: + address = selected_address.replace("*", salt) + elif isinstance(inbound.address, list) and inbound.address: address = random.choice(inbound.address).replace("*", salt) + elif isinstance(inbound.address, str): + address = inbound.address.replace("*", salt) # Select random port from list port = random.choice(inbound.port) if inbound.port else 0 @@ -287,40 +295,55 @@ async def process_inbounds_and_tags( reverse=False, randomize_order: bool = False, ) -> list | str: + def _address_candidates(addresses: list[str] | str) -> list[str | None]: + if isinstance(addresses, str): + return [addresses] if addresses else [None] + if not addresses: + return [None] + # Keep list deterministic and avoid duplicate connection rows. + return sorted(set(addresses)) + proxy_settings = user.proxy_settings.dict() hosts = await filter_hosts(list((await host_manager.get_hosts()).values()), user.status) if randomize_order and len(hosts) > 1: random.shuffle(hosts) for host_data in hosts: - result = await process_host(host_data, format_variables, user.inbounds, proxy_settings) - if not result: - continue - - inbound_copy: SubscriptionInboundData - inbound_copy, settings = result - - # Format remark and address with user variables - remark = inbound_copy.remark.format_map(format_variables) - formatted_address = inbound_copy.address.format_map(format_variables) - - download_settings = getattr(inbound_copy.transport_config, "download_settings", None) - if download_settings: - processed_download_settings = await _prepare_download_settings( - download_settings, + for selected_address in _address_candidates(host_data.address): + result = await process_host( + host_data, format_variables, user.inbounds, proxy_settings, - conf, + selected_address=selected_address, + ) + if not result: + continue + + inbound_copy: SubscriptionInboundData + inbound_copy, settings = result + + # Format remark and address with user variables + remark = inbound_copy.remark.format_map(format_variables) + formatted_address = inbound_copy.address.format_map(format_variables) + + download_settings = getattr(inbound_copy.transport_config, "download_settings", None) + if download_settings: + processed_download_settings = await _prepare_download_settings( + download_settings, + format_variables, + user.inbounds, + proxy_settings, + conf, + ) + if hasattr(inbound_copy.transport_config, "download_settings"): + inbound_copy.transport_config.download_settings = processed_download_settings + + conf.add( + remark=remark, + address=formatted_address, + inbound=inbound_copy, + settings=settings, ) - if hasattr(inbound_copy.transport_config, "download_settings"): - inbound_copy.transport_config.download_settings = processed_download_settings - - conf.add( - remark=remark, - address=formatted_address, - inbound=inbound_copy, - settings=settings, - ) return conf.render(reverse=reverse) diff --git a/tests/api/test_user.py b/tests/api/test_user.py index 8bb946830..11dd9678c 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -13,6 +13,7 @@ delete_group, delete_user, delete_user_template, + get_inbounds, unique_name, ) @@ -153,6 +154,46 @@ def test_user_subscriptions(access_token): cleanup_groups(access_token, core, groups) +def test_user_subscriptions_generates_connection_per_address(access_token): + """Each host address should generate a dedicated subscription connection.""" + core, groups = setup_groups(access_token, 1) + user = create_user( + access_token, + group_ids=[group["id"] for group in groups], + payload={"username": unique_name("test_user_sub_addresses")}, + ) + inbounds = get_inbounds(access_token) + assert inbounds, "No inbounds available for host creation" + + address_one = "multi-address-a.example.com" + address_two = "multi-address-b.example.com" + host_payload = { + "remark": unique_name("host_multi_address"), + "address": [address_one, address_two], + "port": 443, + "sni": ["subscription-test.example.com"], + "inbound_tag": inbounds[0], + "priority": 1, + } + host_response = client.post( + "/api/host", + headers={"Authorization": f"Bearer {access_token}"}, + json=host_payload, + ) + assert host_response.status_code == status.HTTP_201_CREATED + host = host_response.json() + + try: + response = client.get(f"{user['subscription_url']}/links") + assert response.status_code == status.HTTP_200_OK + assert address_one in response.text + assert address_two in response.text + finally: + delete_user(access_token, user["username"]) + client.delete(f"/api/host/{host['id']}", headers={"Authorization": f"Bearer {access_token}"}) + cleanup_groups(access_token, core, groups) + + def test_user_sub_update_user_agent(access_token): """Test that the user sub_update user_agent is accessible.""" core, groups = setup_groups(access_token, 1) From 1aeb7b0d2eca5fe29986a07394d5d94515881c6d Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 24 Feb 2026 23:04:59 +0330 Subject: [PATCH 2/2] feat(subscriptions): Add host address strategy options for subscription generation --- app/models/settings.py | 6 ++ app/operation/subscription.py | 2 + app/subscription/share.py | 25 +++++++- dashboard/public/statics/locales/en.json | 6 +- dashboard/public/statics/locales/fa.json | 6 +- dashboard/public/statics/locales/ru.json | 6 +- dashboard/public/statics/locales/zh.json | 6 +- .../_dashboard.settings.subscriptions.tsx | 33 ++++++++++ dashboard/src/service/api/index.ts | 2 + tests/api/conftest.py | 1 + tests/api/test_user.py | 61 ++++++++++++++++++- 11 files changed, 147 insertions(+), 7 deletions(-) diff --git a/app/models/settings.py b/app/models/settings.py index ca2ed4245..30169d037 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -194,6 +194,11 @@ class ConfigFormat(str, Enum): block = "block" +class HostAddressStrategy(StrEnum): + random = "random" + per_address = "per_address" + + class SubRule(BaseModel): pattern: str target: ConfigFormat @@ -265,6 +270,7 @@ class Subscription(BaseModel): allow_browser_config: bool = Field(default=True) disable_sub_template: bool = Field(default=False) randomize_order: bool = Field(default=False) + host_address_strategy: HostAddressStrategy = Field(default=HostAddressStrategy.random) @field_validator("applications") @classmethod diff --git a/app/operation/subscription.py b/app/operation/subscription.py index e2d0e048a..35778fb83 100644 --- a/app/operation/subscription.py +++ b/app/operation/subscription.py @@ -120,6 +120,7 @@ async def fetch_config(self, user: UsersResponseWithInbounds, client_type: Confi config = client_config.get(client_type) sub_settings = await subscription_settings() randomize_order = sub_settings.randomize_order + host_address_strategy = sub_settings.host_address_strategy # Generate subscription content return ( @@ -128,6 +129,7 @@ async def fetch_config(self, user: UsersResponseWithInbounds, client_type: Confi config_format=config["config_format"], as_base64=config["as_base64"], randomize_order=randomize_order, + host_address_strategy=host_address_strategy, ), config["media_type"], ) diff --git a/app/subscription/share.py b/app/subscription/share.py index 48966346f..dad859928 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -9,6 +9,7 @@ from app.core.hosts import host_manager from app.db.models import UserStatus +from app.models.settings import HostAddressStrategy 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 @@ -50,6 +51,7 @@ async def generate_subscription( as_base64: bool, reverse: bool = False, randomize_order: bool = False, + host_address_strategy: HostAddressStrategy = HostAddressStrategy.random, ) -> str: conf = config_format_handler.get(config_format, None) if conf is None: @@ -57,7 +59,14 @@ async def generate_subscription( 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(), + reverse, + randomize_order=randomize_order, + host_address_strategy=host_address_strategy, + ) if as_base64: config = base64.b64encode(config.encode()).decode() @@ -294,6 +303,7 @@ async def process_inbounds_and_tags( | OutlineConfiguration, reverse=False, randomize_order: bool = False, + host_address_strategy: HostAddressStrategy = HostAddressStrategy.random, ) -> list | str: def _address_candidates(addresses: list[str] | str) -> list[str | None]: if isinstance(addresses, str): @@ -308,7 +318,18 @@ def _address_candidates(addresses: list[str] | str) -> list[str | None]: if randomize_order and len(hosts) > 1: random.shuffle(hosts) for host_data in hosts: - for selected_address in _address_candidates(host_data.address): + if host_address_strategy == HostAddressStrategy.random: + # Random strategy: exactly one randomly selected address per host. + if isinstance(host_data.address, list) and host_data.address: + address_candidates = [random.choice(host_data.address)] + elif isinstance(host_data.address, str) and host_data.address: + address_candidates = [host_data.address] + else: + address_candidates = [None] + else: + address_candidates = _address_candidates(host_data.address) + + for selected_address in address_candidates: result = await process_host( host_data, format_variables, diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 976a64574..d2bbfb6a2 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -216,7 +216,11 @@ "disableSubTemplate": "Disable Subscription Template", "disableSubTemplateDescription": "When disabled, browser requests will return API responses instead of HTML subscription pages", "randomizeOrder": "Randomize Subscription Order", - "randomizeOrderDescription": "Shuffle configuration order on each subscription request" + "randomizeOrderDescription": "Shuffle configuration order on each subscription request", + "hostAddressStrategy": "Host Address Strategy", + "hostAddressStrategyDescription": "Choose how host addresses are included in subscriptions.", + "hostAddressStrategyRandom": "Random Select", + "hostAddressStrategyPerAddress": "Per Address (Clean List)" }, "rules": { "title": "Subscription Rules", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 05b2c277e..253381345 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -123,7 +123,11 @@ "disableSubTemplate": "غیرفعال کردن قالب اشتراک", "disableSubTemplateDescription": "هنگام غیرفعال بودن، درخواست‌های مرورگر به جای صفحات HTML اشتراک، پاسخ‌های API برمی‌گردانند", "randomizeOrder": "تصادفی‌سازی ترتیب اشتراک", - "randomizeOrderDescription": "در هر درخواست اشتراک، ترتیب کانفیگ‌ها به‌صورت تصادفی جابه‌جا می‌شود" + "randomizeOrderDescription": "در هر درخواست اشتراک، ترتیب کانفیگ‌ها به‌صورت تصادفی جابه‌جا می‌شود", + "hostAddressStrategy": "استراتژی آدرس هاست", + "hostAddressStrategyDescription": "مشخص می‌کند آدرس‌های هاست چگونه در اشتراک قرار بگیرند.", + "hostAddressStrategyRandom": "انتخاب تصادفی", + "hostAddressStrategyPerAddress": "به‌ازای هر آدرس (لیست تمیز)" }, "rules": { "title": "قوانین اشتراک", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index f6e745531..d3b232507 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -230,7 +230,11 @@ "disableSubTemplate": "Отключить шаблон подписки", "disableSubTemplateDescription": "При отключении запросы браузера будут возвращать ответы API вместо HTML-страниц подписки", "randomizeOrder": "Случайный порядок конфигов", - "randomizeOrderDescription": "Перемешивать порядок конфигураций при каждом запросе подписки" + "randomizeOrderDescription": "Перемешивать порядок конфигураций при каждом запросе подписки", + "hostAddressStrategy": "Стратегия адресов хоста", + "hostAddressStrategyDescription": "Определяет, как адреса хоста включаются в подписки.", + "hostAddressStrategyRandom": "Случайный выбор", + "hostAddressStrategyPerAddress": "Для каждого адреса (чистый список)" }, "rules": { "title": "Правила подписки", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 3120bf124..847f7aacf 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -196,7 +196,11 @@ "disableSubTemplate": "禁用订阅模板", "disableSubTemplateDescription": "禁用后,浏览器请求将返回 API 响应而不是 HTML 订阅页面", "randomizeOrder": "随机化订阅顺序", - "randomizeOrderDescription": "每次请求订阅时,随机打乱配置顺序" + "randomizeOrderDescription": "每次请求订阅时,随机打乱配置顺序", + "hostAddressStrategy": "主机地址策略", + "hostAddressStrategyDescription": "选择订阅中主机地址的生成方式。", + "hostAddressStrategyRandom": "随机选择", + "hostAddressStrategyPerAddress": "按地址生成(更整洁)" }, "rules": { "title": "订阅规则", diff --git a/dashboard/src/pages/_dashboard.settings.subscriptions.tsx b/dashboard/src/pages/_dashboard.settings.subscriptions.tsx index 2010a5c7f..92de27c5e 100644 --- a/dashboard/src/pages/_dashboard.settings.subscriptions.tsx +++ b/dashboard/src/pages/_dashboard.settings.subscriptions.tsx @@ -34,6 +34,7 @@ const subscriptionSchema = z.object({ allow_browser_config: z.boolean().optional(), disable_sub_template: z.boolean().optional(), randomize_order: z.boolean().optional(), + host_address_strategy: z.enum(['random', 'per_address']).optional(), rules: z.array( z.object({ pattern: z.string().min(1, 'Pattern is required'), @@ -454,6 +455,7 @@ export default function SubscriptionSettings() { allow_browser_config: true, disable_sub_template: false, randomize_order: false, + host_address_strategy: 'random', rules: [], applications: [], manual_sub_request: { @@ -543,6 +545,7 @@ export default function SubscriptionSettings() { allow_browser_config: subscriptionData.allow_browser_config ?? true, disable_sub_template: subscriptionData.disable_sub_template ?? false, randomize_order: subscriptionData.randomize_order ?? false, + host_address_strategy: subscriptionData.host_address_strategy ?? 'random', rules: subscriptionData.rules || [], applications: subscriptionData.applications || [], manual_sub_request: { @@ -679,6 +682,7 @@ export default function SubscriptionSettings() { allow_browser_config: subscriptionData.allow_browser_config ?? true, disable_sub_template: subscriptionData.disable_sub_template ?? false, randomize_order: subscriptionData.randomize_order ?? false, + host_address_strategy: subscriptionData.host_address_strategy ?? 'random', rules: subscriptionData.rules || [], applications: subscriptionData.applications || [], manual_sub_request: { @@ -1041,6 +1045,35 @@ export default function SubscriptionSettings() { )} /> + + ( + +
+ + + {t('settings.subscriptions.general.hostAddressStrategy')} + + + {t('settings.subscriptions.general.hostAddressStrategyDescription')} + +
+ + + +
+ )} + /> diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index 02e69b894..005ebdc53 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -1133,6 +1133,7 @@ export interface SubscriptionOutput { allow_browser_config?: boolean disable_sub_template?: boolean randomize_order?: boolean + host_address_strategy?: 'random' | 'per_address' } export interface SubscriptionInput { @@ -1149,6 +1150,7 @@ export interface SubscriptionInput { allow_browser_config?: boolean disable_sub_template?: boolean randomize_order?: boolean + host_address_strategy?: 'random' | 'per_address' } export type SingBoxMuxSettingsBrutal = Brutal | null diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 835aca985..de0dfece3 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -101,6 +101,7 @@ def mock_settings(monkeypatch: pytest.MonkeyPatch): "profile_title": "Subscription", "host_status_filter": False, "randomize_order": False, + "host_address_strategy": "random", "rules": [ { "pattern": "^([Cc]lash[\\-\\.]?[Vv]erge|[Cc]lash[\\-\\.]?[Mm]eta|[Ff][Ll][Cc]lash|[Mm]ihomo)", diff --git a/tests/api/test_user.py b/tests/api/test_user.py index 11dd9678c..b629e6df0 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -2,6 +2,7 @@ from fastapi import status +from app.models.settings import HostAddressStrategy, Subscription as SubscriptionSettings from tests.api import client from tests.api.helpers import ( create_core, @@ -154,8 +155,18 @@ def test_user_subscriptions(access_token): cleanup_groups(access_token, core, groups) -def test_user_subscriptions_generates_connection_per_address(access_token): +def test_subscription_settings_host_address_strategy_defaults_to_random(): + """Default host address strategy should use random selection.""" + assert SubscriptionSettings(rules=[]).host_address_strategy == HostAddressStrategy.random + + +def test_user_subscriptions_generates_connection_per_address(access_token, monkeypatch): """Each host address should generate a dedicated subscription connection.""" + async def mock_subscription_settings(): + return SubscriptionSettings(rules=[], host_address_strategy=HostAddressStrategy.per_address) + + monkeypatch.setattr("app.operation.subscription.subscription_settings", mock_subscription_settings) + core, groups = setup_groups(access_token, 1) user = create_user( access_token, @@ -194,6 +205,54 @@ def test_user_subscriptions_generates_connection_per_address(access_token): cleanup_groups(access_token, core, groups) +def test_user_subscriptions_random_strategy_selects_single_address(access_token, monkeypatch): + """Random strategy should keep one randomly selected address per host.""" + + async def mock_subscription_settings(): + return SubscriptionSettings(rules=[], host_address_strategy=HostAddressStrategy.random) + + monkeypatch.setattr("app.operation.subscription.subscription_settings", mock_subscription_settings) + + core, groups = setup_groups(access_token, 1) + user = create_user( + access_token, + group_ids=[group["id"] for group in groups], + payload={"username": unique_name("test_user_sub_random")}, + ) + inbounds = get_inbounds(access_token) + assert inbounds, "No inbounds available for host creation" + + address_one = "legacy-random-a.example.com" + address_two = "legacy-random-b.example.com" + host_payload = { + "remark": unique_name("host_random"), + "address": [address_one, address_two], + "port": 443, + "sni": ["subscription-test.example.com"], + "inbound_tag": inbounds[0], + "priority": 1, + } + host_response = client.post( + "/api/host", + headers={"Authorization": f"Bearer {access_token}"}, + json=host_payload, + ) + assert host_response.status_code == status.HTTP_201_CREATED + host = host_response.json() + + try: + response = client.get(f"{user['subscription_url']}/links") + assert response.status_code == status.HTTP_200_OK + has_one = address_one in response.text + has_two = address_two in response.text + assert has_one or has_two + assert not (has_one and has_two) + finally: + delete_user(access_token, user["username"]) + client.delete(f"/api/host/{host['id']}", headers={"Authorization": f"Bearer {access_token}"}) + cleanup_groups(access_token, core, groups) + + def test_user_sub_update_user_agent(access_token): """Test that the user sub_update user_agent is accessible.""" core, groups = setup_groups(access_token, 1)