mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 20:07:13 +00:00
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:
@@ -99,6 +99,7 @@ type subControllerConfig struct {
|
||||
subJsonMux string
|
||||
subJsonRules string
|
||||
subJsonFinalMask string
|
||||
subJsonObservatory string
|
||||
subClashEnableRouting bool
|
||||
subClashRules string
|
||||
|
||||
@@ -180,6 +181,10 @@ func WithSUBJsonFinalMask(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonFinalMask = value }
|
||||
}
|
||||
|
||||
func WithSUBJsonObservatory(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonObservatory = value }
|
||||
}
|
||||
|
||||
func WithSUBClashEnableRouting(value bool) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subClashEnableRouting = value }
|
||||
}
|
||||
@@ -243,6 +248,8 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
}
|
||||
|
||||
sub := NewSubService(config.remarkTemplate)
|
||||
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub)
|
||||
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
|
||||
a := &SUBController{
|
||||
subTitle: config.subTitle,
|
||||
subSupportUrl: config.subSupportURL,
|
||||
@@ -269,7 +276,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
updateInterval: config.updateInterval,
|
||||
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub),
|
||||
subJsonService: subJsonSvc,
|
||||
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
|
||||
|
||||
subTemplateCache: map[string]*cachedSubTemplate{},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -155,6 +155,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
SubJsonFinalMask = ""
|
||||
}
|
||||
|
||||
SubJsonObservatory, err := s.settingService.GetSubJsonObservatory()
|
||||
if err != nil {
|
||||
SubJsonObservatory = ""
|
||||
}
|
||||
|
||||
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
|
||||
if err != nil {
|
||||
SubClashEnableRouting = false
|
||||
@@ -281,6 +286,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
WithSUBJsonMux(SubJsonMux),
|
||||
WithSUBJsonRules(SubJsonRules),
|
||||
WithSUBJsonFinalMask(SubJsonFinalMask),
|
||||
WithSUBJsonObservatory(SubJsonObservatory),
|
||||
WithSUBClashEnableRouting(SubClashEnableRouting),
|
||||
WithSUBClashRules(SubClashRules),
|
||||
WithSUBTitle(SubTitle),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// seedSubProtocolInbound seeds an inbound of the given protocol with one client
|
||||
// wired into the clients/client_inbounds tables so getInboundsBySubId resolves it.
|
||||
func seedSubProtocolInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string, protocol model.Protocol) *model.Inbound {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
|
||||
email := tag + "@e"
|
||||
settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}]}`, uuid, email, subId)
|
||||
ib := &model.Inbound{
|
||||
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
|
||||
Protocol: protocol, Remark: tag, Settings: settings, StreamSettings: stream,
|
||||
SubSortIndex: subSortIndex,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound %s: %v", tag, err)
|
||||
}
|
||||
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
|
||||
if err := db.Create(client).Error; err != nil {
|
||||
t.Fatalf("seed client %s: %v", email, err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("seed client_inbound %s: %v", email, err)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
// The member tag suffix is the inbound's real protocol, not its transport
|
||||
// network: a vmess/tcp member is tagged bal-N-vmess, not the old bal-N-vless.
|
||||
func TestSubJson_BalancerMemberTagUsesProtocol(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
vm := seedSubProtocolInbound(t, "s1", "vm", 4901, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`, model.VMESS)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
balancerDoc := findDocByRemarks(parseSubJsonDocs(t, out), "proto")
|
||||
if balancerDoc == nil {
|
||||
t.Fatalf("balancer doc missing:\n%s", out)
|
||||
}
|
||||
tags := docOutboundTags(balancerDoc)
|
||||
if !strings.Contains(strings.Join(tags, ","), "bal-1-vmess") {
|
||||
t.Fatalf("vmess member tag = %v, want a bal-1-vmess suffix", tags)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer {
|
||||
t.Helper()
|
||||
if err := database.GetDB().Create(b).Error; err != nil {
|
||||
t.Fatalf("seed balancer: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func parseSubJsonDocs(t *testing.T, out string) []map[string]any {
|
||||
t.Helper()
|
||||
var docs []map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &docs); err != nil {
|
||||
t.Fatalf("subscription is not a JSON array: %v\n%s", err, out)
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func docOutboundTags(doc map[string]any) []string {
|
||||
outbounds, _ := doc["outbounds"].([]any)
|
||||
tags := make([]string, 0, len(outbounds))
|
||||
for _, ob := range outbounds {
|
||||
if m, ok := ob.(map[string]any); ok {
|
||||
tags = append(tags, m["tag"].(string))
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func findDocByRemarks(docs []map[string]any, remarks string) map[string]any {
|
||||
for _, doc := range docs {
|
||||
if doc["remarks"] == remarks {
|
||||
return doc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The balancer document retags members under a per-balancer prefix, points
|
||||
// proxy rules at the balancer, and probes it — manual docs keep plain "proxy".
|
||||
func TestSubJson_BalancerDocument(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
tcp := seedSubInbound(t, "s1", "tcpin", 4701, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
|
||||
ws := seedSubInbound(t, "s1", "wsin", 4702, 2, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
|
||||
js := NewSubJsonService("", rules, "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
docs := parseSubJsonDocs(t, out)
|
||||
if len(docs) != 3 {
|
||||
t.Fatalf("docs = %d, want 3 (2 inbounds + 1 balancer):\n%s", len(docs), out)
|
||||
}
|
||||
|
||||
balancerDoc := findDocByRemarks(docs, "auto")
|
||||
if balancerDoc == nil {
|
||||
t.Fatalf("balancer doc missing:\n%s", out)
|
||||
}
|
||||
if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
|
||||
t.Fatalf("balancer outbound tags = %v", tags)
|
||||
}
|
||||
|
||||
routing, _ := balancerDoc["routing"].(map[string]any)
|
||||
balancers, _ := routing["balancers"].([]any)
|
||||
if len(balancers) != 1 {
|
||||
t.Fatalf("balancers = %d, want 1", len(balancers))
|
||||
}
|
||||
balancer, _ := balancers[0].(map[string]any)
|
||||
if balancer["tag"] != "balancer" {
|
||||
t.Fatalf("balancer tag = %v", balancer["tag"])
|
||||
}
|
||||
if selector, _ := balancer["selector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
|
||||
t.Fatalf("selector = %v", selector)
|
||||
}
|
||||
strategy, _ := balancer["strategy"].(map[string]any)
|
||||
if strategy["type"] != "leastLoad" {
|
||||
t.Fatalf("strategy = %v", strategy)
|
||||
}
|
||||
if balancer["fallbackTag"] != "bal-1-vless" {
|
||||
t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
|
||||
}
|
||||
|
||||
ruleJSON, _ := json.Marshal(routing["rules"])
|
||||
if strings.Contains(string(ruleJSON), `"outboundTag":"proxy"`) {
|
||||
t.Fatalf("balancer rules must not point at the plain proxy tag: %s", ruleJSON)
|
||||
}
|
||||
if !strings.Contains(string(ruleJSON), `"balancerTag":"balancer"`) {
|
||||
t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
|
||||
}
|
||||
proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
|
||||
if proxyRules != 2 { // custom rule + default catch-all
|
||||
t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON)
|
||||
}
|
||||
|
||||
observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
|
||||
if selector, _ := observatory["subjectSelector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
|
||||
t.Fatalf("subjectSelector = %v", selector)
|
||||
}
|
||||
ping, _ := observatory["pingConfig"].(map[string]any)
|
||||
if ping["destination"] != subBalancerProbeURL {
|
||||
t.Fatalf("pingConfig destination = %v", ping["destination"])
|
||||
}
|
||||
|
||||
// The routing rewrite must not leak into the manual documents: s.configJson
|
||||
// is shared, so a missing clone would corrupt every other doc.
|
||||
for _, remarks := range []string{"tcpin-tcpin@e", "wsin-wsin@e"} {
|
||||
manual := findDocByRemarks(docs, remarks)
|
||||
if manual == nil {
|
||||
t.Fatalf("manual doc %q missing:\n%s", remarks, out)
|
||||
}
|
||||
if tags := docOutboundTags(manual); tags[0] != "proxy" {
|
||||
t.Fatalf("manual doc %q first tag = %q, want proxy", remarks, tags[0])
|
||||
}
|
||||
manualRouting, _ := manual["routing"].(map[string]any)
|
||||
manualRules, _ := json.Marshal(manualRouting["rules"])
|
||||
if !strings.Contains(string(manualRules), `"outboundTag":"proxy"`) {
|
||||
t.Fatalf("manual doc %q lost its proxy rule: %s", remarks, manualRules)
|
||||
}
|
||||
if _, has := manualRouting["balancers"]; has {
|
||||
t.Fatalf("manual doc %q must not carry balancers", remarks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stringify(values []any) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
out = append(out, v.(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The balancer interleaves with inbounds by the same 1-based number and, on a
|
||||
// tie, follows the inbound group with that number.
|
||||
func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
later := seedSubInbound(t, "s1", "later", 4711, 2, wsTLSStream)
|
||||
first := seedSubInbound(t, "s1", "first", 4712, 1, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
docs := parseSubJsonDocs(t, out)
|
||||
var remarks []string
|
||||
for _, doc := range docs {
|
||||
remarks = append(remarks, doc["remarks"].(string))
|
||||
}
|
||||
if strings.Join(remarks, ",") != "first-first@e,bal,later-later@e" {
|
||||
t.Fatalf("doc order = %v, want [first bal later]", remarks)
|
||||
}
|
||||
balancerDoc := findDocByRemarks(docs, "bal")
|
||||
routing, _ := balancerDoc["routing"].(map[string]any)
|
||||
balancers, _ := routing["balancers"].([]any)
|
||||
strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
|
||||
if strategy["type"] != "roundRobin" {
|
||||
t.Fatalf("strategy = %v, want roundRobin", strategy["type"])
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled balancer is not emitted; an enabled one whose selected inbounds
|
||||
// have no configs for this subscriber is skipped rather than emitted empty.
|
||||
func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
inbound := seedSubInbound(t, "s1", "only", 4721, 1, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "off", Strategy: "random", InboundIds: []int{inbound.Id}, SortOrder: 1, Enabled: false,
|
||||
})
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
docs := parseSubJsonDocs(t, out)
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
|
||||
}
|
||||
if docs[0]["remarks"] != "only-only@e" {
|
||||
t.Fatalf("remaining doc = %v", docs[0]["remarks"])
|
||||
}
|
||||
}
|
||||
|
||||
// Two members sharing a transport get deduplicated tags (…-2 suffix), matching
|
||||
// the reference makeTag convention.
|
||||
func TestSubJson_BalancerTagDedup(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
a := seedSubInbound(t, "s1", "wsa", 4731, 1, wsTLSStream)
|
||||
b := seedSubInbound(t, "s1", "wsb", 4732, 2, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
docs := parseSubJsonDocs(t, out)
|
||||
balancerDoc := findDocByRemarks(docs, "dedup")
|
||||
if balancerDoc == nil {
|
||||
t.Fatalf("balancer doc missing:\n%s", out)
|
||||
}
|
||||
if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
|
||||
t.Fatalf("balancer outbound tags = %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
// random/roundRobin have no fallback so they emit no observatory; leastPing
|
||||
// carries one, with the panel-wide ping config overriding the defaults.
|
||||
func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
rr := seedSubInbound(t, "s1", "rr", 4741, 1, wsTLSStream)
|
||||
lp := seedSubInbound(t, "s1", "lp", 4742, 2, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "rnd", Strategy: "random", InboundIds: []int{rr.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
docs := parseSubJsonDocs(t, out)
|
||||
|
||||
rnd := findDocByRemarks(docs, "rnd")
|
||||
if _, has := rnd["burstObservatory"]; has {
|
||||
t.Fatalf("random balancer must not emit burstObservatory: %v", rnd["burstObservatory"])
|
||||
}
|
||||
|
||||
pinger := findDocByRemarks(docs, "pinger")
|
||||
obs, _ := pinger["burstObservatory"].(map[string]any)
|
||||
if obs == nil {
|
||||
t.Fatalf("leastPing balancer must emit burstObservatory:\n%s", out)
|
||||
}
|
||||
ping, _ := obs["pingConfig"].(map[string]any)
|
||||
if ping["destination"] != "https://probe.example/204" {
|
||||
t.Fatalf("destination = %v, want custom probe URL", ping["destination"])
|
||||
}
|
||||
if ping["httpMethod"] != "GET" {
|
||||
t.Fatalf("httpMethod = %v, want GET", ping["httpMethod"])
|
||||
}
|
||||
if ping["sampling"] != float64(5) {
|
||||
t.Fatalf("sampling = %v, want 5", ping["sampling"])
|
||||
}
|
||||
if ping["interval"] != "1m" {
|
||||
t.Fatalf("interval = %v, want default 1m", ping["interval"])
|
||||
}
|
||||
}
|
||||
|
||||
// A balancer selecting [A, B] with B disabled must carry only A: getInboundsBySubId
|
||||
// filters enable=true, so B never reaches entries. Guards the access scoping.
|
||||
func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
a := seedSubInbound(t, "s1", "keep", 4751, 1, wsTLSStream)
|
||||
b := seedSubInbound(t, "s1", "drop", 4752, 2, wsTLSStream)
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", b.Id).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable inbound B: %v", err)
|
||||
}
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
docs := parseSubJsonDocs(t, out)
|
||||
balancerDoc := findDocByRemarks(docs, "bal")
|
||||
if balancerDoc == nil {
|
||||
t.Fatalf("balancer doc missing (A is still enabled, balancer must emit):\n%s", out)
|
||||
}
|
||||
tags := docOutboundTags(balancerDoc)
|
||||
joined := strings.Join(tags, ",")
|
||||
if !strings.Contains(joined, "bal-1-vless") {
|
||||
t.Fatalf("enabled inbound A must be a balancer member: %v", tags)
|
||||
}
|
||||
// B's address must not surface anywhere in the balancer doc — not as an
|
||||
// outbound tag, not as a connection target a client could dial.
|
||||
balJSON, _ := json.Marshal(balancerDoc)
|
||||
if strings.Contains(string(balJSON), "203.0.113.5:4752") {
|
||||
t.Fatalf("disabled inbound B leaked into balancer doc: %s", balJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// A balancer whose only selected inbound is disabled for this subscriber is
|
||||
// skipped entirely — never emitted as an empty balancer with zero members.
|
||||
func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
only := seedSubInbound(t, "s1", "onlydisabled", 4761, 1, wsTLSStream)
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", only.Id).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable only inbound: %v", err)
|
||||
}
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return
|
||||
}
|
||||
docs := parseSubJsonDocs(t, out)
|
||||
if findDocByRemarks(docs, "empty") != nil {
|
||||
t.Fatalf("balancer with no accessible members must not be emitted:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Connectivity defaults to empty (skip the direct pre-check); an explicit empty
|
||||
// value stays empty instead of restoring the old generate_204 default.
|
||||
func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
inb := seedSubInbound(t, "s1", "lp", 4781, 1, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
|
||||
if ping["connectivity"] != "" {
|
||||
t.Fatalf("default connectivity = %v, want empty (skip)", ping["connectivity"])
|
||||
}
|
||||
|
||||
js.SetObservatoryConfig(`{"connectivity":""}`)
|
||||
out, _, err = js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
|
||||
if ping["connectivity"] != "" {
|
||||
t.Fatalf("explicit empty connectivity = %v, want empty", ping["connectivity"])
|
||||
}
|
||||
|
||||
js.SetObservatoryConfig(`{"connectivity":"http://probe.example/204"}`)
|
||||
out, _, err = js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
|
||||
if ping["connectivity"] != "http://probe.example/204" {
|
||||
t.Fatalf("custom connectivity = %v, want http://probe.example/204", ping["connectivity"])
|
||||
}
|
||||
}
|
||||
|
||||
// leastPing/leastLoad always emit a burst observatory (Xray won't start them
|
||||
// without one); a stored {"enabled":false} is ignored as it is mandatory.
|
||||
func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
a := seedSubInbound(t, "s1", "a", 4771, 1, wsTLSStream)
|
||||
b := seedSubInbound(t, "s1", "b", 4772, 2, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js.SetObservatoryConfig(`{"enabled":false}`)
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
pinger := findDocByRemarks(parseSubJsonDocs(t, out), "pinger")
|
||||
if pinger == nil {
|
||||
t.Fatalf("balancer doc missing:\n%s", out)
|
||||
}
|
||||
if _, has := pinger["burstObservatory"]; !has {
|
||||
t.Fatalf("leastPing must always emit burstObservatory (Xray requires it):\n%s", out)
|
||||
}
|
||||
routing, _ := pinger["routing"].(map[string]any)
|
||||
balancers, _ := routing["balancers"].([]any)
|
||||
balancer, _ := balancers[0].(map[string]any)
|
||||
if balancer["fallbackTag"] != "bal-1-vless" {
|
||||
t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
|
||||
}
|
||||
}
|
||||
|
||||
func observatoryPingConfig(t *testing.T, docs []map[string]any, remarks string) map[string]any {
|
||||
t.Helper()
|
||||
doc := findDocByRemarks(docs, remarks)
|
||||
if doc == nil {
|
||||
t.Fatalf("balancer doc %q missing", remarks)
|
||||
}
|
||||
obs, _ := doc["burstObservatory"].(map[string]any)
|
||||
if obs == nil {
|
||||
t.Fatalf("balancer %q has no burstObservatory", remarks)
|
||||
}
|
||||
ping, _ := obs["pingConfig"].(map[string]any)
|
||||
return ping
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Bad observatory settings (malformed JSON, non-URL destination, bad duration)
|
||||
// must not leak into the emitted burstObservatory — each falls back to the
|
||||
// built-in defaults instead of poisoning the client config.
|
||||
func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
inb := seedSubInbound(t, "s1", "lp", 4821, 1, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
def := defaultSubBalancerObservatoryConfig()
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg string
|
||||
}{
|
||||
{"bad json", `{not-json`},
|
||||
{"bad destination", `{"destination":"not-a-url"}`},
|
||||
{"bad interval", `{"interval":"xyz"}`},
|
||||
{"bad timeout", `{"timeout":"5x"}`},
|
||||
{"bad connectivity", `{"connectivity":"ftp://bad"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js.SetObservatoryConfig(tc.cfg)
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
|
||||
if ping["destination"] != def.Destination {
|
||||
t.Fatalf("destination = %v, want default %q (cfg=%s)", ping["destination"], def.Destination, tc.cfg)
|
||||
}
|
||||
if ping["interval"] != def.Interval {
|
||||
t.Fatalf("interval = %v, want default %q (cfg=%s)", ping["interval"], def.Interval, tc.cfg)
|
||||
}
|
||||
if ping["timeout"] != def.Timeout {
|
||||
t.Fatalf("timeout = %v, want default %q (cfg=%s)", ping["timeout"], def.Timeout, tc.cfg)
|
||||
}
|
||||
if ping["connectivity"] != def.Connectivity {
|
||||
t.Fatalf("connectivity = %v, want default %q (cfg=%s)", ping["connectivity"], def.Connectivity, tc.cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user