mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
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:
parent
56ce6073ce
commit
a6a4ffbeab
6 changed files with 163 additions and 14 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,14 @@ onMounted(loadInboundTags);
|
|||
</template>
|
||||
</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">
|
||||
<template #title>{{ t('pages.settings.pageSize') }}</template>
|
||||
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
|
||||
|
|
@ -298,8 +306,12 @@ onMounted(loadInboundTags);
|
|||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('password') }}</template>
|
||||
<template #description>
|
||||
{{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
|
||||
</template>
|
||||
<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>
|
||||
</SettingListItem>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,12 @@ defineProps({
|
|||
|
||||
<SettingListItem paddings="small">
|
||||
<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>
|
||||
<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>
|
||||
</SettingListItem>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue