Files
Muse 1035d00fe4 精简版P9(4b): 删除本地核心整条路径(进程管理/buildXrayConfig本地分支/local-only注入/本地核心接口与任务)
- service/xray.go 1741->573行: 只剩 agent 配置构建(GetXrayConfigForAgent/buildAgentXrayConfig=模板+节点入站+egress+转发编译); 删进程生命周期/状态机/RestartXray/StopXray/SetToNeedRestart/GetXrayTraffic/GetOnlineUsers/balancer/TestRoute 与 injectPanelEgress/injectNodeEgresses/injectMtproto/injectAmneziawg/mergeSubscriptionOutbounds
- controller: 43处 SetToNeedRestart 与各 RestartXray 调用清除; 删 balancerStatus/balancerOverride/routeTest/getXrayResult 路由与handler(纯本地核心操作); 前端删 RouteTester 页签与组件
- server service: GetConfigJson 改模板直读; 状态Xray块恒 stopped/空版本; Stop/RestartXrayService 改 no-op(DB导入/恢复流程保留形状)
- setting service: PanelEgressProxyURL/NodeEgressProxyURL 恒直连(桥在本地核心内,已不存在); NodeService.withOutboundBridge 恒直连回退
- runtime: Local 改为全 no-op 桩(DB即真源); NewManager() 去 LocalDeps(APIPort/SetNeedRestart)
- job: 删 XrayTrafficJob/CheckXrayRunningJob/CheckClientIpJob 三文件(共享声明 EventBus/IPWithTimestamp/clientStatsSnapshotMaxClients 迁 shared.go); web.go 不再调度 MtprotoJob/AmneziaWGJob, 删 OnCrash 接线与 stop() 的 sidecar StopAll
- MTProto/AmneziaWG 下线: AddInbound/UpdateInbound 对两协议直接拒绝(存量行保留可删); TG bot 的 restart 命令与在线查询按无本地核心收口
- 测试: GetXrayConfig 系列移植到 agent 构建(finalmask/clients/wireguard/scale); 删退役行为测试(lifecycle/restart/mtproto本地应用/client-ip job/inject系列); 共享测试helper迁 mtproto_test_helpers_test.go 与 job/integration_helpers_test.go
- 既有红(非本轮引入,已在 f6d6520 复现): TestGetXrayConfig_EnabledClientsStillEmitted、TestRouteRegistryContract(P6删 endpoints.ts 遗留)
2026-10-21 18:43:55 +00:00

1109 lines
32 KiB
Go

package service
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"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",
"subEnable": "true",
"subJsonEnable": "false",
"subJsonAutoDetect": "false",
"subJsonAlwaysArray": "false",
"subJsonUserAgentRegex": "",
"subClashAutoDetect": "false",
"subClashUserAgentRegex": "",
"subTitle": "",
"subSupportUrl": "",
"subProfileUrl": "",
"subAnnounce": "",
"subHideSettings": "false",
"subListen": "",
"subPort": "2096",
"subPath": "/sub/",
"subDomain": "",
"subCertFile": "",
"subKeyFile": "",
"subUpdates": "12",
"subEncrypt": "true",
"subURI": "",
"subJsonPath": "/json/",
"subJsonURI": "",
"subClashEnable": "false",
"subClashPath": "/clash/",
"subClashURI": "",
"subJsonMux": "",
"subJsonFinalMask": "",
"subJsonObservatory": "",
"subThemeDir": "",
"datepicker": "gregorian",
"warp": "",
"warpUpdateInterval": "0",
"nord": "",
"pia": "",
"restartXrayOnClientDisable": "true",
"panelOutbound": "",
"devChannelEnable": "false",
// Event bus — per-subscriber event filtering (empty = all disabled)
// Consecutive failed observatory probes before an outbound.down event fires
"tgBotEnable": "false",
"tgBotToken": "",
"tgBotProxy": "",
"tgBotAPIServer": "",
"tgBotChatId": "",
"tgRunTime": "@daily",
"tgBotBackup": "false",
"tgCpu": "80",
"tgMemory": "80",
"tgLang": "en-US",
// Event bus — per-subscriber event filtering (empty = all disabled)
"tgEnabledEvents": "login.attempt,cpu.high",
"outboundDownThreshold": "3",
}
// 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.TgBotToken = ""
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
var apiTokenCount int64
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
view.HasApiToken = apiTokenCount > 0
}
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) 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")
}
// 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 {
// The panel-egress bridge lived in the panel's own xray core. The lite
// master runs no local core, so there is never a bridge: direct.
return ""
}
func (s *SettingService) NodeEgressProxyURL(nodeID int) string {
// The per-node egress bridge also lived in the panel's own xray core;
// with no local core there is never a bridge: 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) 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) GetSubHideSettings() (bool, error) {
return s.getBool("subHideSettings")
}
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) GetSubJsonMux() (string, error) {
return s.getString("subJsonMux")
}
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) 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
}
// Event bus — per-subscriber event filtering
// 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) 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)
}
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) GetTgEnabledEvents() (string, error) {
return s.getString("tgEnabledEvents")
}
func (s *SettingService) SetTgEnabledEvents(events string) error {
return s.setString("tgEnabledEvents", events)
}
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
}
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
}
return nil
}
func validateSettingsURLs(allSetting *entity.AllSetting) error {
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)
return nil
}
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"
}
// subPort 0 is the explicit "no port in links" setting (reverse proxy /
// tunnel on the scheme's standard port); the sub server keeps binding
// the default port — only the generated links omit the port.
if subPort <= 0 || (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() },
"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
}
/*
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. 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 == "" {
continue
}
if value, ok := defaultValueMap[key]; ok {
result[key] = value
}
}
return result
}