Files
3x-ui/internal/web/entity/entity.go
T
DIMFLIX da01b7637d 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>
2026-08-23 22:34:20 +02:00

355 lines
15 KiB
Go

package entity
import (
"crypto/tls"
"math"
"net"
"net/mail"
"net/netip"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
type Msg struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj any `json:"obj"`
}
type AllSetting struct {
WebListen string `json:"webListen" form:"webListen"`
WebDomain string `json:"webDomain" form:"webDomain"`
WebPort int `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"`
WebCertFile string `json:"webCertFile" form:"webCertFile"`
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"`
WebBasePath string `json:"webBasePath" form:"webBasePath"`
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"`
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
IpLimitAllowlist string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"`
PanelOutbound string `json:"panelOutbound" form:"panelOutbound"`
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
Datepicker string `json:"datepicker" form:"datepicker"`
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"`
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"`
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"`
TgRunTime string `json:"tgRunTime" form:"tgRunTime"`
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"`
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`
TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"`
TgLang string `json:"tgLang" form:"tgLang"`
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`
SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"`
SmtpHost string `json:"smtpHost" form:"smtpHost"`
SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"`
SmtpUsername string `json:"smtpUsername" form:"smtpUsername"`
SmtpPassword string `json:"smtpPassword" form:"smtpPassword"`
SmtpFrom string `json:"smtpFrom" form:"smtpFrom"`
SmtpFromName string `json:"smtpFromName" form:"smtpFromName"`
SmtpTo string `json:"smtpTo" form:"smtpTo"`
SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`
SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`
SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`
SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
TimeLocation string `json:"timeLocation" form:"timeLocation"`
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
SubEnable bool `json:"subEnable" form:"subEnable"`
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"`
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
SubTitle string `json:"subTitle" form:"subTitle"`
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"`
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`
SubListen string `json:"subListen" form:"subListen"`
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
SubPath string `json:"subPath" form:"subPath"`
SubDomain string `json:"subDomain" form:"subDomain"`
SubCertFile string `json:"subCertFile" form:"subCertFile"`
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"`
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"`
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"`
SubURI string `json:"subURI" form:"subURI"`
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"`
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"`
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
SubClashPath string `json:"subClashPath" form:"subClashPath"`
SubClashURI string `json:"subClashURI" form:"subClashURI"`
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
SubClashRules string `json:"subClashRules" form:"subClashRules"`
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"`
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
LdapHost string `json:"ldapHost" form:"ldapHost"`
LdapPort int `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"`
LdapInsecureSkipVerify bool `json:"ldapInsecureSkipVerify" form:"ldapInsecureSkipVerify"`
LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"`
LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"`
LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"`
LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"`
LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"`
LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"`
LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"`
LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"`
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
}
type AllSettingView struct {
AllSetting
HasTgBotToken bool `json:"hasTgBotToken"`
HasTwoFactorToken bool `json:"hasTwoFactorToken"`
HasLdapPassword bool `json:"hasLdapPassword"`
HasApiToken bool `json:"hasApiToken"`
HasWarpSecret bool `json:"hasWarpSecret"`
HasNordSecret bool `json:"hasNordSecret"`
HasSmtpPassword bool `json:"hasSmtpPassword"`
}
func pathHasForbiddenChar(s string) bool {
for _, r := range s {
if r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
return true
}
}
return false
}
// CheckNetipAddrOrPrefixList mirrors parseIpLimitAllowlist exactly: net and netip
// disagree (net accepts "/024", netip does not), so save and scan must share rules.
func CheckNetipAddrOrPrefixList(list, message string) error {
for entry := range strings.SplitSeq(list, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if _, err := netip.ParseAddr(entry); err == nil {
continue
}
if _, err := netip.ParsePrefix(entry); err != nil {
return common.NewError(message, entry)
}
}
return nil
}
// checkIPOrCIDRList rejects the first comma-separated entry that is neither a
// bare address nor a CIDR, naming it with the caller's message.
func checkIPOrCIDRList(list, message string) error {
for entry := range strings.SplitSeq(list, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if ip := net.ParseIP(entry); ip != nil {
continue
}
if _, _, err := net.ParseCIDR(entry); err != nil {
return common.NewError(message, entry)
}
}
return nil
}
func (s *AllSetting) CheckValid() error {
if s.WebListen != "" {
ip := net.ParseIP(s.WebListen)
if ip == nil {
return common.NewError("web listen is not valid ip:", s.WebListen)
}
}
if s.SubListen != "" {
ip := net.ParseIP(s.SubListen)
if ip == nil {
return common.NewError("Sub listen is not valid ip:", s.SubListen)
}
}
if s.WebPort <= 0 || s.WebPort > math.MaxUint16 {
return common.NewError("web port is not a valid port:", s.WebPort)
}
if s.SubPort <= 0 || s.SubPort > math.MaxUint16 {
return common.NewError("Sub port is not a valid port:", s.SubPort)
}
if (s.SubPort == s.WebPort) && listenAddressesConflict(s.WebListen, s.SubListen) {
return common.NewError("Sub and Web could not use same ip:port, ", s.SubListen, ":", s.SubPort, " & ", s.WebListen, ":", s.WebPort)
}
if s.WebCertFile != "" || s.WebKeyFile != "" {
_, err := tls.LoadX509KeyPair(s.WebCertFile, s.WebKeyFile)
if err != nil {
return common.NewErrorf("cert file <%v> or key file <%v> invalid: %v", s.WebCertFile, s.WebKeyFile, err)
}
}
if s.SubCertFile != "" || s.SubKeyFile != "" {
_, err := tls.LoadX509KeyPair(s.SubCertFile, s.SubKeyFile)
if err != nil {
return common.NewErrorf("cert file <%v> or key file <%v> invalid: %v", s.SubCertFile, s.SubKeyFile, err)
}
}
for _, p := range []struct {
name string
value string
}{
{"web base path", s.WebBasePath},
{"subscription path", s.SubPath},
{"subscription JSON path", s.SubJsonPath},
{"subscription Clash path", s.SubClashPath},
} {
if pathHasForbiddenChar(p.value) {
return common.NewError("URI path contains an invalid character:", p.name)
}
}
if !strings.HasPrefix(s.WebBasePath, "/") {
s.WebBasePath = "/" + s.WebBasePath
}
if !strings.HasSuffix(s.WebBasePath, "/") {
s.WebBasePath += "/"
}
if !strings.HasPrefix(s.SubPath, "/") {
s.SubPath = "/" + s.SubPath
}
if !strings.HasSuffix(s.SubPath, "/") {
s.SubPath += "/"
}
if !strings.HasPrefix(s.SubJsonPath, "/") {
s.SubJsonPath = "/" + s.SubJsonPath
}
if !strings.HasSuffix(s.SubJsonPath, "/") {
s.SubJsonPath += "/"
}
if !strings.HasPrefix(s.SubClashPath, "/") {
s.SubClashPath = "/" + s.SubClashPath
}
if !strings.HasSuffix(s.SubClashPath, "/") {
s.SubClashPath += "/"
}
if err := checkIPOrCIDRList(s.TrustedProxyCIDRs, "trusted proxy CIDR is not valid:"); err != nil {
return err
}
// Rejected here rather than skipped at scan time: a typo in an allowlist
// entry silently leaves the address unprotected until a trusted network gets banned.
if err := CheckNetipAddrOrPrefixList(s.IpLimitAllowlist, "IP limit allowlist entry is not valid:"); err != nil {
return err
}
_, err := time.LoadLocation(s.TimeLocation)
if err != nil {
return common.NewError("time location not exist:", s.TimeLocation)
}
if s.SmtpFrom != "" {
if _, err := mail.ParseAddress(s.SmtpFrom); err != nil {
return common.NewError("SMTP from address is not valid:", s.SmtpFrom)
}
}
return nil
}
// listenAddressesConflict reports whether two listen addresses on the same port
// would collide at bind time. A wildcard listen ("", "0.0.0.0", "::") overlaps
// every address, so it conflicts with anything on that port; two specific
// addresses conflict only when identical.
func listenAddressesConflict(a, b string) bool {
if a == b {
return true
}
return isWildcardListen(a) || isWildcardListen(b)
}
func isWildcardListen(listen string) bool {
if listen == "" {
return true
}
if ip := net.ParseIP(listen); ip != nil {
return ip.IsUnspecified()
}
return false
}
type HostGroup struct {
GroupId string `json:"groupId"`
InboundIds []int `json:"inboundIds" validate:"required,min=1"`
Hosts []string `json:"hosts" validate:"omitempty"`
SortOrder int `json:"sortOrder"`
Remark string `json:"remark" validate:"required,max=256"`
ServerDescription string `json:"serverDescription" validate:"omitempty,max=64"`
IsDisabled bool `json:"isDisabled"`
IsHidden bool `json:"isHidden"`
Tags []string `json:"tags"`
Port int `json:"port" validate:"gte=0,lte=65535"`
Security string `json:"security" validate:"omitempty,oneof=same tls none reality"`
Sni string `json:"sni"`
HostHeader string `json:"hostHeader"`
Path string `json:"path"`
Alpn []string `json:"alpn"`
Fingerprint string `json:"fingerprint"`
OverrideSniFromAddress bool `json:"overrideSniFromAddress"`
KeepSniBlank bool `json:"keepSniBlank"`
PinnedPeerCertSha256 []string `json:"pinnedPeerCertSha256"`
VerifyPeerCertByName string `json:"verifyPeerCertByName"`
AllowInsecure bool `json:"allowInsecure"`
EchConfigList string `json:"echConfigList"`
MuxParams string `json:"muxParams"`
SockoptParams string `json:"sockoptParams"`
FinalMask string `json:"finalMask"`
VlessRoute string `json:"vlessRoute"`
ExcludeFromSubTypes []string `json:"excludeFromSubTypes"`
NodeGuids []string `json:"nodeGuids"`
MihomoIpVersion string `json:"mihomoIpVersion" validate:"omitempty,oneof=dual ipv4 ipv6 ipv4-prefer ipv6-prefer"`
MihomoX25519 bool `json:"mihomoX25519"`
ShuffleHost bool `json:"shuffleHost"`
}