mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
bf7ce2daaa
* feat(discord): add Discord notification bot service, settings UI, and event subscriber - internal/web/service/discord: implement lightweight Discord REST API v10 client and EventBus subscriber - internal/web/service/setting: add discordBotEnable, discordBotToken, discordChannelId, discordEnabledEvents, discordCpu, discordMemory settings and secret protection - internal/web/controller: register POST /panel/api/setting/testDiscord endpoint - frontend: add Discord settings tab, notifications configuration, sidebar navigation, and command palette integration - translation: add localization keys across all 13 locales - tests: add comprehensive unit tests with httptest server and verify route/i18n contracts * fix(discord): address PR review findings on concurrency, linting, i18n, and stories - subscriber: eliminate unbounded goroutines, sending inline per EventBus contract - discord: accept context.Context in SendMessage, SendEmbed, SendTest with http.NewRequestWithContext - format: apply gofumpt to controller and entity struct alignments - i18n: localize testDiscord controller responses across all 13 locales - storybook: add DiscordNotifications.stories.tsx component story * docs: add Discord bot setup and operations guide - add docs/content/docs/en/operations/discord-bot.mdx with setup steps, event indicators, settings, and troubleshooting - add docs/content/docs/ru/operations/discord-bot.mdx with localized instructions - update operations/meta.json across en, ru, zh, fa - link Discord bot from panel configuration overview * feat(discord): add discordLang, discordRunTime, discordBotBackup settings and update settings UI - internal/web/entity: add DiscordRunTime, DiscordBotBackup, DiscordLang fields to AllSetting - internal/web/service/setting: add defaultValueMap entries, getters, and setters - frontend: update AllSetting schema, model defaults, and generate OpenAPI / Zod contracts - frontend: extract shared NotifyTimeField component and update DiscordTab with General and Notifications tabs - translation: add localization keys across all 13 locales * feat(discord): implement scheduled status reports and database backup attachments - internal/web/service/discord: add SendMessageWithFiles supporting multipart uploads - internal/web/service/discord: implement BuildReport and SendReport generating rich status embeds - internal/web/service/discord: attach database backup (and config.json) when discordBotBackup is enabled - internal/web/job: implement DiscordNotifyJob scheduled via robfig/cron - internal/web/locale: add LocalizerFor and I18nForLang helpers - internal/web/controller: trigger reloadDiscordFunc to dynamically reschedule cron upon setting changes - internal/web/web: register and reschedule DiscordNotifyJob - tests: comprehensive unit tests for multipart uploads, status reporting, and job execution * feat(discord): add interactive bot commands via Gateway WebSocket and update documentation - internal/web/service/discord/gateway: connect to Discord Gateway v10 via WebSocket (gorilla/websocket) - internal/web/service/discord/gateway: handle heartbeat loop, reconnection, and command dispatch - commands: implement !status, !report, !backup, !usage <email>, !inbounds, !restart, !help (with ! and / prefixes) - internal/web/web: start/stop Gateway client with server and reload dynamically on setting updates - docs: update operations guide (en, ru) with scheduled reports, backups, commands, and privileged intents - tests: add end-to-end WebSocket Gateway test verifying command handling * style(discord): fix goimports formatting and add 3x-ui to gitignore * fix(discord): stop gateway panics, reconnect storms and proxy bypass The Gateway client wrote to its websocket from both the heartbeat ticker and the read loop answering server-requested op 1 heartbeats. gorilla panics on concurrent writes and neither goroutine recovers, so a colliding heartbeat took the whole panel process down; writes now share writeMu. It also reconnected every 5s forever after close codes Discord marks non-reconnectable (4004 bad token, 4010-4014, including 4014 when Message Content Intent is off), re-identifying and logging a warning each time. The loop now stops on those codes; the docs say to restart the panel. The gateway dialed with websocket.DefaultDialer, bypassing the panel egress proxy the REST client already uses, so where Discord is filtered notifications arrived but commands never connected. * fix(discord): deliver the scheduled report when the backup upload fails SendReport posted the report embed and the x-ui.db/config.json attachments in one multipart request. Once the database outgrows Discord's upload cap (20 MiB by default) the request is rejected and the report embed is lost with it on every run, leaving only a log warning. Send the embed first and the attachments as a second message. * chore(discord): delete tests that pass whether or not the code works TestDiscordNotifyJob_NilServiceNoPanic and TestHandleEvent_NilDiscordService feed a nil DiscordService that web.go never passes, and TestDiscordNotifyJob_DisabledNoPanic passes with or without the enable guard because Xray is not running under test. * fix(discord): require admin user IDs for bot commands and honor discordLang Any member who could post in the configured channel could run !backup (the whole x-ui.db and config.json, even with discordBotBackup off), !restart and !usage. Commands now run only for the Discord user IDs in the new discordAdminIds setting; an empty list turns commands off. discordLang was saved and offered in the UI, but nothing read it, so every embed stayed English. The test message, alerts, the scheduled report and command replies now render through I18nForLang in the chosen language, with a discord section in all 13 locales. InitLocalizer takes an fs.FS so tests load the real translation files. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
1807 lines
52 KiB
Go
1807 lines
52 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"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray/dnsconf"
|
|
)
|
|
|
|
//go:embed config.json
|
|
var xrayTemplateConfig string
|
|
|
|
const (
|
|
DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
|
|
DefaultSubJsonUserAgentRegex = ``
|
|
DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
|
|
DefaultSubExpiredTemplate = "⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}"
|
|
DefaultSubTrafficDepletedTemplate = "🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}"
|
|
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",
|
|
"subInfoNodeEnable": "false",
|
|
"subExpiredTemplate": DefaultSubExpiredTemplate,
|
|
"subTrafficDepletedTemplate": DefaultSubTrafficDepletedTemplate,
|
|
"timeLocation": "Local",
|
|
"tgBotEnable": "false",
|
|
"tgBotToken": "",
|
|
"tgBotProxy": "",
|
|
"tgBotAPIServer": "",
|
|
"tgBotChatId": "",
|
|
"tgRunTime": "@daily",
|
|
"tgBotBackup": "false",
|
|
"tgCpu": "80",
|
|
"tgMemory": "80",
|
|
"tgLang": "en-US",
|
|
"twoFactorEnable": "false",
|
|
"twoFactorToken": "",
|
|
"happLinkEnable": "false",
|
|
"subEnable": "true",
|
|
"subJsonEnable": "false",
|
|
"subJsonAutoDetect": "false",
|
|
"subJsonAlwaysArray": "false",
|
|
"subJsonUserAgentRegex": "",
|
|
"subClashAutoDetect": "false",
|
|
"subClashUserAgentRegex": "",
|
|
"subTitle": "",
|
|
"subSupportUrl": "",
|
|
"subProfileUrl": "",
|
|
"subAnnounce": "",
|
|
"subEnableRouting": "false",
|
|
"subRoutingRules": "",
|
|
"subHideSettings": "false",
|
|
"subHappAutoDetect": "false",
|
|
"subHappProviderId": "",
|
|
"subHappNewUrl": "",
|
|
"subHappFallbackUrl": "",
|
|
"subHappSubInfoColor": "blue",
|
|
"subHappSubInfoText": "",
|
|
"subHappSubInfoButtonText": "",
|
|
"subHappSubInfoButtonLink": "",
|
|
"subHappSubExpire": "false",
|
|
"subHappSubExpireButtonLink": "",
|
|
"subHappNotificationExpire": "false",
|
|
"subHappNoLimit": "false",
|
|
"subHappAlwaysHwid": "false",
|
|
"subHappTunMode": "",
|
|
"subHappTunType": "",
|
|
"subHappExcludeRoutes": "",
|
|
"subHappExcludeApns": "false",
|
|
"subHappColorProfile": "",
|
|
"subHappPingType": "",
|
|
"subHappAutoConnect": "false",
|
|
"subHappAutoConnectType": "lowestdelay",
|
|
"subHappPerAppMode": "off",
|
|
"subHappPerAppList": "",
|
|
"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": "",
|
|
"subJsonRoutingRules": "",
|
|
"subJsonDns": "",
|
|
"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
|
|
|
|
// Discord bot notifications
|
|
"discordBotEnable": "false",
|
|
"discordBotToken": "",
|
|
"discordChannelId": "",
|
|
"discordAdminIds": "",
|
|
"discordRunTime": "@daily",
|
|
"discordBotBackup": "false",
|
|
"discordCpu": "80",
|
|
"discordMemory": "80",
|
|
"discordLang": "en-US",
|
|
"discordEnabledEvents": "login.attempt,cpu.high",
|
|
}
|
|
|
|
// 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) 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)
|
|
view.HasDiscordBotToken = secretConfigured(allSetting.DiscordBotToken)
|
|
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 = ""
|
|
view.DiscordBotToken = ""
|
|
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()
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("1 = 1").Delete(model.Setting{}).Error; err != nil {
|
|
return err
|
|
}
|
|
paths := []model.Setting{
|
|
{Key: "subPath", Value: "/" + random.NumLower(16) + "/"},
|
|
{Key: "subJsonPath", Value: "/" + random.NumLower(16) + "/"},
|
|
{Key: "subClashPath", Value: "/" + random.NumLower(16) + "/"},
|
|
}
|
|
return tx.Create(&paths).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) GetSubInfoNodeEnable() (bool, error) {
|
|
return s.getBool("subInfoNodeEnable")
|
|
}
|
|
|
|
func (s *SettingService) GetSubExpiredTemplate() (string, error) {
|
|
return s.getString("subExpiredTemplate")
|
|
}
|
|
|
|
func (s *SettingService) GetSubTrafficDepletedTemplate() (string, error) {
|
|
return s.getString("subTrafficDepletedTemplate")
|
|
}
|
|
|
|
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) GetHappLinkEnable() (bool, error) {
|
|
return s.getBool("happLinkEnable")
|
|
}
|
|
|
|
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) GetSubHappAutoDetect() (bool, error) {
|
|
return s.getBool("subHappAutoDetect")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappProviderId() (string, error) {
|
|
return s.getString("subHappProviderId")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappNewUrl() (string, error) {
|
|
return s.getString("subHappNewUrl")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappFallbackUrl() (string, error) {
|
|
return s.getString("subHappFallbackUrl")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappSubInfoColor() (string, error) {
|
|
return s.getString("subHappSubInfoColor")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappSubInfoText() (string, error) {
|
|
return s.getString("subHappSubInfoText")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappSubInfoButtonText() (string, error) {
|
|
return s.getString("subHappSubInfoButtonText")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappSubInfoButtonLink() (string, error) {
|
|
return s.getString("subHappSubInfoButtonLink")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappSubExpire() (bool, error) {
|
|
return s.getBool("subHappSubExpire")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappSubExpireButtonLink() (string, error) {
|
|
return s.getString("subHappSubExpireButtonLink")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappNotificationExpire() (bool, error) {
|
|
return s.getBool("subHappNotificationExpire")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappNoLimit() (bool, error) {
|
|
return s.getBool("subHappNoLimit")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappAlwaysHwid() (bool, error) {
|
|
return s.getBool("subHappAlwaysHwid")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappTunMode() (string, error) {
|
|
return s.getString("subHappTunMode")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappTunType() (string, error) {
|
|
return s.getString("subHappTunType")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappExcludeRoutes() (string, error) {
|
|
return s.getString("subHappExcludeRoutes")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappExcludeApns() (bool, error) {
|
|
return s.getBool("subHappExcludeApns")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappColorProfile() (string, error) {
|
|
return s.getString("subHappColorProfile")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappPingType() (string, error) {
|
|
return s.getString("subHappPingType")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappAutoConnect() (bool, error) {
|
|
return s.getBool("subHappAutoConnect")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappAutoConnectType() (string, error) {
|
|
return s.getString("subHappAutoConnectType")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappPerAppMode() (string, error) {
|
|
return s.getString("subHappPerAppMode")
|
|
}
|
|
|
|
func (s *SettingService) GetSubHappPerAppList() (string, error) {
|
|
return s.getString("subHappPerAppList")
|
|
}
|
|
|
|
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) GetSubJsonRoutingRules() (string, error) {
|
|
return s.getString("subJsonRoutingRules")
|
|
}
|
|
|
|
func (s *SettingService) GetSubJsonDns() (string, error) {
|
|
return s.getString("subJsonDns")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Discord bot settings
|
|
|
|
func (s *SettingService) GetDiscordBotEnable() (bool, error) {
|
|
return s.getBool("discordBotEnable")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordBotEnable(value bool) error {
|
|
return s.setBool("discordBotEnable", value)
|
|
}
|
|
|
|
func (s *SettingService) GetDiscordBotToken() (string, error) {
|
|
return s.getString("discordBotToken")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordBotToken(value string) error {
|
|
return s.setString("discordBotToken", value)
|
|
}
|
|
|
|
func (s *SettingService) GetDiscordChannelId() (string, error) {
|
|
return s.getString("discordChannelId")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordChannelId(value string) error {
|
|
return s.setString("discordChannelId", value)
|
|
}
|
|
|
|
func (s *SettingService) GetDiscordAdminIds() (string, error) {
|
|
return s.getString("discordAdminIds")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordAdminIds(value string) error {
|
|
return s.setString("discordAdminIds", value)
|
|
}
|
|
|
|
func (s *SettingService) GetDiscordEnabledEvents() (string, error) {
|
|
return s.getString("discordEnabledEvents")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordEnabledEvents(events string) error {
|
|
return s.setString("discordEnabledEvents", events)
|
|
}
|
|
|
|
func (s *SettingService) GetDiscordCpu() (int, error) {
|
|
return s.getInt("discordCpu")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordCpu(value int) error {
|
|
return s.setInt("discordCpu", value)
|
|
}
|
|
|
|
func (s *SettingService) GetDiscordMemory() (int, error) {
|
|
return s.getInt("discordMemory")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordMemory(value int) error {
|
|
return s.setInt("discordMemory", value)
|
|
}
|
|
|
|
func (s *SettingService) GetDiscordRunTime() (string, error) {
|
|
return s.getString("discordRunTime")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordRunTime(value string) error {
|
|
return s.setString("discordRunTime", value)
|
|
}
|
|
|
|
func (s *SettingService) GetDiscordBotBackup() (bool, error) {
|
|
return s.getBool("discordBotBackup")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordBotBackup(value bool) error {
|
|
return s.setBool("discordBotBackup", value)
|
|
}
|
|
|
|
func (s *SettingService) GetDiscordLang() (string, error) {
|
|
return s.getString("discordLang")
|
|
}
|
|
|
|
func (s *SettingService) SetDiscordLang(value string) error {
|
|
return s.setString("discordLang", 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
|
|
DiscordBotToken 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 := validateSubJsonDnsSetting(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
|
|
}
|
|
if !clears.DiscordBotToken && strings.TrimSpace(allSetting.DiscordBotToken) == "" {
|
|
value, err := s.GetDiscordBotToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
allSetting.DiscordBotToken = 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 _, ptr := range []*string{
|
|
&allSetting.SubHappNewUrl,
|
|
&allSetting.SubHappFallbackUrl,
|
|
&allSetting.SubHappSubInfoButtonLink,
|
|
&allSetting.SubHappSubExpireButtonLink,
|
|
} {
|
|
if strings.TrimSpace(*ptr) != "" {
|
|
*ptr = common.EnsureURLScheme(strings.TrimSpace(*ptr))
|
|
}
|
|
}
|
|
for name, value := range map[string]*string{
|
|
"Happ routing source": &allSetting.SubRoutingRules,
|
|
"Clash/Mihomo routing source": &allSetting.SubClashRules,
|
|
"Incy routing source": &allSetting.SubIncyRoutingRules,
|
|
"JSON subscription routing source": &allSetting.SubJsonRoutingRules,
|
|
} {
|
|
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
|
|
}
|
|
|
|
// The same parser the sub server uses, so a value can never be saved as valid
|
|
// and then silently ignored at request time.
|
|
func validateSubJsonDnsSetting(allSetting *entity.AllSetting) error {
|
|
value := strings.TrimSpace(allSetting.SubJsonDns)
|
|
if value != "" {
|
|
if _, err := dnsconf.Parse(value); err != nil {
|
|
return common.NewError("JSON subscription DNS is invalid:", err.Error())
|
|
}
|
|
}
|
|
allSetting.SubJsonDns = value
|
|
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() },
|
|
"happLinkEnable": func() (any, error) { return s.GetHappLinkEnable() },
|
|
"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,
|
|
"discordBotToken": 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
|
|
}
|