feat(settings): redact secrets in AllSettingView and add TrustedProxyCIDRs

Introduces AllSettingView which strips tgBotToken, twoFactorToken,
ldapPassword, apiToken and warp/nord secrets before sending them to
the browser, replacing them with boolean hasFoo presence flags. A new
/panel/setting/secret endpoint allows updating individual secrets by
key. Secrets that arrive blank on a save are preserved from the DB
rather than overwritten. Adds TrustedProxyCIDRs as a configurable
setting (defaults to localhost CIDRs). URL fields are validated before
save.
This commit is contained in:
farhadh 2026-05-11 21:10:05 +02:00
parent 56ce6073ce
commit a6a4ffbeab
No known key found for this signature in database
6 changed files with 163 additions and 14 deletions

View file

@ -15,6 +15,7 @@ export class AllSetting {
this.webKeyFile = ""; this.webKeyFile = "";
this.webBasePath = "/"; this.webBasePath = "/";
this.sessionMaxAge = 360; this.sessionMaxAge = 360;
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
this.pageSize = 25; this.pageSize = 25;
this.expireDiff = 0; this.expireDiff = 0;
this.trafficDiff = 0; this.trafficDiff = 0;
@ -87,6 +88,12 @@ export class AllSetting {
this.ldapDefaultTotalGB = 0; this.ldapDefaultTotalGB = 0;
this.ldapDefaultExpiryDays = 0; this.ldapDefaultExpiryDays = 0;
this.ldapDefaultLimitIP = 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) { if (data == null) {
return return

View file

@ -153,6 +153,14 @@ onMounted(loadInboundTags);
</template> </template>
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small">
<template #title>Trusted proxy CIDRs</template>
<template #description>Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers.</template>
<template #control>
<a-input v-model:value="allSetting.trustedProxyCIDRs" placeholder="127.0.0.1/32,::1/128" />
</template>
</SettingListItem>
<SettingListItem paddings="small"> <SettingListItem paddings="small">
<template #title>{{ t('pages.settings.pageSize') }}</template> <template #title>{{ t('pages.settings.pageSize') }}</template>
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template> <template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
@ -298,8 +306,12 @@ onMounted(loadInboundTags);
<SettingListItem paddings="small"> <SettingListItem paddings="small">
<template #title>{{ t('password') }}</template> <template #title>{{ t('password') }}</template>
<template #description>
{{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
</template>
<template #control> <template #control>
<a-input-password v-model:value="allSetting.ldapPassword" /> <a-input-password v-model:value="allSetting.ldapPassword"
:placeholder="allSetting.hasLdapPassword ? 'Configured - enter a new value to replace' : ''" />
</template> </template>
</SettingListItem> </SettingListItem>

View file

@ -23,9 +23,12 @@ defineProps({
<SettingListItem paddings="small"> <SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramToken') }}</template> <template #title>{{ t('pages.settings.telegramToken') }}</template>
<template #description>{{ t('pages.settings.telegramTokenDesc') }}</template> <template #description>
{{ allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc') }}
</template>
<template #control> <template #control>
<a-input v-model:value="allSetting.tgBotToken" type="text" /> <a-input-password v-model:value="allSetting.tgBotToken"
:placeholder="allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''" />
</template> </template>
</SettingListItem> </SettingListItem>

View file

@ -38,18 +38,24 @@ function buildTotp() {
watch(() => props.open, (next) => { watch(() => props.open, (next) => {
if (!next) return; if (!next) return;
enteredCode.value = ''; enteredCode.value = '';
totp = null;
qrValue.value = '';
if (props.token) { if (props.token) {
buildTotp(); buildTotp();
} }
}); });
function close(success) { function close(success, code = '') {
emit('confirm', success); emit('confirm', success, code);
emit('update:open', false); emit('update:open', false);
enteredCode.value = ''; enteredCode.value = '';
} }
function onOk() { function onOk() {
if (props.type === 'confirm' && !props.token) {
close(true, enteredCode.value);
return;
}
if (!totp) return; if (!totp) return;
if (totp.generate() === enteredCode.value) { if (totp.generate() === enteredCode.value) {
close(true); close(true);

View file

@ -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. // AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
type AllSetting struct { type AllSetting struct {
// Web server settings // Web server settings
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
WebPort int `json:"webPort" form:"webPort"` // Web server port number WebPort int `json:"webPort" form:"webPort"` // Web server port number
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server 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 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 WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes 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 // UI settings
PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists
@ -110,6 +111,20 @@ type AllSetting struct {
// JSON subscription routing rules // 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. // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
func (s *AllSetting) CheckValid() error { func (s *AllSetting) CheckValid() error {
if s.WebListen != "" { if s.WebListen != "" {
@ -179,6 +194,19 @@ func (s *AllSetting) CheckValid() error {
s.SubClashPath += "/" 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) _, err := time.LoadLocation(s.TimeLocation)
if err != nil { if err != nil {
return common.NewError("time location not exist:", s.TimeLocation) return common.NewError("time location not exist:", s.TimeLocation)

View file

@ -36,6 +36,7 @@ var defaultValueMap = map[string]string{
"apiToken": "", "apiToken": "",
"webBasePath": "/", "webBasePath": "/",
"sessionMaxAge": "360", "sessionMaxAge": "360",
"trustedProxyCIDRs": "127.0.0.1/32,::1/128",
"pageSize": "25", "pageSize": "25",
"expireDiff": "0", "expireDiff": "0",
"trafficDiff": "0", "trafficDiff": "0",
@ -199,6 +200,32 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
return allSetting, nil 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 { func (s *SettingService) ResetSettings() error {
db := database.GetDB() db := database.GetDB()
err := db.Where("1 = 1").Delete(model.Setting{}).Error 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 { 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) { func (s *SettingService) GetListen() (string, error) {
@ -417,6 +448,10 @@ func (s *SettingService) GetSessionMaxAge() (int, error) {
return s.getInt("sessionMaxAge") return s.getInt("sessionMaxAge")
} }
func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
return s.getString("trustedProxyCIDRs")
}
func (s *SettingService) GetRemarkModel() (string, error) { func (s *SettingService) GetRemarkModel() (string, error) {
return s.getString("remarkModel") return s.getString("remarkModel")
} }
@ -771,6 +806,12 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
} }
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) 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 { if err := allSetting.CheckValid(); err != nil {
return err return err
} }
@ -791,6 +832,58 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
return common.Combine(errs...) 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) { func (s *SettingService) GetDefaultXrayConfig() (any, error) {
var jsonData any var jsonData any
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData) err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)