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
2 changes: 1 addition & 1 deletion app/core/hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions app/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/operation/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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"],
)
Expand Down
100 changes: 72 additions & 28 deletions app/subscription/share.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,14 +51,22 @@ 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:
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(),
reverse,
randomize_order=randomize_order,
host_address_strategy=host_address_strategy,
)

if as_base64:
config = base64.b64encode(config.encode()).decode()
Expand Down Expand Up @@ -170,11 +179,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.
"""

Expand Down Expand Up @@ -209,8 +222,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
Expand Down Expand Up @@ -286,41 +303,68 @@ 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):
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)
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)

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:
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)

Expand Down
6 changes: 5 additions & 1 deletion dashboard/public/statics/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion dashboard/public/statics/locales/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@
"disableSubTemplate": "غیرفعال کردن قالب اشتراک",
"disableSubTemplateDescription": "هنگام غیرفعال بودن، درخواست‌های مرورگر به جای صفحات HTML اشتراک، پاسخ‌های API برمی‌گردانند",
"randomizeOrder": "تصادفی‌سازی ترتیب اشتراک",
"randomizeOrderDescription": "در هر درخواست اشتراک، ترتیب کانفیگ‌ها به‌صورت تصادفی جابه‌جا می‌شود"
"randomizeOrderDescription": "در هر درخواست اشتراک، ترتیب کانفیگ‌ها به‌صورت تصادفی جابه‌جا می‌شود",
"hostAddressStrategy": "استراتژی آدرس هاست",
"hostAddressStrategyDescription": "مشخص می‌کند آدرس‌های هاست چگونه در اشتراک قرار بگیرند.",
"hostAddressStrategyRandom": "انتخاب تصادفی",
"hostAddressStrategyPerAddress": "به‌ازای هر آدرس (لیست تمیز)"
},
"rules": {
"title": "قوانین اشتراک",
Expand Down
6 changes: 5 additions & 1 deletion dashboard/public/statics/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,11 @@
"disableSubTemplate": "Отключить шаблон подписки",
"disableSubTemplateDescription": "При отключении запросы браузера будут возвращать ответы API вместо HTML-страниц подписки",
"randomizeOrder": "Случайный порядок конфигов",
"randomizeOrderDescription": "Перемешивать порядок конфигураций при каждом запросе подписки"
"randomizeOrderDescription": "Перемешивать порядок конфигураций при каждом запросе подписки",
"hostAddressStrategy": "Стратегия адресов хоста",
"hostAddressStrategyDescription": "Определяет, как адреса хоста включаются в подписки.",
"hostAddressStrategyRandom": "Случайный выбор",
"hostAddressStrategyPerAddress": "Для каждого адреса (чистый список)"
},
"rules": {
"title": "Правила подписки",
Expand Down
6 changes: 5 additions & 1 deletion dashboard/public/statics/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,11 @@
"disableSubTemplate": "禁用订阅模板",
"disableSubTemplateDescription": "禁用后,浏览器请求将返回 API 响应而不是 HTML 订阅页面",
"randomizeOrder": "随机化订阅顺序",
"randomizeOrderDescription": "每次请求订阅时,随机打乱配置顺序"
"randomizeOrderDescription": "每次请求订阅时,随机打乱配置顺序",
"hostAddressStrategy": "主机地址策略",
"hostAddressStrategyDescription": "选择订阅中主机地址的生成方式。",
"hostAddressStrategyRandom": "随机选择",
"hostAddressStrategyPerAddress": "按地址生成(更整洁)"
},
"rules": {
"title": "订阅规则",
Expand Down
33 changes: 33 additions & 0 deletions dashboard/src/pages/_dashboard.settings.subscriptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -1041,6 +1045,35 @@ export default function SubscriptionSettings() {
</FormItem>
)}
/>

<FormField
control={form.control}
name="host_address_strategy"
render={({ field }) => (
<FormItem className="flex items-center justify-between space-y-0 rounded-lg border bg-card p-3 transition-colors hover:bg-accent/50 sm:p-4 lg:col-span-2">
<div className="flex-1 space-y-0.5 pr-4">
<FormLabel className="flex items-center gap-2 text-sm font-medium">
<Code className="h-4 w-4 shrink-0" />
<span className="break-words">{t('settings.subscriptions.general.hostAddressStrategy')}</span>
</FormLabel>
<FormDescription className="text-xs leading-relaxed text-muted-foreground sm:leading-normal">
{t('settings.subscriptions.general.hostAddressStrategyDescription')}
</FormDescription>
</div>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-[220px] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="random">{t('settings.subscriptions.general.hostAddressStrategyRandom')}</SelectItem>
<SelectItem value="per_address">{t('settings.subscriptions.general.hostAddressStrategyPerAddress')}</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
</div>

Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/service/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Loading