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.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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ type AllSetting struct {
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue