Files
3x-ui/internal/web/service/setting.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

1530 lines
44 KiB
Go

package service
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/xlzd/gotp"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"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/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
//go:embed config.json
var xrayTemplateConfig string
const (
DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
DefaultSubJsonUserAgentRegex = ``
DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
DefaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128"
maxRegexLength = 2048
)
var defaultValueMap = map[string]string{
"xrayTemplateConfig": xrayTemplateConfig,
"webListen": "",
"webDomain": "",
"webPort": "2053",
"webCertFile": "",
"webKeyFile": "",
"secret": random.Seq(32),
"panelGuid": uuid.NewString(),
"apiToken": "",
// Node mTLS material (opt-in). All default empty: the CA + master client
// cert are minted lazily on first use, and the node-side trust CA is pasted
// in by the operator. Kept out of entity.AllSetting so private keys never
// reach the settings UI/export.
"nodeMtlsCaCertPem": "",
"nodeMtlsCaKeyPem": "",
"nodeMtlsClientCertPem": "",
"nodeMtlsClientKeyPem": "",
"nodeMtlsClientCertSha256": "",
"nodeMtlsClientCAPem": "",
"webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
"sessionMaxAge": "360",
"trustedProxyCIDRs": DefaultTrustedProxyCIDRs,
"ipLimitAllowlist": "",
"pageSize": "25",
"expireDiff": "0",
"trafficDiff": "0",
"remarkTemplate": DefaultRemarkTemplate,
"subShowIdentityOnAllLinks": "false",
"timeLocation": "Local",
"tgBotEnable": "false",
"tgBotToken": "",
"tgBotProxy": "",
"tgBotAPIServer": "",
"tgBotChatId": "",
"tgRunTime": "@daily",
"tgBotBackup": "false",
"tgCpu": "80",
"tgMemory": "80",
"tgLang": "en-US",
"twoFactorEnable": "false",
"twoFactorToken": "",
"subEnable": "true",
"subJsonEnable": "false",
"subJsonAutoDetect": "false",
"subJsonAlwaysArray": "false",
"subJsonUserAgentRegex": "",
"subClashAutoDetect": "false",
"subClashUserAgentRegex": "",
"subTitle": "",
"subSupportUrl": "",
"subProfileUrl": "",
"subAnnounce": "",
"subEnableRouting": "false",
"subRoutingRules": "",
"subHideSettings": "false",
"subIncyEnableRouting": "false",
"subIncyRoutingRules": "",
"subListen": "",
"subPort": "2096",
"subPath": "/sub/",
"subDomain": "",
"subCertFile": "",
"subKeyFile": "",
"subUpdates": "12",
"subEncrypt": "true",
"subURI": "",
"subJsonPath": "/json/",
"subJsonURI": "",
"subClashEnable": "false",
"subClashPath": "/clash/",
"subClashURI": "",
"subClashEnableRouting": "false",
"subClashRules": "",
"subJsonMux": "",
"subJsonRules": "",
"subJsonFinalMask": "",
"subJsonObservatory": "",
"subThemeDir": "",
"datepicker": "gregorian",
"warp": "",
"warpUpdateInterval": "0",
"nord": "",
"pia": "",
"externalTrafficInformEnable": "false",
"externalTrafficInformURI": "",
"restartXrayOnClientDisable": "true",
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
"panelOutbound": "",
"devChannelEnable": "false",
// LDAP defaults
"ldapEnable": "false",
"ldapHost": "",
"ldapPort": "389",
"ldapUseTLS": "false",
"ldapInsecureSkipVerify": "false",
"ldapBindDN": "",
"ldapPassword": "",
"ldapBaseDN": "",
"ldapUserFilter": "(objectClass=person)",
"ldapUserAttr": "mail",
"ldapVlessField": "vless_enabled",
"ldapSyncCron": "@every 1m",
"ldapFlagField": "",
"ldapTruthyValues": "true,1,yes,on",
"ldapInvertFlag": "false",
"ldapInboundTags": "",
"ldapAutoCreate": "false",
"ldapAutoDelete": "false",
"ldapDefaultTotalGB": "0",
"ldapDefaultExpiryDays": "0",
"ldapDefaultLimitIP": "0",
// Event bus — per-subscriber event filtering (empty = all disabled)
"tgEnabledEvents": "login.attempt,cpu.high",
"smtpEnabledEvents": "login.attempt,cpu.high",
"smtpCpu": "80",
"smtpMemory": "80",
// Consecutive failed observatory probes before an outbound.down event fires
"outboundDownThreshold": "3",
// Email (SMTP) notifications
"smtpEnable": "false",
"smtpHost": "",
"smtpPort": "587",
"smtpUsername": "",
"smtpPassword": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpTo": "",
"smtpEncryptionType": "starttls", // no, starttls, tls
}
// SettingService provides business logic for application settings management.
// It handles configuration storage, retrieval, and validation for all system settings.
type SettingService struct{}
func (s *SettingService) GetDefaultJSONConfig() (any, error) {
var jsonData any
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
if err != nil {
return nil, err
}
return jsonData, nil
}
func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
db := database.GetDB()
settings := make([]*model.Setting, 0)
err := db.Model(model.Setting{}).Not("key = ?", "xrayTemplateConfig").Find(&settings).Error
if err != nil {
return nil, err
}
allSetting := &entity.AllSetting{}
t := reflect.TypeFor[entity.AllSetting]()
v := reflect.ValueOf(allSetting).Elem()
fields := reflect_util.GetFields(t)
setSetting := func(key, value string) (err error) {
defer func() {
panicErr := recover()
if panicErr != nil {
err = errors.New(fmt.Sprint(panicErr))
}
}()
var found bool
var field reflect.StructField
for _, f := range fields {
if f.Tag.Get("json") == key {
field = f
found = true
break
}
}
if !found {
// Some settings are automatically generated, no need to return to the front end to modify the user
return nil
}
fieldV := v.FieldByName(field.Name)
switch t := fieldV.Interface().(type) {
case int:
n, err := strconv.ParseInt(effectiveSettingValue(key, value), 10, 64)
if err != nil {
return err
}
fieldV.SetInt(n)
case string:
fieldV.SetString(value)
case bool:
fieldV.SetBool(effectiveSettingValue(key, value) == "true")
default:
return common.NewErrorf("unknown field %v type %v", key, t)
}
return
}
keyMap := map[string]bool{}
for _, setting := range settings {
err := setSetting(setting.Key, setting.Value)
if err != nil {
return nil, err
}
keyMap[setting.Key] = true
}
for key, value := range defaultValueMap {
if keyMap[key] {
continue
}
err := setSetting(key, value)
if err != nil {
return nil, err
}
}
return allSetting, nil
}
func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
allSetting, err := s.GetAllSetting()
if err != nil {
return nil, err
}
view := &entity.AllSettingView{AllSetting: *allSetting}
view.HasTgBotToken = secretConfigured(allSetting.TgBotToken)
view.HasTwoFactorToken = secretConfigured(allSetting.TwoFactorToken)
view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword)
var apiTokenCount int64
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
view.HasApiToken = apiTokenCount > 0
}
view.TgBotToken = ""
view.TwoFactorToken = ""
view.LdapPassword = ""
view.SmtpPassword = ""
return view, nil
}
func secretConfigured(value string) bool {
return strings.TrimSpace(value) != ""
}
func mustString(value string, _ error) string {
return value
}
func getEnv(key, fallback string) string {
val, ok := os.LookupEnv(key)
if !ok {
return fallback
}
val = strings.TrimSpace(val)
if val == "" {
return fallback
}
return val
}
func (s *SettingService) ResetSettings() error {
db := database.GetDB()
err := db.Where("1 = 1").Delete(model.Setting{}).Error
if err != nil {
return err
}
return db.Model(model.User{}).
Where("1 = 1").Error
}
func (s *SettingService) getSetting(key string) (*model.Setting, error) {
db := database.GetDB()
setting := &model.Setting{}
err := db.Model(model.Setting{}).Where("key = ?", key).First(setting).Error
if err != nil {
return nil, err
}
return setting, nil
}
func (s *SettingService) saveSetting(key string, value string) error {
setting, err := s.getSetting(key)
db := database.GetDB()
if database.IsNotFound(err) {
return db.Create(&model.Setting{
Key: key,
Value: value,
}).Error
} else if err != nil {
return err
}
setting.Key = key
setting.Value = value
return db.Save(setting).Error
}
func (s *SettingService) getString(key string) (string, error) {
setting, err := s.getSetting(key)
if database.IsNotFound(err) {
value, ok := defaultValueMap[key]
if !ok {
return "", common.NewErrorf("key <%v> not in defaultValueMap", key)
}
return value, nil
} else if err != nil {
return "", err
}
return setting.Value, nil
}
func (s *SettingService) setString(key string, value string) error {
return s.saveSetting(key, value)
}
func effectiveSettingValue(key, stored string) string {
if stored == "" {
if def, ok := defaultValueMap[key]; ok {
return def
}
}
return stored
}
func (s *SettingService) getBool(key string) (bool, error) {
str, err := s.getString(key)
if err != nil {
return false, err
}
return strconv.ParseBool(effectiveSettingValue(key, str))
}
func (s *SettingService) setBool(key string, value bool) error {
return s.setString(key, strconv.FormatBool(value))
}
func (s *SettingService) getInt(key string) (int, error) {
str, err := s.getString(key)
if err != nil {
return 0, err
}
return strconv.Atoi(effectiveSettingValue(key, str))
}
func (s *SettingService) setInt(key string, value int) error {
return s.setString(key, strconv.Itoa(value))
}
func (s *SettingService) GetWarpLastUpdate() (int64, error) {
setting, err := s.getSetting("warpLastUpdate")
if database.IsNotFound(err) {
return 0, nil
}
if err != nil {
return 0, err
}
if setting.Value == "" {
return 0, nil
}
return strconv.ParseInt(setting.Value, 10, 64)
}
func (s *SettingService) SetWarpLastUpdate(val int64) error {
return s.saveSetting("warpLastUpdate", strconv.FormatInt(val, 10))
}
func (s *SettingService) SetWarpUpdateInterval(val int) error {
return s.setInt("warpUpdateInterval", val)
}
func (s *SettingService) GetXrayConfigTemplate() (string, error) {
return s.getString("xrayTemplateConfig")
}
func (s *SettingService) GetXrayOutboundTestUrl() (string, error) {
return s.getString("xrayOutboundTestUrl")
}
func (s *SettingService) SetXrayOutboundTestUrl(url string) error {
clean, err := SanitizeHTTPURL(url)
if err != nil {
return err
}
return s.setString("xrayOutboundTestUrl", clean)
}
func (s *SettingService) GetListen() (string, error) {
return s.getString("webListen")
}
func (s *SettingService) SetListen(ip string) error {
return s.setString("webListen", ip)
}
func (s *SettingService) GetWebDomain() (string, error) {
return s.getString("webDomain")
}
func (s *SettingService) GetTgBotToken() (string, error) {
return s.getString("tgBotToken")
}
func (s *SettingService) SetTgBotToken(token string) error {
return s.setString("tgBotToken", token)
}
func (s *SettingService) GetTgBotProxy() (string, error) {
return s.getString("tgBotProxy")
}
func (s *SettingService) SetTgBotProxy(token string) error {
return s.setString("tgBotProxy", token)
}
// GetPanelOutbound returns the Xray outbound tag the panel's own outbound
// requests (version checks, Telegram, subscription fetches) are routed through.
func (s *SettingService) GetPanelOutbound() (string, error) {
return s.getString("panelOutbound")
}
func (s *SettingService) SetPanelOutbound(tag string) error {
return s.setString("panelOutbound", tag)
}
// PanelEgressProxyURL resolves the loopback SOCKS bridge that the generated
// config exposes when a panel outbound is configured (see injectPanelEgress).
// It returns "" — meaning a direct connection — when the feature is off or
// the bridge is not present in the running core yet.
func (s *SettingService) PanelEgressProxyURL() string {
tag, err := s.GetPanelOutbound()
if err != nil || tag == "" {
return ""
}
proc := XrayProcess()
if proc == nil || !proc.IsRunning() {
logger.Warning("panel outbound [", tag, "] is set but Xray is not running, using a direct connection")
return ""
}
cfg := proc.GetConfig()
if cfg == nil {
return ""
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
}
}
logger.Warning("panel outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
return ""
}
func (s *SettingService) NodeEgressProxyURL(nodeID int) string {
tag := NodeEgressInboundTag(nodeID)
proc := XrayProcess()
if proc == nil || !proc.IsRunning() {
logger.Warning("node outbound [", tag, "] is set but Xray is not running, using a direct connection")
return ""
}
cfg := proc.GetConfig()
if cfg == nil {
return ""
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag == tag {
return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
}
}
logger.Warning("node outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
return ""
}
// NewProxiedHTTPClient returns an HTTP client that routes the panel's own
// outbound requests through the configured panel outbound (via the loopback
// SOCKS bridge in the running Xray). When the feature is off or the bridge
// is unavailable it falls back to a direct client.
func (s *SettingService) NewProxiedHTTPClient(timeout time.Duration) *http.Client {
proxyUrl := s.PanelEgressProxyURL()
client, err := netproxy.NewHTTPClient(proxyUrl, timeout)
if err != nil {
logger.Warningf("Invalid panel egress proxy %q, using direct connection: %v", proxyUrl, err)
return &http.Client{Timeout: timeout}
}
return client
}
func (s *SettingService) GetTgBotAPIServer() (string, error) {
return s.getString("tgBotAPIServer")
}
func (s *SettingService) SetTgBotAPIServer(token string) error {
return s.setString("tgBotAPIServer", token)
}
func (s *SettingService) GetTgBotChatId() (string, error) {
return s.getString("tgBotChatId")
}
func (s *SettingService) SetTgBotChatId(chatIds string) error {
return s.setString("tgBotChatId", chatIds)
}
func (s *SettingService) GetTgbotEnabled() (bool, error) {
return s.getBool("tgBotEnable")
}
func (s *SettingService) SetTgbotEnabled(value bool) error {
return s.setBool("tgBotEnable", value)
}
func (s *SettingService) GetTgbotRuntime() (string, error) {
return s.getString("tgRunTime")
}
func (s *SettingService) SetTgbotRuntime(time string) error {
return s.setString("tgRunTime", time)
}
func (s *SettingService) GetTgBotBackup() (bool, error) {
return s.getBool("tgBotBackup")
}
func (s *SettingService) GetTgCpu() (int, error) {
return s.getInt("tgCpu")
}
func (s *SettingService) GetTgMemory() (int, error) {
return s.getInt("tgMemory")
}
func (s *SettingService) SetTgMemory(value int) error {
return s.setInt("tgMemory", value)
}
func (s *SettingService) GetTgLang() (string, error) {
return s.getString("tgLang")
}
func (s *SettingService) GetTwoFactorEnable() (bool, error) {
return s.getBool("twoFactorEnable")
}
func (s *SettingService) SetTwoFactorEnable(value bool) error {
return s.setBool("twoFactorEnable", value)
}
func (s *SettingService) GetTwoFactorToken() (string, error) {
return s.getString("twoFactorToken")
}
func (s *SettingService) SetTwoFactorToken(value string) error {
return s.setString("twoFactorToken", value)
}
func (s *SettingService) VerifyTwoFactorCode(code string) error {
enabled, err := s.GetTwoFactorEnable()
if err != nil {
return err
}
if !enabled {
return nil
}
token, err := s.GetTwoFactorToken()
if err != nil {
return err
}
if strings.TrimSpace(token) == "" || !gotp.NewDefaultTOTP(token).Verify(strings.TrimSpace(code), time.Now().Unix()) {
return common.NewError("invalid two factor code")
}
return nil
}
func (s *SettingService) GetPort() (int, error) {
return s.getInt("webPort")
}
func (s *SettingService) SetPort(port int) error {
return s.setInt("webPort", port)
}
func (s *SettingService) SetCertFile(webCertFile string) error {
return s.setString("webCertFile", webCertFile)
}
func (s *SettingService) GetCertFile() (string, error) {
return s.getString("webCertFile")
}
func (s *SettingService) SetKeyFile(webKeyFile string) error {
return s.setString("webKeyFile", webKeyFile)
}
func (s *SettingService) GetKeyFile() (string, error) {
return s.getString("webKeyFile")
}
func (s *SettingService) GetExpireDiff() (int, error) {
return s.getInt("expireDiff")
}
func (s *SettingService) GetTrafficDiff() (int, error) {
return s.getInt("trafficDiff")
}
func (s *SettingService) GetSessionMaxAge() (int, error) {
return s.getInt("sessionMaxAge")
}
// GetIpLimitAllowlist returns the operator's trusted addresses and networks,
// which the IP limit neither counts nor bans.
func (s *SettingService) GetIpLimitAllowlist() (string, error) {
return s.getString("ipLimitAllowlist")
}
func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
return s.getString("trustedProxyCIDRs")
}
func (s *SettingService) GetRemarkTemplate() (string, error) {
return s.getString("remarkTemplate")
}
func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
return s.getBool("subShowIdentityOnAllLinks")
}
func (s *SettingService) GetSecret() ([]byte, error) {
secret, err := s.getString("secret")
if secret == "" || secret == defaultValueMap["secret"] {
if secret == "" {
secret = defaultValueMap["secret"]
}
saveErr := s.saveSetting("secret", secret)
if saveErr != nil {
logger.Warning("save secret failed:", saveErr)
}
}
return []byte(secret), err
}
// GetPanelGuid returns this panel's stable self-identifier, persisting a
// freshly generated UUID on first read. It is the globally stable node
// identity used to attribute online clients and inbounds to the physical
// node that hosts them across a chain of nodes (#4983), where per-panel
// autoincrement node ids are meaningless one hop away.
func (s *SettingService) GetPanelGuid() (string, error) {
guid, err := s.getString("panelGuid")
if err != nil {
return "", err
}
if guid == defaultValueMap["panelGuid"] {
if saveErr := s.saveSetting("panelGuid", guid); saveErr != nil {
logger.Warning("save panelGuid failed:", saveErr)
}
}
return guid, nil
}
func (s *SettingService) SetBasePath(basePath string) error {
if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath
}
if !strings.HasSuffix(basePath, "/") {
basePath += "/"
}
return s.setString("webBasePath", basePath)
}
func (s *SettingService) GetBasePath() (string, error) {
basePath, err := s.getString("webBasePath")
if err != nil {
return "", err
}
return normalizeBasePath(basePath), nil
}
func (s *SettingService) GetTimeLocation() (*time.Location, error) {
l, err := s.getString("timeLocation")
if err != nil {
return nil, err
}
location, err := time.LoadLocation(l)
if err != nil {
defaultLocation := defaultValueMap["timeLocation"]
logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation)
location, err = time.LoadLocation(defaultLocation)
if err != nil {
logger.Errorf("failed to load default location, using UTC: %v", err)
return time.UTC, nil
}
return location, nil
}
return location, nil
}
func (s *SettingService) GetSubEnable() (bool, error) {
return s.getBool("subEnable")
}
func (s *SettingService) GetSubJsonEnable() (bool, error) {
return s.getBool("subJsonEnable")
}
func (s *SettingService) GetSubJsonAutoDetect() (bool, error) {
return s.getBool("subJsonAutoDetect")
}
func (s *SettingService) GetSubJsonAlwaysArray() (bool, error) {
return s.getBool("subJsonAlwaysArray")
}
func (s *SettingService) GetSubJsonUserAgentRegex() (string, error) {
return s.getString("subJsonUserAgentRegex")
}
func (s *SettingService) GetSubClashAutoDetect() (bool, error) {
return s.getBool("subClashAutoDetect")
}
func (s *SettingService) GetSubClashUserAgentRegex() (string, error) {
return s.getString("subClashUserAgentRegex")
}
func (s *SettingService) GetSubTitle() (string, error) {
return s.getString("subTitle")
}
func (s *SettingService) GetSubSupportUrl() (string, error) {
value, err := s.getString("subSupportUrl")
return common.EnsureURLScheme(value), err
}
func (s *SettingService) GetSubProfileUrl() (string, error) {
value, err := s.getString("subProfileUrl")
return common.EnsureURLScheme(value), err
}
func (s *SettingService) GetSubAnnounce() (string, error) {
return s.getString("subAnnounce")
}
func (s *SettingService) GetSubEnableRouting() (bool, error) {
return s.getBool("subEnableRouting")
}
func (s *SettingService) GetSubRoutingRules() (string, error) {
return s.getString("subRoutingRules")
}
func (s *SettingService) GetSubHideSettings() (bool, error) {
return s.getBool("subHideSettings")
}
func (s *SettingService) GetSubIncyEnableRouting() (bool, error) {
return s.getBool("subIncyEnableRouting")
}
func (s *SettingService) GetSubIncyRoutingRules() (string, error) {
return s.getString("subIncyRoutingRules")
}
func (s *SettingService) GetSubListen() (string, error) {
return s.getString("subListen")
}
func (s *SettingService) GetSubPort() (int, error) {
return s.getInt("subPort")
}
func (s *SettingService) GetSubPath() (string, error) {
return s.getString("subPath")
}
func (s *SettingService) GetSubJsonPath() (string, error) {
return s.getString("subJsonPath")
}
func (s *SettingService) GetSubDomain() (string, error) {
return s.getString("subDomain")
}
func (s *SettingService) SetSubCertFile(subCertFile string) error {
return s.setString("subCertFile", subCertFile)
}
func (s *SettingService) GetSubCertFile() (string, error) {
return s.getString("subCertFile")
}
func (s *SettingService) SetSubKeyFile(subKeyFile string) error {
return s.setString("subKeyFile", subKeyFile)
}
func (s *SettingService) GetSubKeyFile() (string, error) {
return s.getString("subKeyFile")
}
func (s *SettingService) GetSubUpdates() (string, error) {
return s.getString("subUpdates")
}
func (s *SettingService) GetSubEncrypt() (bool, error) {
return s.getBool("subEncrypt")
}
func (s *SettingService) GetPageSize() (int, error) {
return s.getInt("pageSize")
}
func (s *SettingService) GetSubURI() (string, error) {
return s.getString("subURI")
}
func (s *SettingService) GetSubJsonURI() (string, error) {
return s.getString("subJsonURI")
}
func (s *SettingService) GetSubClashEnable() (bool, error) {
return s.getBool("subClashEnable")
}
func (s *SettingService) GetSubClashPath() (string, error) {
return s.getString("subClashPath")
}
func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI")
}
func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
return s.getBool("subClashEnableRouting")
}
func (s *SettingService) GetSubClashRules() (string, error) {
return s.getString("subClashRules")
}
func (s *SettingService) GetSubJsonMux() (string, error) {
return s.getString("subJsonMux")
}
func (s *SettingService) GetSubJsonRules() (string, error) {
return s.getString("subJsonRules")
}
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
return s.getString("subJsonFinalMask")
}
func (s *SettingService) GetSubJsonObservatory() (string, error) {
return s.getString("subJsonObservatory")
}
func (s *SettingService) GetSubThemeDir() (string, error) {
return s.getString("subThemeDir")
}
func (s *SettingService) GetDatepicker() (string, error) {
return s.getString("datepicker")
}
func (s *SettingService) GetWarp() (string, error) {
return s.getString("warp")
}
func (s *SettingService) SetWarp(data string) error {
return s.setString("warp", data)
}
func (s *SettingService) GetNord() (string, error) {
return s.getString("nord")
}
func (s *SettingService) SetNord(data string) error {
return s.setString("nord", data)
}
func (s *SettingService) GetPia() (string, error) {
return s.getString("pia")
}
func (s *SettingService) SetPia(data string) error {
return s.setString("pia", data)
}
func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
return s.getBool("externalTrafficInformEnable")
}
func (s *SettingService) SetExternalTrafficInformEnable(value bool) error {
return s.setBool("externalTrafficInformEnable", value)
}
func (s *SettingService) GetExternalTrafficInformURI() (string, error) {
return s.getString("externalTrafficInformURI")
}
func (s *SettingService) SetExternalTrafficInformURI(InformURI string) error {
return s.setString("externalTrafficInformURI", InformURI)
}
func (s *SettingService) GetRestartXrayOnClientDisable() (bool, error) {
return s.getBool("restartXrayOnClientDisable")
}
func (s *SettingService) SetRestartXrayOnClientDisable(value bool) error {
return s.setBool("restartXrayOnClientDisable", value)
}
// GetDevChannelEnable reports whether the panel self-update tracks the rolling
// per-commit dev release instead of the latest stable tag.
func (s *SettingService) GetDevChannelEnable() (bool, error) {
return s.getBool("devChannelEnable")
}
func (s *SettingService) SetDevChannelEnable(value bool) error {
return s.setBool("devChannelEnable", value)
}
// GetIpLimitEnable reports whether the IP-limit feature is available. Always
// true since the panel enforces limits via the core's online-stats API; on an
// older core the job falls back to access-log parsing and warns there when the
// log is missing, so the UI no longer hides the field behind that condition.
func (s *SettingService) GetIpLimitEnable() (bool, error) {
return true, nil
}
// GetAccessLogEnable reports whether an Xray access log is configured. Used by
// the UI for features that genuinely read the log file (the xray log viewer) —
// distinct from IP limiting, which works without it.
func (s *SettingService) GetAccessLogEnable() (bool, error) {
accessLogPath, err := xray.GetAccessLogPath()
if err != nil {
return false, err
}
return (accessLogPath != "none" && accessLogPath != ""), nil
}
// GetLdapEnable returns whether LDAP is enabled.
func (s *SettingService) GetLdapEnable() (bool, error) {
return s.getBool("ldapEnable")
}
func (s *SettingService) GetLdapHost() (string, error) {
return s.getString("ldapHost")
}
func (s *SettingService) GetLdapPort() (int, error) {
return s.getInt("ldapPort")
}
func (s *SettingService) GetLdapUseTLS() (bool, error) {
return s.getBool("ldapUseTLS")
}
func (s *SettingService) GetLdapInsecureSkipVerify() (bool, error) {
return s.getBool("ldapInsecureSkipVerify")
}
func (s *SettingService) GetLdapBindDN() (string, error) {
return s.getString("ldapBindDN")
}
func (s *SettingService) GetLdapPassword() (string, error) {
return s.getString("ldapPassword")
}
func (s *SettingService) GetLdapBaseDN() (string, error) {
return s.getString("ldapBaseDN")
}
func (s *SettingService) GetLdapUserFilter() (string, error) {
return s.getString("ldapUserFilter")
}
func (s *SettingService) GetLdapUserAttr() (string, error) {
return s.getString("ldapUserAttr")
}
func (s *SettingService) GetLdapVlessField() (string, error) {
return s.getString("ldapVlessField")
}
func (s *SettingService) GetLdapSyncCron() (string, error) {
return s.getString("ldapSyncCron")
}
func (s *SettingService) GetLdapFlagField() (string, error) {
return s.getString("ldapFlagField")
}
func (s *SettingService) GetLdapTruthyValues() (string, error) {
return s.getString("ldapTruthyValues")
}
func (s *SettingService) GetLdapInvertFlag() (bool, error) {
return s.getBool("ldapInvertFlag")
}
func (s *SettingService) GetLdapInboundTags() (string, error) {
return s.getString("ldapInboundTags")
}
func (s *SettingService) GetLdapAutoCreate() (bool, error) {
return s.getBool("ldapAutoCreate")
}
func (s *SettingService) GetLdapAutoDelete() (bool, error) {
return s.getBool("ldapAutoDelete")
}
func (s *SettingService) GetLdapDefaultTotalGB() (int, error) {
return s.getInt("ldapDefaultTotalGB")
}
func (s *SettingService) GetLdapDefaultExpiryDays() (int, error) {
return s.getInt("ldapDefaultExpiryDays")
}
func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
return s.getInt("ldapDefaultLimitIP")
}
// Event bus — per-subscriber event filtering
func (s *SettingService) GetTgEnabledEvents() (string, error) {
return s.getString("tgEnabledEvents")
}
func (s *SettingService) SetTgEnabledEvents(events string) error {
return s.setString("tgEnabledEvents", events)
}
func (s *SettingService) GetSmtpEnabledEvents() (string, error) {
return s.getString("smtpEnabledEvents")
}
func (s *SettingService) SetSmtpEnabledEvents(events string) error {
return s.setString("smtpEnabledEvents", events)
}
// Email (SMTP) settings
func (s *SettingService) GetSmtpEnable() (bool, error) {
return s.getBool("smtpEnable")
}
func (s *SettingService) SetSmtpEnable(value bool) error {
return s.setBool("smtpEnable", value)
}
func (s *SettingService) GetSmtpHost() (string, error) {
return s.getString("smtpHost")
}
func (s *SettingService) SetSmtpHost(value string) error {
return s.setString("smtpHost", value)
}
func (s *SettingService) GetSmtpPort() (int, error) {
return s.getInt("smtpPort")
}
func (s *SettingService) SetSmtpPort(value int) error {
return s.setInt("smtpPort", value)
}
func (s *SettingService) GetSmtpUsername() (string, error) {
return s.getString("smtpUsername")
}
func (s *SettingService) SetSmtpUsername(value string) error {
return s.setString("smtpUsername", value)
}
func (s *SettingService) GetSmtpFrom() (string, error) {
return s.getString("smtpFrom")
}
func (s *SettingService) SetSmtpFrom(value string) error {
return s.setString("smtpFrom", value)
}
func (s *SettingService) GetSmtpFromName() (string, error) {
return s.getString("smtpFromName")
}
func (s *SettingService) SetSmtpFromName(value string) error {
return s.setString("smtpFromName", value)
}
func (s *SettingService) GetSmtpPassword() (string, error) {
return s.getString("smtpPassword")
}
func (s *SettingService) SetSmtpPassword(value string) error {
return s.setString("smtpPassword", value)
}
func (s *SettingService) GetSmtpTo() (string, error) {
return s.getString("smtpTo")
}
func (s *SettingService) SetSmtpTo(value string) error {
return s.setString("smtpTo", value)
}
func (s *SettingService) GetSmtpEncryptionType() (string, error) {
return s.getString("smtpEncryptionType")
}
func (s *SettingService) SetSmtpEncryptionType(value string) error {
return s.setString("smtpEncryptionType", value)
}
func (s *SettingService) GetSmtpCpu() (int, error) {
return s.getInt("smtpCpu")
}
func (s *SettingService) SetSmtpCpu(value int) error {
return s.setInt("smtpCpu", value)
}
func (s *SettingService) GetSmtpMemory() (int, error) {
return s.getInt("smtpMemory")
}
func (s *SettingService) SetSmtpMemory(value int) error {
return s.setInt("smtpMemory", value)
}
// GetOutboundDownThreshold returns how many consecutive failed observatory
// probes an outbound must accumulate before an outbound.down notification is
// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
func (s *SettingService) GetOutboundDownThreshold() (int, error) {
return s.getInt("outboundDownThreshold")
}
func (s *SettingService) SetOutboundDownThreshold(value int) error {
return s.setInt("outboundDownThreshold", value)
}
// SecretClears marks redacted secrets the user explicitly emptied. Without a
// flag, a blank submitted secret means "unchanged" (the field is always served
// blank to the browser) and the stored value is preserved.
type SecretClears struct {
TgBotToken bool
LdapPassword bool
SmtpPassword bool
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
return err
}
if err := validateSettingsURLs(allSetting); err != nil {
return err
}
if err := validateSubUserAgentRegexes(allSetting); err != nil {
return err
}
if err := allSetting.CheckValid(); err != nil {
return err
}
v := reflect.ValueOf(allSetting).Elem()
t := reflect.TypeFor[entity.AllSetting]()
fields := reflect_util.GetFields(t)
db := database.GetDB()
return db.Transaction(func(tx *gorm.DB) error {
var existing []*model.Setting
if err := tx.Find(&existing).Error; err != nil {
return err
}
byKey := make(map[string]*model.Setting, len(existing))
for _, st := range existing {
byKey[st.Key] = st
}
for _, field := range fields {
key := field.Tag.Get("json")
fieldV := v.FieldByName(field.Name)
value := fmt.Sprint(fieldV.Interface())
if st, ok := byKey[key]; ok {
if st.Value == value {
continue
}
st.Value = value
if err := tx.Save(st).Error; err != nil {
return err
}
continue
}
if err := tx.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
return err
}
}
return nil
})
}
func validateSubUserAgentRegexes(allSetting *entity.AllSetting) error {
jsonPattern, err := validateSubUserAgentRegex("Xray JSON", allSetting.SubJsonUserAgentRegex, DefaultSubJsonUserAgentRegex)
if err != nil {
return err
}
clashPattern, err := validateSubUserAgentRegex("Clash/Mihomo", allSetting.SubClashUserAgentRegex, DefaultSubClashUserAgentRegex)
if err != nil {
return err
}
allSetting.SubJsonUserAgentRegex = jsonPattern
allSetting.SubClashUserAgentRegex = clashPattern
return nil
}
func validateSubUserAgentRegex(name, pattern, defaultPattern string) (string, error) {
pattern = strings.TrimSpace(pattern)
effectivePattern := pattern
if effectivePattern == "" {
effectivePattern = defaultPattern
}
if len(effectivePattern) > maxRegexLength {
return "", common.NewErrorf("%s User-Agent regex must not exceed %d characters", name, maxRegexLength)
}
if _, err := regexp.Compile(effectivePattern); err != nil {
return "", common.NewErrorf("%s User-Agent regex is invalid: %v", name, err)
}
// Return the original pattern (empty string if cleared) so the caller
// can distinguish "user explicitly set empty" from "user set a value".
// The empty value is stored in the DB and inherited as runtime default.
return pattern, nil
}
func ValidateRegex(pattern string) error {
if len(pattern) > maxRegexLength {
return common.NewErrorf("Regular expression must not exceed %d characters", maxRegexLength)
}
if _, err := regexp.Compile(pattern); err != nil {
return common.NewError("Regular expression is invalid:", err)
}
return nil
}
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
value, err := s.GetTgBotToken()
if err != nil {
return err
}
allSetting.TgBotToken = value
}
if !clears.LdapPassword && strings.TrimSpace(allSetting.LdapPassword) == "" {
value, err := s.GetLdapPassword()
if err != nil {
return err
}
allSetting.LdapPassword = value
}
if allSetting.TwoFactorEnable && strings.TrimSpace(allSetting.TwoFactorToken) == "" {
value, err := s.GetTwoFactorToken()
if err != nil {
return err
}
allSetting.TwoFactorToken = value
}
if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" {
value, err := s.GetSmtpPassword()
if err != nil {
return err
}
allSetting.SmtpPassword = value
}
return nil
}
func validateSettingsURLs(allSetting *entity.AllSetting) error {
if allSetting.ExternalTrafficInformURI != "" {
u, err := SanitizeHTTPURL(allSetting.ExternalTrafficInformURI)
if err != nil {
return common.NewError("external traffic inform URI is invalid:", err)
}
allSetting.ExternalTrafficInformURI = u
}
if allSetting.TgBotAPIServer != "" {
u, err := SanitizeHTTPURL(allSetting.TgBotAPIServer)
if err != nil {
return common.NewError("telegram API server URL is invalid:", err)
}
allSetting.TgBotAPIServer = u
}
// Support/profile links land in subscription headers and page data, where
// client apps resolve a scheme-less value against the panel's own domain.
// Non-http schemes (tg://, mailto:) are legitimate here, so only default
// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
for name, value := range map[string]*string{
"Happ routing source": &allSetting.SubRoutingRules,
"Clash/Mihomo routing source": &allSetting.SubClashRules,
"Incy routing source": &allSetting.SubIncyRoutingRules,
} {
if err := validateRemoteRoutingURLSetting(name, value); err != nil {
return err
}
}
return nil
}
func validateRemoteRoutingURLSetting(name string, value *string) error {
canonical, remote, err := common.ParseRemoteRoutingURL(*value)
if err != nil {
return common.NewError(name, err.Error())
}
if remote {
*value = canonical
}
return nil
}
func (s *SettingService) UpdateSecret(key string, value string) error {
switch key {
case "tgBotToken", "ldapPassword", "twoFactorToken":
return s.saveSetting(key, strings.TrimSpace(value))
default:
return common.NewError("secret key is not replaceable:", key)
}
}
func (s *SettingService) GetDefaultXrayConfig() (any, error) {
var jsonData any
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
if err != nil {
return nil, err
}
return jsonData, nil
}
func extractHostname(host string) string {
h, _, err := net.SplitHostPort(host)
// Err is not nil means host does not contain port
if err != nil {
h = host
}
ip := net.ParseIP(h)
// If it's not an IP, return as is
if ip == nil {
return h
}
// If it's an IPv4, return as is
if ip.To4() != nil {
return h
}
// IPv6 needs bracketing
return "[" + h + "]"
}
// BuildSubURIBase is shared by GetDefaultSettings (the panel's Client
// Information page) and the subscription page so both render subscription
// URLs identically.
func (s *SettingService) BuildSubURIBase(host string) string {
subPort, _ := s.GetSubPort()
subDomain, _ := s.GetSubDomain()
subKeyFile, _ := s.GetSubKeyFile()
subCertFile, _ := s.GetSubCertFile()
subTLS := subKeyFile != "" && subCertFile != ""
if subDomain == "" {
subDomain = extractHostname(host)
}
scheme := "http"
if subTLS {
scheme = "https"
}
if (subPort == 443 && subTLS) || (subPort == 80 && !subTLS) {
return scheme + "://" + subDomain
}
return fmt.Sprintf("%s://%s:%d", scheme, subDomain, subPort)
}
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
type settingFunc func() (any, error)
settings := map[string]settingFunc{
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
"pageSize": func() (any, error) { return s.GetPageSize() },
"defaultCert": func() (any, error) { return s.GetCertFile() },
"defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
"subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
"datepicker": func() (any, error) { return s.GetDatepicker() },
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
"accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() },
"webDomain": func() (any, error) { return s.GetWebDomain() },
"subDomain": func() (any, error) { return s.GetSubDomain() },
"devChannelEnable": func() (any, error) { return s.GetDevChannelEnable() },
"isDevBuild": func() (any, error) { return config.IsDevBuild(), nil },
}
result := make(map[string]any)
for key, fn := range settings {
value, err := fn()
if err != nil {
return "", err
}
result[key] = value
}
subEnable := result["subEnable"].(bool)
subJsonEnable := false
if v, ok := result["subJsonEnable"]; ok {
if b, ok2 := v.(bool); ok2 {
subJsonEnable = b
}
}
subClashEnable := false
if v, ok := result["subClashEnable"]; ok {
if b, ok2 := v.(bool); ok2 {
subClashEnable = b
}
}
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") {
subURI := s.BuildSubURIBase(host)
subTitle, _ := s.GetSubTitle()
subPath, _ := s.GetSubPath()
subJsonPath, _ := s.GetSubJsonPath()
subClashPath, _ := s.GetSubClashPath()
if subEnable && result["subURI"].(string) == "" {
result["subURI"] = subURI + subPath
}
if result["subTitle"].(string) == "" {
result["subTitle"] = subTitle
}
if subJsonEnable && result["subJsonURI"].(string) == "" {
result["subJsonURI"] = subURI + subJsonPath
}
if subClashEnable && result["subClashURI"].(string) == "" {
result["subClashURI"] = subURI + subClashPath
}
}
return result, nil
}
var factoryDefaultSecretKeys = map[string]bool{
"tgBotToken": true,
"twoFactorToken": true,
"ldapPassword": true,
"smtpPassword": true,
}
/*
GetFactoryDefaults returns the shipped default value per setting, keyed by
the AllSetting json field name. Unlike GetDefaultSettings (which reports
current effective values), this is defaultValueMap projected through the
AllSetting field set: only keys that exist as an AllSetting json tag are
returned, minus the credential fields in factoryDefaultSecretKeys. Keys
with no AllSetting field (secret, panelGuid, the node mTLS material,
xrayTemplateConfig) are excluded structurally rather than by deny-list.
*/
func (s *SettingService) GetFactoryDefaults() map[string]string {
result := make(map[string]string)
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
key := field.Tag.Get("json")
if key == "" || factoryDefaultSecretKeys[key] {
continue
}
if value, ok := defaultValueMap[key]; ok {
result[key] = value
}
}
return result
}