mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 20:07:13 +00:00
da01b7637d
* 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>
940 lines
30 KiB
Go
940 lines
30 KiB
Go
package sub
|
|
|
|
import (
|
|
_ "embed"
|
|
"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"
|
|
)
|
|
|
|
//go:embed default.json
|
|
var defaultJson string
|
|
|
|
// SubJsonService handles JSON subscription configuration generation and management.
|
|
type SubJsonService struct {
|
|
configJson map[string]any
|
|
defaultOutbounds []json_util.RawMessage
|
|
finalMask string
|
|
mux string
|
|
observatory subBalancerObservatoryConfig
|
|
|
|
SubService *SubService
|
|
}
|
|
|
|
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
|
func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
|
|
var configJson map[string]any
|
|
var defaultOutbounds []json_util.RawMessage
|
|
_ = json.Unmarshal([]byte(defaultJson), &configJson)
|
|
if outboundSlices, ok := configJson["outbounds"].([]any); ok {
|
|
for _, defaultOutbound := range outboundSlices {
|
|
jsonBytes, _ := json.Marshal(defaultOutbound)
|
|
defaultOutbounds = append(defaultOutbounds, jsonBytes)
|
|
}
|
|
}
|
|
|
|
if rules != "" {
|
|
var newRules []any
|
|
routing, _ := configJson["routing"].(map[string]any)
|
|
defaultRules, _ := routing["rules"].([]any)
|
|
_ = json.Unmarshal([]byte(rules), &newRules)
|
|
defaultRules = append(newRules, defaultRules...)
|
|
routing["rules"] = defaultRules
|
|
configJson["routing"] = routing
|
|
}
|
|
|
|
return &SubJsonService{
|
|
configJson: configJson,
|
|
defaultOutbounds: defaultOutbounds,
|
|
finalMask: finalMask,
|
|
mux: mux,
|
|
observatory: defaultSubBalancerObservatoryConfig(),
|
|
SubService: subService,
|
|
}
|
|
}
|
|
|
|
// GetJson generates a JSON subscription configuration for the given subscription ID and host.
|
|
func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
|
|
subReq := s.SubService.ForRequest(host)
|
|
subReq.subscriptionBody = true
|
|
inbounds, err := subReq.getInboundsBySubId(subId)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
externalLinks, err := subReq.getClientExternalLinksBySubId(subId)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if len(inbounds) == 0 && len(externalLinks) == 0 {
|
|
return "", "", nil
|
|
}
|
|
|
|
var header string
|
|
|
|
seenEmails := make(map[string]struct{})
|
|
entries := make([]subConfigEntry, 0, len(inbounds))
|
|
// Prepare Inbounds
|
|
for _, inbound := range inbounds {
|
|
clients := subReq.matchingClients(inbound, subId)
|
|
if len(clients) == 0 {
|
|
continue
|
|
}
|
|
subReq.projectThroughFallbackMaster(inbound)
|
|
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
|
|
injectExternalProxy(inbound, hostEps)
|
|
}
|
|
|
|
var inboundConfigs []json_util.RawMessage
|
|
for _, client := range clients {
|
|
seenEmails[client.Email] = struct{}{}
|
|
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) {
|
|
outbound := parsedExternalOutbound(el.Link)
|
|
if outbound == nil {
|
|
continue
|
|
}
|
|
seenEmails[ext.Email] = struct{}{}
|
|
remark := el.Name
|
|
if remark == "" {
|
|
remark = ext.Email
|
|
}
|
|
newOutbounds := []json_util.RawMessage{outbound}
|
|
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
|
|
newConfigJson := make(map[string]any)
|
|
maps.Copy(newConfigJson, s.configJson)
|
|
newConfigJson["outbounds"] = newOutbounds
|
|
newConfigJson["remarks"] = remark
|
|
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
|
|
configArray = append(configArray, newConfig)
|
|
}
|
|
}
|
|
|
|
if len(configArray) == 0 {
|
|
return "", "", nil
|
|
}
|
|
|
|
emails := make([]string, 0, len(seenEmails))
|
|
for e := range seenEmails {
|
|
emails = append(emails, e)
|
|
}
|
|
traffic, _ := subReq.AggregateTrafficByEmails(emails)
|
|
|
|
var finalJson []byte
|
|
if len(configArray) == 1 && !alwaysReturnArray {
|
|
finalJson, _ = json.MarshalIndent(configArray[0], "", " ")
|
|
} else {
|
|
finalJson, _ = json.MarshalIndent(configArray, "", " ")
|
|
}
|
|
|
|
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
|
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))
|
|
|
|
// When externalProxy is empty the JSON config falls back to a
|
|
// synthetic one whose `dest` is the host the client connects to.
|
|
// For node-managed inbounds we want the node's address — request
|
|
// host won't reach the right xray. resolveInboundAddress already
|
|
// implements the node→subscriber-host fallback chain.
|
|
defaultDest := subReq.resolveInboundAddress(inbound)
|
|
if defaultDest == "" {
|
|
defaultDest = host
|
|
}
|
|
|
|
// Per-inbound xmux takes precedence over the global subJsonMux.
|
|
// When xmux is present inside xhttpSettings, XHTTP multiplexing
|
|
// is handled by xmux — don't also set the legacy outbound.Mux.
|
|
mux := s.mux
|
|
if xhttp, ok := stream["xhttpSettings"].(map[string]any); ok {
|
|
if _, hasXmux := xhttp["xmux"]; hasXmux {
|
|
mux = ""
|
|
}
|
|
}
|
|
|
|
externalProxies, ok := stream["externalProxy"].([]any)
|
|
hasExternalProxy := ok && len(externalProxies) > 0
|
|
if !hasExternalProxy {
|
|
externalProxies = []any{
|
|
map[string]any{
|
|
"forceTls": "same",
|
|
"dest": defaultDest,
|
|
"port": float64(inbound.Port),
|
|
"remark": "",
|
|
},
|
|
}
|
|
}
|
|
|
|
delete(stream, "externalProxy")
|
|
network, _ := stream["network"].(string)
|
|
|
|
for _, ep := range externalProxies {
|
|
extPrxy, ok := ep.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Expand the host's {{VAR}} remark template for this client (no-op for
|
|
// the synthetic/legacy entry) before it's used as the config remark.
|
|
subReq.renderHostRemark(inbound, client, extPrxy, network)
|
|
inbound.Listen, _ = extPrxy["dest"].(string)
|
|
if port, ok := extPrxy["port"].(float64); ok {
|
|
inbound.Port = int(port)
|
|
}
|
|
newStream := cloneStreamForExternalProxy(stream)
|
|
forceTls, _ := extPrxy["forceTls"].(string)
|
|
switch forceTls {
|
|
case "tls":
|
|
if newStream["security"] != "tls" {
|
|
newStream["security"] = "tls"
|
|
newStream["tlsSettings"] = map[string]any{}
|
|
}
|
|
case "none":
|
|
if newStream["security"] != "none" {
|
|
newStream["security"] = "none"
|
|
delete(newStream, "tlsSettings")
|
|
}
|
|
}
|
|
security, _ := newStream["security"].(string)
|
|
if hasExternalProxy {
|
|
applyExternalProxyTLSToStream(extPrxy, newStream, security)
|
|
}
|
|
applyHostStreamOverrides(extPrxy, newStream)
|
|
streamSettings, _ := json.MarshalIndent(newStream, "", " ")
|
|
hostMux := hostMuxOverride(extPrxy)
|
|
|
|
var newOutbounds []json_util.RawMessage
|
|
|
|
switch inbound.Protocol {
|
|
case "vmess":
|
|
newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, jsonMux(mux, hostMux)))
|
|
case "vless":
|
|
vc := client
|
|
vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy))
|
|
// Same gate the raw link and the Clash proxy apply: a flow left
|
|
// over from a transport Vision supported produces an outbound
|
|
// xray refuses to start.
|
|
newNetwork, _ := newStream["network"].(string)
|
|
if vc.Flow != "" && !vlessFlowAllowed(newNetwork, security, subReq.linkSettings(inbound)) {
|
|
vc.Flow = ""
|
|
}
|
|
newOutbounds = append(newOutbounds, s.genVless(subReq, inbound, streamSettings, vc, jsonMux(mux, hostMux)))
|
|
case "trojan", "shadowsocks":
|
|
newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux)))
|
|
case "hysteria":
|
|
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux)))
|
|
case "wireguard":
|
|
wgOutbound := s.genWireguard(inbound, client)
|
|
if wgOutbound == nil {
|
|
continue
|
|
}
|
|
newOutbounds = append(newOutbounds, wgOutbound)
|
|
}
|
|
|
|
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
|
|
newConfigJson := make(map[string]any)
|
|
maps.Copy(newConfigJson, s.configJson)
|
|
|
|
transport, _ := newStream["network"].(string)
|
|
newConfigJson["outbounds"] = newOutbounds
|
|
newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy, transport)
|
|
|
|
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
|
|
newJsonArray = append(newJsonArray, newConfig)
|
|
}
|
|
|
|
return newJsonArray
|
|
}
|
|
|
|
func (s *SubJsonService) streamData(stream string, clientKey string) map[string]any {
|
|
var streamSettings map[string]any
|
|
if err := json.Unmarshal([]byte(stream), &streamSettings); err != nil || streamSettings == nil {
|
|
streamSettings = map[string]any{}
|
|
}
|
|
security, _ := streamSettings["security"].(string)
|
|
switch security {
|
|
case "tls":
|
|
if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
|
|
streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
|
|
} else {
|
|
delete(streamSettings, "tlsSettings")
|
|
}
|
|
case "reality":
|
|
if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
|
|
streamSettings["realitySettings"] = s.realityData(realitySettings, clientKey)
|
|
} else {
|
|
delete(streamSettings, "realitySettings")
|
|
}
|
|
}
|
|
delete(streamSettings, "sockopt")
|
|
|
|
if s.finalMask != "" {
|
|
s.applyGlobalFinalMask(streamSettings)
|
|
}
|
|
|
|
// remove proxy protocol
|
|
network, _ := streamSettings["network"].(string)
|
|
switch network {
|
|
case "tcp":
|
|
streamSettings["tcpSettings"] = s.removeAcceptProxy(streamSettings["tcpSettings"])
|
|
case "ws":
|
|
streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"])
|
|
case "httpupgrade":
|
|
streamSettings["httpupgradeSettings"] = s.removeAcceptProxy(streamSettings["httpupgradeSettings"])
|
|
case "xhttp":
|
|
streamSettings["xhttpSettings"] = s.removeAcceptProxy(streamSettings["xhttpSettings"])
|
|
if xhttp, ok := streamSettings["xhttpSettings"].(map[string]any); ok {
|
|
delete(xhttp, "noSSEHeader")
|
|
delete(xhttp, "scMaxBufferedPosts")
|
|
delete(xhttp, "scStreamUpServerSecs")
|
|
delete(xhttp, "serverMaxHeaderBytes")
|
|
// Values matching xray-core's own defaults stay off the wire:
|
|
// old panels seeded them into every stored config and the
|
|
// literal scMinPostsIntervalMs=30 is a DPI fingerprint (#5141).
|
|
if v, _ := xhttp["scMaxEachPostBytes"].(string); v == "" || v == "1000000" {
|
|
delete(xhttp, "scMaxEachPostBytes")
|
|
}
|
|
if v, _ := xhttp["scMinPostsIntervalMs"].(string); v == "" || v == "30" {
|
|
delete(xhttp, "scMinPostsIntervalMs")
|
|
}
|
|
}
|
|
}
|
|
return streamSettings
|
|
}
|
|
|
|
func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
|
|
var fm map[string]any
|
|
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
|
|
return
|
|
}
|
|
merged := mergeFinalMask(streamSettings["finalmask"], fm)
|
|
if len(merged) > 0 {
|
|
streamSettings["finalmask"] = merged
|
|
}
|
|
}
|
|
|
|
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
|
|
netSettings, ok := setting.(map[string]any)
|
|
if ok {
|
|
delete(netSettings, "acceptProxyProtocol")
|
|
}
|
|
return netSettings
|
|
}
|
|
|
|
func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
|
|
tlsData := make(map[string]any, 1)
|
|
tlsClientSettings, _ := tData["settings"].(map[string]any)
|
|
|
|
tlsData["serverName"] = tData["serverName"]
|
|
tlsData["alpn"] = tData["alpn"]
|
|
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
|
|
tlsData["fingerprint"] = fingerprint
|
|
}
|
|
if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
|
|
tlsData["echConfigList"] = ech
|
|
}
|
|
if vcn, ok := verifyPeerCertByNameValue(tlsClientSettings); ok {
|
|
tlsData["verifyPeerCertByName"] = vcn
|
|
}
|
|
// xray-core now parses pinnedPeerCertSha256 as a comma-separated string, not
|
|
// an array; emit the joined form so v2ray clients can import the config (#5401).
|
|
if pins, ok := pinnedSha256List(tlsClientSettings); ok {
|
|
tlsData["pinnedPeerCertSha256"] = strings.Join(pins, ",")
|
|
}
|
|
return tlsData
|
|
}
|
|
|
|
func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map[string]any {
|
|
rltyData := make(map[string]any, 1)
|
|
rltyClientSettings, _ := rData["settings"].(map[string]any)
|
|
|
|
rltyData["show"] = false
|
|
rltyData["publicKey"] = rltyClientSettings["publicKey"]
|
|
rltyData["fingerprint"] = rltyClientSettings["fingerprint"]
|
|
rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
|
|
|
|
seed, _ := rltyClientSettings["spiderX"].(string)
|
|
rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
|
|
shortIds, ok := rData["shortIds"].([]any)
|
|
if ok && len(shortIds) > 0 {
|
|
rltyData["shortId"], _ = shortIds[random.Num(len(shortIds))].(string)
|
|
} else {
|
|
rltyData["shortId"] = ""
|
|
}
|
|
serverNames, ok := rData["serverNames"].([]any)
|
|
if ok && len(serverNames) > 0 {
|
|
rltyData["serverName"], _ = serverNames[random.Num(len(serverNames))].(string)
|
|
} else {
|
|
rltyData["serverName"] = ""
|
|
}
|
|
|
|
return rltyData
|
|
}
|
|
|
|
// jsonMux picks the per-host mux override when present, else the global mux.
|
|
func jsonMux(global, override string) string {
|
|
if override != "" {
|
|
return override
|
|
}
|
|
return global
|
|
}
|
|
|
|
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
|
|
outbound := Outbound{
|
|
|
|
Protocol: string(inbound.Protocol),
|
|
Tag: "proxy"}
|
|
if mux != "" {
|
|
outbound.Mux = json_util.RawMessage(mux)
|
|
}
|
|
outbound.StreamSettings = streamSettings
|
|
|
|
security := normalizeVmessSecurity(client.Security)
|
|
outbound.Settings = map[string]any{
|
|
"address": inbound.Listen,
|
|
"port": inbound.Port,
|
|
"id": client.ID,
|
|
"security": security,
|
|
"level": 8,
|
|
}
|
|
|
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
|
return result
|
|
}
|
|
|
|
func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
|
|
outbound := Outbound{
|
|
Protocol: string(inbound.Protocol),
|
|
Tag: "proxy"}
|
|
if mux != "" {
|
|
outbound.Mux = json_util.RawMessage(mux)
|
|
}
|
|
outbound.StreamSettings = streamSettings
|
|
|
|
// Add encryption for VLESS outbound from inbound settings
|
|
inboundSettings := subReq.linkSettings(inbound)
|
|
encryption, _ := inboundSettings["encryption"].(string)
|
|
|
|
settings := map[string]any{
|
|
"address": inbound.Listen,
|
|
"port": inbound.Port,
|
|
"id": client.ID,
|
|
"encryption": encryption,
|
|
"level": 8,
|
|
}
|
|
if client.Flow != "" && !inbound.DisableFlow {
|
|
settings["flow"] = client.Flow
|
|
}
|
|
outbound.Settings = settings
|
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
|
return result
|
|
}
|
|
|
|
func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
|
|
outbound := Outbound{}
|
|
|
|
serverData := make([]ServerSetting, 1)
|
|
serverData[0] = ServerSetting{
|
|
Address: inbound.Listen,
|
|
Port: inbound.Port,
|
|
Level: 8,
|
|
Password: client.Password,
|
|
}
|
|
|
|
if inbound.Protocol == model.Shadowsocks {
|
|
inboundSettings := subReq.linkSettings(inbound)
|
|
method, _ := inboundSettings["method"].(string)
|
|
serverData[0].Method = method
|
|
|
|
// server password in multi-user 2022 protocols
|
|
if strings.HasPrefix(method, "2022") {
|
|
if serverPassword, ok := inboundSettings["password"].(string); ok {
|
|
serverData[0].Password = fmt.Sprintf("%s:%s", serverPassword, client.Password)
|
|
}
|
|
}
|
|
}
|
|
|
|
outbound.Protocol = string(inbound.Protocol)
|
|
outbound.Tag = "proxy"
|
|
if mux != "" {
|
|
outbound.Mux = json_util.RawMessage(mux)
|
|
}
|
|
outbound.StreamSettings = streamSettings
|
|
|
|
// Wrap the endpoint in a "servers" array (the standard Xray schema for
|
|
// Shadowsocks/Trojan outbounds). The flat top-level form only parses on very
|
|
// recent xray-core; older bundled cores (e.g. in v2rayN) reject it, so SS
|
|
// links fail to connect. See genVnext/genVless for the VMess/VLESS shape.
|
|
server := map[string]any{
|
|
"address": serverData[0].Address,
|
|
"port": serverData[0].Port,
|
|
"password": serverData[0].Password,
|
|
"level": 8,
|
|
}
|
|
if inbound.Protocol == model.Shadowsocks {
|
|
server["method"] = serverData[0].Method
|
|
}
|
|
outbound.Settings = map[string]any{
|
|
"servers": []any{server},
|
|
}
|
|
|
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
|
return result
|
|
}
|
|
|
|
func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
|
|
outbound := Outbound{
|
|
|
|
Protocol: string(inbound.Protocol),
|
|
Tag: "proxy"}
|
|
|
|
if mux != "" {
|
|
outbound.Mux = json_util.RawMessage(mux)
|
|
}
|
|
|
|
var settings, stream map[string]any
|
|
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
|
version, _ := settings["version"].(float64)
|
|
outbound.Settings = map[string]any{
|
|
"version": int(version),
|
|
"address": inbound.Listen,
|
|
"port": inbound.Port,
|
|
}
|
|
|
|
_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
|
hyStream, _ := stream["hysteriaSettings"].(map[string]any)
|
|
outHyStream := map[string]any{
|
|
"version": int(version),
|
|
"auth": client.Auth,
|
|
}
|
|
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
|
|
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
|
|
}
|
|
if masquerade, ok := hyStream["masquerade"].(map[string]any); ok {
|
|
outHyStream["masquerade"] = masquerade
|
|
}
|
|
newStream["hysteriaSettings"] = outHyStream
|
|
|
|
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
|
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
|
|
}
|
|
|
|
newStream["network"] = "hysteria"
|
|
newStream["security"] = "tls"
|
|
|
|
outbound.StreamSettings, _ = json.MarshalIndent(newStream, "", " ")
|
|
|
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
|
return result
|
|
}
|
|
|
|
// genWireguard builds an Xray wireguard outbound for a native WireGuard inbound,
|
|
// mirroring genWireguardLink: the peer public key is derived from the inbound
|
|
// secretKey, the client owns the private key / tunnel address / pre-shared key,
|
|
// and the peer routes the full tunnel. Returns nil when the client has no key.
|
|
func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Client) json_util.RawMessage {
|
|
if client.PrivateKey == "" {
|
|
return nil
|
|
}
|
|
|
|
var inboundSettings map[string]any
|
|
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
|
secretKey, _ := inboundSettings["secretKey"].(string)
|
|
|
|
peer := map[string]any{
|
|
"endpoint": joinHostPort(inbound.Listen, inbound.Port),
|
|
"allowedIPs": []string{"0.0.0.0/0", "::/0"},
|
|
}
|
|
if secretKey != "" {
|
|
if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil {
|
|
peer["publicKey"] = pub
|
|
}
|
|
}
|
|
if client.PreSharedKey != "" {
|
|
peer["preSharedKey"] = client.PreSharedKey
|
|
}
|
|
if client.KeepAlive > 0 {
|
|
peer["keepAlive"] = client.KeepAlive
|
|
}
|
|
|
|
settings := map[string]any{
|
|
"secretKey": client.PrivateKey,
|
|
"peers": []any{peer},
|
|
}
|
|
if len(client.AllowedIPs) > 0 {
|
|
settings["address"] = client.AllowedIPs
|
|
}
|
|
if mtu, ok := inboundSettings["mtu"].(float64); ok && mtu > 0 {
|
|
settings["mtu"] = int(mtu)
|
|
}
|
|
|
|
outbound := map[string]any{
|
|
"protocol": string(inbound.Protocol),
|
|
"tag": "proxy",
|
|
"settings": settings,
|
|
}
|
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
|
return result
|
|
}
|
|
|
|
func mergeFinalMask(base any, extra map[string]any) map[string]any {
|
|
merged := map[string]any{}
|
|
if baseMap, ok := base.(map[string]any); ok {
|
|
for key, value := range baseMap {
|
|
switch key {
|
|
case "tcp", "udp":
|
|
if masks, ok := value.([]any); ok {
|
|
merged[key] = append([]any(nil), masks...)
|
|
}
|
|
default:
|
|
merged[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
for key, value := range extra {
|
|
switch key {
|
|
case "tcp", "udp":
|
|
baseMasks, _ := merged[key].([]any)
|
|
extraMasks, _ := value.([]any)
|
|
if len(extraMasks) > 0 {
|
|
merged[key] = append(baseMasks, extraMasks...)
|
|
}
|
|
case "quicParams":
|
|
if _, exists := merged[key]; !exists {
|
|
merged[key] = value
|
|
}
|
|
default:
|
|
merged[key] = value
|
|
}
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
type Outbound struct {
|
|
Protocol string `json:"protocol"`
|
|
Tag string `json:"tag"`
|
|
StreamSettings json_util.RawMessage `json:"streamSettings"`
|
|
Mux json_util.RawMessage `json:"mux,omitempty"`
|
|
Settings map[string]any `json:"settings,omitempty"`
|
|
}
|
|
|
|
type ServerSetting struct {
|
|
Password string `json:"password"`
|
|
Level int `json:"level"`
|
|
Address string `json:"address"`
|
|
Port int `json:"port"`
|
|
Flow string `json:"flow,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
}
|