diff --git a/frontend/src/models/setting.js b/frontend/src/models/setting.js
index 7efc9e7d..1f465036 100644
--- a/frontend/src/models/setting.js
+++ b/frontend/src/models/setting.js
@@ -15,6 +15,7 @@ export class AllSetting {
this.webKeyFile = "";
this.webBasePath = "/";
this.sessionMaxAge = 360;
+ this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
this.pageSize = 25;
this.expireDiff = 0;
this.trafficDiff = 0;
@@ -87,6 +88,12 @@ export class AllSetting {
this.ldapDefaultTotalGB = 0;
this.ldapDefaultExpiryDays = 0;
this.ldapDefaultLimitIP = 0;
+ this.hasTgBotToken = false;
+ this.hasTwoFactorToken = false;
+ this.hasLdapPassword = false;
+ this.hasApiToken = false;
+ this.hasWarpSecret = false;
+ this.hasNordSecret = false;
if (data == null) {
return
@@ -97,4 +104,4 @@ export class AllSetting {
equals(other) {
return ObjectUtil.equals(this, other);
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/pages/settings/GeneralTab.vue b/frontend/src/pages/settings/GeneralTab.vue
index 2d85312b..75957277 100644
--- a/frontend/src/pages/settings/GeneralTab.vue
+++ b/frontend/src/pages/settings/GeneralTab.vue
@@ -153,6 +153,14 @@ onMounted(loadInboundTags);
+
+ Trusted proxy CIDRs
+ Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers.
+
+
+
+
+
{{ t('pages.settings.pageSize') }}
{{ t('pages.settings.pageSizeDesc') }}
@@ -298,8 +306,12 @@ onMounted(loadInboundTags);
{{ t('password') }}
+
+ {{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
+
-
+
diff --git a/frontend/src/pages/settings/TelegramTab.vue b/frontend/src/pages/settings/TelegramTab.vue
index ce82350f..15111307 100644
--- a/frontend/src/pages/settings/TelegramTab.vue
+++ b/frontend/src/pages/settings/TelegramTab.vue
@@ -23,9 +23,12 @@ defineProps({
{{ t('pages.settings.telegramToken') }}
- {{ t('pages.settings.telegramTokenDesc') }}
+
+ {{ allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc') }}
+
-
+
diff --git a/frontend/src/pages/settings/TwoFactorModal.vue b/frontend/src/pages/settings/TwoFactorModal.vue
index 51a0afea..d0b5819a 100644
--- a/frontend/src/pages/settings/TwoFactorModal.vue
+++ b/frontend/src/pages/settings/TwoFactorModal.vue
@@ -38,18 +38,24 @@ function buildTotp() {
watch(() => props.open, (next) => {
if (!next) return;
enteredCode.value = '';
+ totp = null;
+ qrValue.value = '';
if (props.token) {
buildTotp();
}
});
-function close(success) {
- emit('confirm', success);
+function close(success, code = '') {
+ emit('confirm', success, code);
emit('update:open', false);
enteredCode.value = '';
}
function onOk() {
+ if (props.type === 'confirm' && !props.token) {
+ close(true, enteredCode.value);
+ return;
+ }
if (!totp) return;
if (totp.generate() === enteredCode.value) {
close(true);
diff --git a/web/entity/entity.go b/web/entity/entity.go
index 77e1d661..b1f02a37 100644
--- a/web/entity/entity.go
+++ b/web/entity/entity.go
@@ -21,13 +21,14 @@ type Msg struct {
// AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
type AllSetting struct {
// Web server settings
- WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
- WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
- WebPort int `json:"webPort" form:"webPort"` // Web server port number
- WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server
- WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
- WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
- SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
+ WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
+ WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
+ WebPort int `json:"webPort" form:"webPort"` // Web server port number
+ WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server
+ WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
+ WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
+ SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
+ TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
// UI settings
PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists
@@ -110,6 +111,20 @@ type AllSetting struct {
// JSON subscription routing rules
}
+// AllSettingView is the browser-safe settings read model. Secret values
+// are redacted from the embedded write model and represented by presence
+// flags so the UI can show configured/not configured state.
+type AllSettingView struct {
+ AllSetting
+
+ HasTgBotToken bool `json:"hasTgBotToken"`
+ HasTwoFactorToken bool `json:"hasTwoFactorToken"`
+ HasLdapPassword bool `json:"hasLdapPassword"`
+ HasApiToken bool `json:"hasApiToken"`
+ HasWarpSecret bool `json:"hasWarpSecret"`
+ HasNordSecret bool `json:"hasNordSecret"`
+}
+
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
func (s *AllSetting) CheckValid() error {
if s.WebListen != "" {
@@ -179,6 +194,19 @@ func (s *AllSetting) CheckValid() error {
s.SubClashPath += "/"
}
+ for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") {
+ cidr = strings.TrimSpace(cidr)
+ if cidr == "" {
+ continue
+ }
+ if ip := net.ParseIP(cidr); ip != nil {
+ continue
+ }
+ if _, _, err := net.ParseCIDR(cidr); err != nil {
+ return common.NewError("trusted proxy CIDR is not valid:", cidr)
+ }
+ }
+
_, err := time.LoadLocation(s.TimeLocation)
if err != nil {
return common.NewError("time location not exist:", s.TimeLocation)
diff --git a/web/service/setting.go b/web/service/setting.go
index 0e0ca8f8..fe0da73d 100644
--- a/web/service/setting.go
+++ b/web/service/setting.go
@@ -36,6 +36,7 @@ var defaultValueMap = map[string]string{
"apiToken": "",
"webBasePath": "/",
"sessionMaxAge": "360",
+ "trustedProxyCIDRs": "127.0.0.1/32,::1/128",
"pageSize": "25",
"expireDiff": "0",
"trafficDiff": "0",
@@ -199,6 +200,32 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
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.HasApiToken = secretConfigured(mustString(s.getString("apiToken")))
+ view.TgBotToken = ""
+ view.TwoFactorToken = ""
+ view.LdapPassword = ""
+ return view, nil
+}
+
+func secretConfigured(value string) bool {
+ return strings.TrimSpace(value) != ""
+}
+
+func mustString(value string, _ error) string {
+ return value
+}
+
func (s *SettingService) ResetSettings() error {
db := database.GetDB()
err := db.Where("1 = 1").Delete(model.Setting{}).Error
@@ -286,7 +313,11 @@ func (s *SettingService) GetXrayOutboundTestUrl() (string, error) {
}
func (s *SettingService) SetXrayOutboundTestUrl(url string) error {
- return s.setString("xrayOutboundTestUrl", url)
+ clean, err := SanitizeHTTPURL(url)
+ if err != nil {
+ return err
+ }
+ return s.setString("xrayOutboundTestUrl", clean)
}
func (s *SettingService) GetListen() (string, error) {
@@ -417,6 +448,10 @@ func (s *SettingService) GetSessionMaxAge() (int, error) {
return s.getInt("sessionMaxAge")
}
+func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
+ return s.getString("trustedProxyCIDRs")
+}
+
func (s *SettingService) GetRemarkModel() (string, error) {
return s.getString("remarkModel")
}
@@ -771,6 +806,12 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
+ if err := s.preserveRedactedSecrets(allSetting); err != nil {
+ return err
+ }
+ if err := validateSettingsURLs(allSetting); err != nil {
+ return err
+ }
if err := allSetting.CheckValid(); err != nil {
return err
}
@@ -791,6 +832,58 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
return common.Combine(errs...)
}
+func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error {
+ if strings.TrimSpace(allSetting.TgBotToken) == "" {
+ value, err := s.GetTgBotToken()
+ if err != nil {
+ return err
+ }
+ allSetting.TgBotToken = value
+ }
+ if 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
+ }
+ 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
+ }
+ return nil
+}
+
+func (s *SettingService) UpdateSecret(key string, value string) error {
+ switch key {
+ case "tgBotToken", "ldapPassword", "twoFactorToken", "apiToken":
+ 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)