mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
6a5b4fab6a
* feat(clients): add stateless Happ link generator
Generate Happ provider links from the current effective subscription source without caching results. Reject unsafe provider responses and redact failure diagnostics.
* fix(clients): reject duplicate Happ provider fields
Parse Happ provider objects token by token so duplicate supported keys cannot be silently overwritten by encoding/json.
* feat(clients): expose on-demand Happ link API
Expose a no-store client endpoint backed by the Happ link generator and keep its generated OpenAPI contract synchronized.
* fix(openapi): exclude service interfaces from generated types
Keep dependency-injection interfaces out of the frontend API surface while preserving allowed response schemas.
* feat(clients): add stateless Happ QR presentation
Generate Happ links only for the active modal scope and retire late responses so Standard remains immediately available. Add focused component coverage and localized retry guidance across every locale.
* fix(clients): cover overlapping Happ generations
Prove the cancellation cleanup is required by resolving a retired request while its replacement remains pending. Also wait for Regenerate to leave loading state before exercising the existing action.
* fix(clients): harden Happ link handling
Validate generated responses before rendering and hide actions during unresolved requests. Strengthen route, redirect, timeout, and lint regression coverage with mutation-sensitive tests.
* fix(clients): gate Happ link generation behind operator opt-in
- add a fail-closed happLinkEnable setting
- enforce the gate before and after provider requests
- add locked Happ QR state with privacy disclosure and settings link
- cover backend, frontend, settings, and i18n regressions
* fix(frontend): guard oversized Happ QR codes
Keep valid long crypt5 links copyable while suppressing QR rendering and image actions above the encoder's UTF-8 byte limit. Add localized guidance and boundary coverage.
* fix(clients): log the sanitized transport error for Happ link failures
Every fail() call in HappService.Generate passed a string literal as the
detail, so the sanitizer written for provider errors only ever saw
constants, and an operator following the QR modal's "check Logs" hint
found nothing beyond reason=transport. Transport and body-read errors now
flow through sanitizeHappDetail, which also redacts cookie/session pairs.
Drop TestHappLinkEnableDefaultsOffWithoutPersistingRow: it pinned a getter
and its constant default, which the Generate gate test already drives.
* fix(frontend): size the Happ QR cap to level L and keep the QR modal mounted on close
HAPP_QR_MAX_BYTES was the level-M capacity (2331) while QrPanel encodes at
errorLevel "L", whose version-40 byte-mode capacity is 2953, so valid links
between 2332 and 2953 bytes lost their QR. The cap now matches the encoder
and a test renders the real QrPanel at the boundary.
Keying the modal content on `open` remounted it on every close, which cut
the Modal's exit transition and made the openSubId sync unreachable, so
`loading` never turned on for the subLinks fetch and a client without a
subscription link flashed noLinks on reopen. `open` leaves the key and the
sync block now also resets the Happ state.
* chore(clients): request Happ crypt5 links from api-v3
crypto.happ.su serves api-v2.php and api-v3.php side by side. Probed with
the same payloads, both take {"url"} over a JSON POST, answer
{"encrypted_link":"happ://crypt5/..."} of identical length with the same
crypt5 key marker, and fail the same way: 400 "No url provided.",
500 "Invalid URL format.", 405 on GET. Happ's own generator page is
branded "URL Encryption v3", so the panel follows it. The parser and the
link validator are unchanged.
* feat: add local generation of encrypted Happ links
- Implemented functionality to generate encrypted Happ links locally without network dependency.
- Added validation for URL length and format to ensure compliance with processing limits.
- Introduced new error handling for invalid URLs and control characters.
- Updated translations for various languages to reflect changes in Happ link generation.
- Created unit tests to validate the encryption process and ensure session keys and nonces are unique.
* fix(frontend): match the tuic memo deps to the non-optional subSettings
The Happ branch reads subSettings non-optionally in ClientQrModalContent
(happLinkEnable and the WireGuard/AmneziaWG publicHost memos), so React
Compiler infers subSettings.publicHost. The TUIC memo merged in from main
still listed subSettings?.publicHost, which fails oxlint's
preserve-manual-memoization rule and makes the compiler skip optimizing
the component. make verify stopped at lint-fe on the branch head.
* chore(happ): trim the pinned-key provenance comment to two lines
CLAUDE.md caps a comment block at two lines. The bare URL line repeated
the repository and file the next line already names, so it is folded
into that line (review LOW on happ_crypto.go).
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
1702 lines
49 KiB
Go
1702 lines
49 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
|
|
}
|
|
|
|
// 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)
|
|
var apiTokenCount int64
|
|
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
|
|
view.HasApiToken = apiTokenCount > 0
|
|
}
|
|
view.TgBotToken = ""
|
|
view.TwoFactorToken = ""
|
|
view.LdapPassword = ""
|
|
view.SmtpPassword = ""
|
|
return view, nil
|
|
}
|
|
|
|
func secretConfigured(value string) bool {
|
|
return strings.TrimSpace(value) != ""
|
|
}
|
|
|
|
func mustString(value string, _ error) string {
|
|
return value
|
|
}
|
|
|
|
func getEnv(key, fallback string) string {
|
|
val, ok := os.LookupEnv(key)
|
|
if !ok {
|
|
return fallback
|
|
}
|
|
val = strings.TrimSpace(val)
|
|
if val == "" {
|
|
return fallback
|
|
}
|
|
return val
|
|
}
|
|
|
|
func (s *SettingService) ResetSettings() error {
|
|
db := database.GetDB()
|
|
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)
|
|
}
|
|
|
|
// GetOutboundDownThreshold returns how many consecutive failed observatory
|
|
// probes an outbound must accumulate before an outbound.down notification is
|
|
// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
|
|
func (s *SettingService) GetOutboundDownThreshold() (int, error) {
|
|
return s.getInt("outboundDownThreshold")
|
|
}
|
|
|
|
func (s *SettingService) SetOutboundDownThreshold(value int) error {
|
|
return s.setInt("outboundDownThreshold", value)
|
|
}
|
|
|
|
// SecretClears marks redacted secrets the user explicitly emptied. Without a
|
|
// flag, a blank submitted secret means "unchanged" (the field is always served
|
|
// blank to the browser) and the stored value is preserved.
|
|
type SecretClears struct {
|
|
TgBotToken bool
|
|
LdapPassword bool
|
|
SmtpPassword bool
|
|
}
|
|
|
|
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
|
|
if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSettingsURLs(allSetting); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSubUserAgentRegexes(allSetting); err != nil {
|
|
return err
|
|
}
|
|
if err := 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
|
|
}
|
|
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,
|
|
}
|
|
|
|
/*
|
|
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
|
|
}
|