feat(sub): client-side balancers for the JSON subscription (#6243)

* feat(sub): add SubBalancer model and migration

Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up.

* feat(sub): add SubBalancer service

List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart.

* feat(sub): add SubBalancer API controller and routes

GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it.

* feat(sub): emit client-side balancers in JSON subscription

For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present.

* test(sub): cover SubBalancer service and JSON output

Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup.

* feat(sub): add sub-balancers i18n keys

pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales.

* feat(sub): add SubBalancer schema and API queries

Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key.

* feat(sub): add subscription balancers settings tab

SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled.

* test(sub): add SubBalancer form modal test

Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled).

* feat(sub): register sub-balancers in API docs and OpenAPI

Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it.

* docs: sync openapi.json with frontend

docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API.

* docs: add subscription balancers API reference

Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations.

* feat(sub): accept roundRobin balancer strategy

Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist.

* feat(sub): add roundRobin strategy label

pages.settings.subBalancers.strategyRoundRobin added to all 13 locales.

* feat(sub): expose roundRobin in balancer form

Zod strategy enum, form modal label key, and table strategy colour for roundRobin.

* docs(sub): list roundRobin in strategy description

The create/update strategy param description now mentions roundRobin alongside the other three.

* feat(sub): add subJsonObservatory setting

Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask.

* feat(sub): wire observatory config through sub controller

WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction.

* feat(sub): emit observatory conditionally with configurable probes

burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override.

* feat(sub): add subJsonObservatory to AllSetting model

Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string.

* feat(sub): add balancer observatory config card

New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults.

* fix(sub): hide save/restart header on sub-balancers tab

Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly.

* feat(sub): add observatory config i18n keys

pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales.

* feat(sub): regenerate openapi for subJsonObservatory

openapigen picks up the new AllSetting field; openapi.json synced into docs.

* feat(sub): add observatory tab to sub-balancers

Mirrors the Xray Balancers page: two tabs (Balancers + Observatory).
Wires allSetting/updateSetting into the tab and adds tabBalancers /
tabObservatory labels to all locales. The page Save header is shown
again on this tab so the observatory config can be saved.

* refactor(sub): drop observatory tab from sub-formats

Now that the observatory config lives under sub-balancers, remove the
duplicate tab plus its state and defaults from sub-formats.

* fix(sub): add missing inboundsCount i18n key

The sub-balancers table rendered the raw key path in the Inbounds
column because pages.settings.subBalancers.inboundsCount was not
defined. Added it to all 13 locales.

* test(sub): pin disabled-inbound exclusion from balancer

The balancer builds its members from the subscriber's already-filtered
entry set, so an inbound disabled for that user can never surface as a
member. Adds tests for both shapes (one of several disabled, and the
only selected one disabled).

* fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback

Three coupled defects on the balancer observatory surface, flagged in PR review:

- The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this.

- Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value.

- routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking.

Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it.

* fix(sub-balancer): localize controller toasts and reject malformed ids

Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success.

* fix(sub-balancer): enforce remark length cap server-side

The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap.

* fix(sub-balancer): exclude mtproto from balancer member picker

SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment.

* docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope

- Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only).

- Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX.

- Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror.

* style(model): trim SubBalancer comment to 2-line cap

CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3.

* fix(sub-balancer): parse enabled explicitly and preserve it on partial update

parseSubBalancerForm treated any non-"false" value as true (so "bogus"
silently enabled) and always overwrote Enabled on update, so a PATCH that
omitted the toggle reset a disabled balancer back to enabled. Parse the
field with strconv.ParseBool and return *bool: absent means "no change"
on update and "true" on create; a malformed value is rejected as 400.
Update keeps the stored Enabled when the pointer is nil.

* fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds

DelInbound cascaded hosts but left the deleted inbound id in every
sub_balancers.InboundIds, so the balancer kept emitting a member no
subscriber could resolve — a dangling outbound tag with no proxy behind it.
Strip the id inside the existing delete transaction (same shape as the hosts
cascade, #5648); with the last member gone the balancer stops emitting.

* fix(sub-balancer): return not-found when deleting a missing balancer

Delete returned the gorm result error only, which is nil when no row matched,
so the controller reported success:true for an id that never existed — a stale
UI row looked like a clean delete. Check RowsAffected and return a not-found
error on 0 so the toast reflects reality.

* style(sub): shorten leastPing/leastLoad observatory comments

The observatory-emission guard comment and its test comment ran a few
lines long; trim them to a couple of lines each without dropping the
invariant that leastPing/leastLoad require a burst observatory.

* fix(sub): validate observatory setting instead of silently dropping it

SetObservatoryConfig applied whatever survived json.Unmarshal with no checks,
so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even
unparseable JSON was either silently applied or silently ignored. Validate
each field: parse durations with time.ParseDuration, require http(s) URLs for
destination/connectivity, and log a warning naming the field and the bad value
on every fallback — including the unmarshal error, which was a quiet return.
Bad values now keep the built-in defaults instead of leaking into the emitted
burstObservatory.

* fix(sub): deduplicate burst-observatory defaults across Go and frontend

The burst-observatory ping defaults lived in three places that had drifted:
Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod
PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity
pre-check URL). Align them to one set: https probe destination, sampling 2,
and empty connectivity (skip the direct pre-check). The settings tab now
parses the stored JSON through PingConfigSchema and seeds its default from
DEFAULT_BURST_OBSERVATORY instead of carrying its own literal.

* refactor(sub): extract proxy outbounds once before the balancer loop

buildBalancerConfig unmarshalled every inbound document and re-extracted its
first outbound on each balancer, so with B balancers and N inbound docs the
same document was parsed B*N times. Pull each doc's proxy outbound in a single
pre-pass over the entries and cache it per entry; buildBalancerConfig now
clones the cached map before retagging, so one parse serves every balancer.
Output is byte-for-byte unchanged.

* fix(sub): form balancer member tags from the inbound protocol, not tcp→vless

balancerTransport derived the bal-N tag suffix from the outbound's transport
network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was
mislabelled "vless" in every client config — the tag lied about the proxy
type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless,
bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and
dedup suffix are unchanged. Update the existing tag assertions and add a vmess
case that fails under the old mapping.

* fix(sub-balancer): default strategy to random in the create form

The create-balancer form seeded strategy to 'leastLoad', but the service
validate() defaults an empty strategy to 'random' and the API docs say the
default is 'random' — so a freshly opened form showed leastLoad while saving
without touching the field silently stored random. Align the form default to
'random' so what the admin sees is what gets persisted.

* feat(api-docs): document the SubBalancer response schema

The five sub-balancer endpoints carried no responseSchema, so the API docs
page rendered them without a typed example. Add example: tags to every
SubBalancer field, allow the struct through openapigen, and point the list
(responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate
the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/.

* style(sub-balancer): drop whitespace-only separator lines, add final newline

subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as
separators between statements and had no trailing newline. Replace them with
clean empty blank lines and end each file with a newline.

* fix(i18n): translate sub-balancer toasts and observatory note

The sub-balancer toast messages (list/create/update/delete/invalidId) and
the observatory note were left in English across 11 non-English locales
(ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key
in the subBalancers block was already translated. Translate them to match the
meaning and terminology of the surrounding keys in each file; the JSON
structure and keys are unchanged.

* fix(sub-balancer): hide disabled inbounds from the member picker

The picker offered every protocol-eligible inbound regardless of its enable
flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled
member is therefore dropped from every subscriber's entries, and when it was
the balancer's only member the balancer document silently stops being emitted
— with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll
MembersDisabled already documents that backend behavior.

Filter the way the sibling client picker has since #5645: hide disabled
inbounds, but keep one that is already selected so editing an existing
balancer cannot silently drop a member.

Drop the `?? []` on the useWatch result so the new useMemo dependency stays
referentially stable.

* style(sub): trim the balancerMemberSuffix comment to the 2-line cap

Comment blocks in committed Go are capped at 2 lines; the name already carries
what the function picks, so keep only the why.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
DIMFLIX
2026-08-23 23:34:20 +03:00
committed by GitHub
parent 81fcacab11
commit da01b7637d
65 changed files with 6587 additions and 792 deletions
+304 -2
View File
@@ -5,9 +5,15 @@ import (
"encoding/json"
"fmt"
"maps"
"net/url"
"slices"
"sort"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
@@ -22,6 +28,7 @@ type SubJsonService struct {
defaultOutbounds []json_util.RawMessage
finalMask string
mux string
observatory subBalancerObservatoryConfig
SubService *SubService
}
@@ -53,6 +60,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
defaultOutbounds: defaultOutbounds,
finalMask: finalMask,
mux: mux,
observatory: defaultSubBalancerObservatoryConfig(),
SubService: subService,
}
}
@@ -74,9 +82,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
}
var header string
var configArray []json_util.RawMessage
seenEmails := make(map[string]struct{})
entries := make([]subConfigEntry, 0, len(inbounds))
// Prepare Inbounds
for _, inbound := range inbounds {
clients := subReq.matchingClients(inbound, subId)
@@ -88,10 +96,35 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
injectExternalProxy(inbound, hostEps)
}
var inboundConfigs []json_util.RawMessage
for _, client := range clients {
seenEmails[client.Email] = struct{}{}
configArray = append(configArray, s.getConfig(subReq, inbound, client, host)...)
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
}
if len(inboundConfigs) > 0 {
entries = append(entries, subConfigEntry{
sortIndex: inbound.SubSortIndex,
id: inbound.Id,
configs: inboundConfigs,
})
}
}
entries = s.appendBalancerEntries(entries)
// Inbounds arrive sorted by (sub_sort_index, id); balancers interleave by
// the same key and, on an equal number, follow the inbound group.
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].sortIndex != entries[j].sortIndex {
return entries[i].sortIndex < entries[j].sortIndex
}
if entries[i].kind != entries[j].kind {
return entries[i].kind < entries[j].kind
}
return entries[i].id < entries[j].id
})
var configArray []json_util.RawMessage
for _, entry := range entries {
configArray = append(configArray, entry.configs...)
}
for _, ext := range externalLinks {
for _, el := range expandEntry(ext) {
@@ -136,6 +169,275 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
return string(finalJson), header, nil
}
// subConfigEntry is one ordered block of the JSON subscription: an inbound's
// configs (kind 0) or a balancer config (kind 1).
type subConfigEntry struct {
sortIndex int
kind int
id int
configs []json_util.RawMessage
}
const (
subBalancerTag = "balancer"
subBalancerProbeURL = "https://www.google.com/generate_204"
)
// subBalancerObservatoryConfig is the panel-wide burstObservatory ping config
// emitted into every client-side balancer doc (subJsonObservatory setting).
type subBalancerObservatoryConfig struct {
Destination string `json:"destination"`
Connectivity string `json:"connectivity"`
Interval string `json:"interval"`
Sampling int `json:"sampling"`
Timeout string `json:"timeout"`
HTTPMethod string `json:"httpMethod"`
}
func defaultSubBalancerObservatoryConfig() subBalancerObservatoryConfig {
return subBalancerObservatoryConfig{
Destination: subBalancerProbeURL,
Connectivity: "",
Interval: "1m",
Sampling: 2,
Timeout: "5s",
HTTPMethod: "HEAD",
}
}
// SetObservatoryConfig overrides defaults from the panel JSON setting. An empty
// cfg keeps all defaults; invalid values fall back with a warning, never panic.
func (s *SubJsonService) SetObservatoryConfig(cfg string) {
s.observatory = defaultSubBalancerObservatoryConfig()
if cfg == "" {
return
}
var parsed subBalancerObservatoryConfig
if err := json.Unmarshal([]byte(cfg), &parsed); err != nil {
logger.Warningf("subJsonObservatory: invalid JSON %q, using defaults: %v", cfg, err)
return
}
if parsed.Destination != "" {
if validProbeURL(parsed.Destination) {
s.observatory.Destination = parsed.Destination
} else {
logger.Warningf("subJsonObservatory: invalid destination %q, keeping default %q", parsed.Destination, s.observatory.Destination)
}
}
if parsed.Connectivity != "" {
if validProbeURL(parsed.Connectivity) {
s.observatory.Connectivity = parsed.Connectivity
} else {
logger.Warningf("subJsonObservatory: invalid connectivity %q, keeping default (skip)", parsed.Connectivity)
}
}
if parsed.Interval != "" {
if _, err := time.ParseDuration(parsed.Interval); err == nil {
s.observatory.Interval = parsed.Interval
} else {
logger.Warningf("subJsonObservatory: invalid interval %q, keeping default %q", parsed.Interval, s.observatory.Interval)
}
}
if parsed.Sampling > 0 {
s.observatory.Sampling = parsed.Sampling
}
if parsed.Timeout != "" {
if _, err := time.ParseDuration(parsed.Timeout); err == nil {
s.observatory.Timeout = parsed.Timeout
} else {
logger.Warningf("subJsonObservatory: invalid timeout %q, keeping default %q", parsed.Timeout, s.observatory.Timeout)
}
}
if parsed.HTTPMethod == "HEAD" || parsed.HTTPMethod == "GET" {
s.observatory.HTTPMethod = parsed.HTTPMethod
}
}
// validProbeURL accepts only absolute http(s) URLs so a malformed probe or
// connectivity value can't slip into the emitted burstObservatory.
func validProbeURL(s string) bool {
u, err := url.Parse(s)
if err != nil || u == nil {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
func (s *SubJsonService) balancerObservatory(prefix string) map[string]any {
o := s.observatory
return map[string]any{
"subjectSelector": []string{prefix},
"pingConfig": map[string]any{
"destination": o.Destination,
"connectivity": o.Connectivity,
"interval": o.Interval,
"sampling": o.Sampling,
"timeout": o.Timeout,
"httpMethod": o.HTTPMethod,
},
}
}
// appendBalancerEntries appends one entry per enabled balancer that has at
// least one member outbound among the inbound entries.
func (s *SubJsonService) appendBalancerEntries(entries []subConfigEntry) []subConfigEntry {
balancers := getEnabledSubBalancers()
if len(balancers) == 0 {
return entries
}
// Pre-pass: pull each inbound doc's proxy outbound once so every balancer
// reuses it instead of re-unmarshalling the whole document per balancer.
entryProxies := make([][]map[string]any, len(entries))
for i, entry := range entries {
if entry.kind != 0 {
continue
}
for _, config := range entry.configs {
if proxy := extractProxyOutbound(config); proxy != nil {
entryProxies[i] = append(entryProxies[i], proxy)
}
}
}
for i := range balancers {
config := s.buildBalancerConfig(&balancers[i], entries, entryProxies)
if config == nil {
continue
}
entries = append(entries, subConfigEntry{
sortIndex: balancers[i].SortOrder,
kind: 1,
id: balancers[i].Id,
configs: []json_util.RawMessage{config},
})
}
return entries
}
// extractProxyOutbound returns the first outbound of a document when it is the
// proxy (tag == "proxy"), else nil — the only member shape a balancer retags.
func extractProxyOutbound(config json_util.RawMessage) map[string]any {
var doc map[string]any
if json.Unmarshal(config, &doc) != nil {
return nil
}
outbounds, _ := doc["outbounds"].([]any)
if len(outbounds) == 0 {
return nil
}
outbound, _ := outbounds[0].(map[string]any)
if outbound == nil || outbound["tag"] != "proxy" {
return nil
}
return outbound
}
func getEnabledSubBalancers() []model.SubBalancer {
var balancers []model.SubBalancer
if err := database.GetDB().Model(&model.SubBalancer{}).
Where("enabled = ?", true).
Order("sort_order asc, id asc").Find(&balancers).Error; err != nil {
logger.Error("SubJsonService - getEnabledSubBalancers:", err)
return nil
}
return balancers
}
// Suffix by proxy protocol, not transport network — a vmess/tcp member used to
// be mislabelled "vless".
func balancerMemberSuffix(protocol string) string {
if protocol == "" {
return "other"
}
return protocol
}
// buildBalancerConfig assembles the balancer profile: members retagged under a
// per-balancer prefix, a routing.balancers entry, and (for leastPing/leastLoad) an observatory.
func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entries []subConfigEntry, entryProxies [][]map[string]any) json_util.RawMessage {
prefix := fmt.Sprintf("bal-%d-", balancer.Id)
usedTags := make(map[string]bool)
var proxies []json_util.RawMessage
var firstTag string
// entryProxies is the pre-extracted proxy outbounds per entry; kind!=0 rows
// have none. Clone before retagging so the cached map stays reusable.
for i, entry := range entries {
if entry.kind != 0 || !slices.Contains(balancer.InboundIds, entry.id) {
continue
}
for _, outbound := range entryProxies[i] {
protocol, _ := outbound["protocol"].(string)
base := prefix + balancerMemberSuffix(protocol)
tag := base
for suffix := 2; usedTags[tag]; suffix++ {
tag = fmt.Sprintf("%s-%d", base, suffix)
}
usedTags[tag] = true
member := maps.Clone(outbound)
member["tag"] = tag
if raw, err := json.MarshalIndent(member, "", " "); err == nil {
if firstTag == "" {
firstTag = tag
}
proxies = append(proxies, raw)
}
}
}
if len(proxies) == 0 {
return nil
}
outbounds := append([]json_util.RawMessage{}, proxies...)
outbounds = append(outbounds, s.defaultOutbounds...)
// The routing subtree in s.configJson is shared by every emitted document;
// clone it (and each rule map) before pointing rules at the balancer.
baseRouting, _ := s.configJson["routing"].(map[string]any)
routing := make(map[string]any, len(baseRouting)+1)
maps.Copy(routing, baseRouting)
baseRules, _ := baseRouting["rules"].([]any)
rules := make([]any, 0, len(baseRules)+1)
for _, rule := range baseRules {
ruleMap, ok := rule.(map[string]any)
if !ok {
rules = append(rules, rule)
continue
}
ruleMap = maps.Clone(ruleMap)
if ruleMap["outboundTag"] == "proxy" {
delete(ruleMap, "outboundTag")
ruleMap["balancerTag"] = subBalancerTag
}
rules = append(rules, ruleMap)
}
routing["rules"] = rules
isObservatory := balancer.Strategy == "leastPing" || balancer.Strategy == "leastLoad"
balancerEntry := map[string]any{
"tag": subBalancerTag,
"selector": []string{prefix},
"strategy": map[string]any{"type": balancer.Strategy},
}
if isObservatory && firstTag != "" {
// With all probes failing, route to the first member instead of
// failing dispatch.
balancerEntry["fallbackTag"] = firstTag
}
routing["balancers"] = []any{balancerEntry}
newConfigJson := make(map[string]any, len(s.configJson)+2)
maps.Copy(newConfigJson, s.configJson)
newConfigJson["outbounds"] = outbounds
newConfigJson["remarks"] = balancer.Remark
newConfigJson["routing"] = routing
// leastPing/leastLoad require a burst observatory (Xray refuses to start
// them without one); fallbackTag above covers the probe-outage case.
if isObservatory {
newConfigJson["burstObservatory"] = s.balancerObservatory(prefix)
}
config, _ := json.MarshalIndent(newConfigJson, "", " ")
return config
}
func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
var newJsonArray []json_util.RawMessage
stream := s.streamData(inbound.StreamSettings, subKey(client))