Scert/backend/internal/httpapi/settings_api.go
xuans e6c40d85a4 新增DNS账户管理及归属显示
增加 DNS 账户列表接口、归属用户记录、脱敏账号标识和管理页面导航,补充前端回归覆盖。
2026-09-26 12:29:03 +08:00

520 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"context"
"errors"
"net/http"
"reflect"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
type settingsView struct {
Server settingsServerView `json:"server"`
Web settingsWebView `json:"web"`
Paths settingsPathsView `json:"paths"`
ACME settingsACMEView `json:"acme"`
DNS settingsDNSView `json:"dns"`
Notification settingsNotificationView `json:"notification"`
}
type settingsServerView struct {
ProxyHTTPSPort int `json:"proxy_https_port"`
Host string `json:"host"`
Port int `json:"port"`
HTTPSMode string `json:"https_mode"`
}
type settingsWebView struct {
Enabled bool `json:"enabled"`
Hostname string `json:"hostname"`
PublicIP string `json:"public_ip"`
}
type settingsPathsView struct {
Certificate string `json:"certificate"`
}
type settingsACMEView struct {
CA string `json:"ca"`
Environment string `json:"environment"`
AccountEmail string `json:"account_email"`
RenewalCheckIntervalDays int `json:"renewal_check_interval_days"`
RenewalNearExpiryDays int `json:"renewal_near_expiry_days"`
RenewalThresholdDays int `json:"renewal_threshold_days"`
RenewalUrgentThresholdDays int `json:"renewal_urgent_threshold_days"`
RenewalMaxRetries int `json:"renewal_max_retries"`
RenewalRetryIntervalMins int `json:"renewal_retry_interval_mins"`
PropagationTimeoutSecs int `json:"propagation_timeout_secs"`
PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"`
}
type settingsDNSView struct {
Provider string `json:"provider"`
VerificationState string `json:"verification_state"`
CredentialConfigured bool `json:"credential_configured"`
}
type settingsNotificationView struct {
HTTPEnabled bool `json:"http_enabled"`
SMTPEnabled bool `json:"smtp_enabled"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPUsername string `json:"smtp_username"`
SMTPMode string `json:"smtp_mode"`
SMTPAllowPlaintextAuth bool `json:"smtp_allow_plaintext_auth"`
SMTPCredentialConfigured bool `json:"smtp_credential_configured"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HTTPPath string `json:"http_path"`
HTTPLoginPath string `json:"http_login_path"`
HTTPAuthMode string `json:"http_auth_mode"`
HTTPCredentialConfigured bool `json:"http_credential_configured"`
EventMode string `json:"event_mode"`
IncludeDeploymentDetails bool `json:"include_deployment_details"`
IncludeCertificateSerial bool `json:"include_certificate_serial"`
IncludeManagementURL bool `json:"include_management_url"`
SenderName string `json:"sender_name"`
SenderEmail string `json:"sender_email"`
Recipients []string `json:"recipients"`
TimeoutSecs int `json:"timeout_secs"`
RetryCount int `json:"retry_count"`
RetryIntervalsSecs []int `json:"retry_intervals_secs"`
}
type settingsRequest struct {
Server *settingsServerRequest `json:"server"`
Web *settingsWebRequest `json:"web"`
Paths *settingsPathsRequest `json:"paths"`
ACME *settingsACMERequest `json:"acme"`
DNS *settingsDNSRequest `json:"dns"`
Notification *settingsNotificationRequest `json:"notification"`
}
type settingsServerRequest struct {
ProxyHTTPSPort *int `json:"proxy_https_port"`
Host *string `json:"host"`
Port *int `json:"port"`
HTTPSMode *string `json:"https_mode"`
}
type settingsWebRequest struct {
Enabled *bool `json:"enabled"`
Hostname *string `json:"hostname"`
PublicIP *string `json:"public_ip"`
}
type settingsPathsRequest struct {
Certificate *string `json:"certificate"`
}
type settingsACMERequest struct {
CA *string `json:"ca"`
Environment *string `json:"environment"`
AccountEmail *string `json:"account_email"`
RenewalCheckIntervalDays *int `json:"renewal_check_interval_days"`
RenewalNearExpiryDays *int `json:"renewal_near_expiry_days"`
RenewalThresholdDays *int `json:"renewal_threshold_days"`
RenewalUrgentThresholdDays *int `json:"renewal_urgent_threshold_days"`
RenewalMaxRetries *int `json:"renewal_max_retries"`
RenewalRetryIntervalMins *int `json:"renewal_retry_interval_mins"`
PropagationTimeoutSecs *int `json:"propagation_timeout_secs"`
PropagationPollingIntervalSecs *int `json:"propagation_polling_interval_secs"`
}
type settingsDNSRequest struct {
Provider *string `json:"provider"`
CredentialRef *string `json:"credential_ref"`
VerificationToken *string `json:"verification_token"`
TestDomain *string `json:"test_domain"`
}
type settingsNotificationRequest struct {
HTTPEnabled *bool `json:"http_enabled"`
SMTPEnabled *bool `json:"smtp_enabled"`
SMTPHost *string `json:"smtp_host"`
SMTPPort *int `json:"smtp_port"`
SMTPUsername *string `json:"smtp_username"`
SMTPPassword *string `json:"smtp_password"`
SMTPMode *string `json:"smtp_mode"`
SMTPAllowPlaintextAuth *bool `json:"smtp_allow_plaintext_auth"`
HTTPCredentials map[string]string `json:"http_credentials"`
Provider *string `json:"provider"`
Endpoint *string `json:"endpoint"`
HTTPPath *string `json:"http_path"`
HTTPLoginPath *string `json:"http_login_path"`
HTTPAuthMode *string `json:"http_auth_mode"`
EventMode *string `json:"event_mode"`
IncludeDeploymentDetails *bool `json:"include_deployment_details"`
IncludeCertificateSerial *bool `json:"include_certificate_serial"`
IncludeManagementURL *bool `json:"include_management_url"`
SenderName *string `json:"sender_name"`
SenderEmail *string `json:"sender_email"`
Recipients *[]string `json:"recipients"`
TimeoutSecs *int `json:"timeout_secs"`
RetryCount *int `json:"retry_count"`
RetryIntervalsSecs *[]int `json:"retry_intervals_secs"`
}
func (s *Server) getSettings(c *gin.Context) {
s.configMu.RLock()
cfg := s.config
s.configMu.RUnlock()
view := settingsViewFromConfig(cfg)
c.JSON(http.StatusOK, view)
}
func (s *Server) updateSettings(c *gin.Context) {
session, authenticated := s.currentAuthSession(c)
if !authenticated {
s.writeError(c, http.StatusUnauthorized, "请先登录管理后台", nil)
return
}
if strings.TrimSpace(s.configPath) == "" {
s.writeError(c, http.StatusInternalServerError, "配置文件路径未配置", errors.New("配置文件路径为空"))
return
}
var request settingsRequest
if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "设置请求格式不正确", err)
return
}
if !s.tlsMu.TryLock() {
s.writeError(c, http.StatusConflict, "管理证书或配置正在更新,请稍后重试", nil)
return
}
defer s.tlsMu.Unlock()
s.configMu.Lock()
previous := s.config
updated := s.config
applySettingsRequest(&updated, request)
updated.Normalize()
createdCredentialRefs, err := s.saveSettingsNotificationCredentials(c.Request.Context(), &updated, request)
if err != nil {
s.rollbackSetupCredentialsWithLog(createdCredentialRefs)
s.configMu.Unlock()
s.writeError(c, http.StatusBadRequest, "保存通知凭据失败", err)
return
}
dnsCredentialRef, dnsVerificationToken, dnsTestDomain, err := s.applySettingsDNS(c.Request.Context(), &updated, previous, request.DNS)
if err != nil {
s.rollbackSetupCredentialsWithLog(createdCredentialRefs)
if dnsCredentialRef != "" && dnsCredentialRef != previous.DNS.CredentialRef {
s.rollbackSetupCredentialsWithLog([]string{dnsCredentialRef})
}
s.configMu.Unlock()
s.writeError(c, http.StatusBadRequest, "保存 DNS 设置失败", err)
return
}
if dnsCredentialRef != "" && dnsCredentialRef != previous.DNS.CredentialRef {
createdCredentialRefs = append(createdCredentialRefs, dnsCredentialRef)
}
// 只有实际更换凭据才记录新归属,修改其他设置不能认领历史账号。
if updated.DNS.CredentialRef == "" {
updated.DNS.OwnerUserID = ""
} else if updated.DNS.CredentialRef != previous.DNS.CredentialRef {
updated.DNS.OwnerUserID = session.UserID
}
if err := updated.Validate(); err != nil {
s.rollbackSetupCredentialsWithLog(createdCredentialRefs)
s.configMu.Unlock()
s.writeError(c, http.StatusBadRequest, "设置内容校验失败", err)
return
}
if err := updated.Save(s.configPath); err != nil {
s.rollbackSetupCredentialsWithLog(createdCredentialRefs)
s.configMu.Unlock()
s.writeError(c, http.StatusInternalServerError, "保存设置失败", err)
return
}
listenerRestartRequired := previous.Server.Host != updated.Server.Host ||
previous.Server.Port != updated.Server.Port ||
previous.Server.HTTPSMode != updated.Server.HTTPSMode
configRestartRequired := listenerRestartRequired ||
previous.Web.Enabled != updated.Web.Enabled ||
previous.Paths.Certificate != updated.Paths.Certificate ||
!reflect.DeepEqual(nonRenewalACMEConfig(previous.ACME), nonRenewalACMEConfig(updated.ACME)) ||
!reflect.DeepEqual(previous.DNS, updated.DNS) ||
!reflect.DeepEqual(previous.Notification, updated.Notification)
if s.updateRenewalPolicy != nil {
s.updateRenewalPolicy(renewalPolicyFromConfig(updated))
}
message := "设置已保存"
if listenerRestartRequired {
message = "设置已保存;服务监听地址、端口或 HTTPS 模式已变更,请重启 Scert 服务"
} else if configRestartRequired {
message = "设置已保存;Web 页面、证书、DNS 或通知运行配置已变更,请重启 Scert 服务"
}
s.config = updated
s.configMu.Unlock()
oldCredentialRefs := []string{
previous.DNS.CredentialRef,
previous.Notification.HTTPCredentialRef,
previous.Notification.SMTPCredentialRef,
}
newCredentialRefs := map[string]struct{}{
updated.DNS.CredentialRef: {},
updated.Notification.HTTPCredentialRef: {},
updated.Notification.SMTPCredentialRef: {},
}
var obsoleteCredentialRefs []string
for _, reference := range oldCredentialRefs {
reference = strings.TrimSpace(reference)
if reference == "" {
continue
}
if _, stillUsed := newCredentialRefs[reference]; !stillUsed {
obsoleteCredentialRefs = append(obsoleteCredentialRefs, reference)
}
}
if len(obsoleteCredentialRefs) > 0 {
if err := s.rollbackSetupCredentials(obsoleteCredentialRefs); err != nil && s.logger != nil {
s.logger.Warn("\u6e05\u7406\u5df2\u66ff\u6362\u7684\u654f\u611f\u51ed\u636e\u5931\u8d25", "error", err)
}
}
if dnsVerificationToken != "" && s.setupDNS != nil {
if err := s.setupDNS.Consume(dnsVerificationToken, updated.DNS.Provider, dnsCredentialRef, dnsTestDomain); err != nil && s.logger != nil {
s.logger.Warn("系统设置已保存,但清理 DNS 验证令牌失败", "error", err)
}
}
c.JSON(http.StatusOK, gin.H{
"settings": settingsViewFromConfig(updated),
"restart_required": configRestartRequired,
"message": message,
})
}
func (s *Server) applySettingsDNS(ctx context.Context, cfg *config.Config, previous config.Config, request *settingsDNSRequest) (string, string, string, error) {
if cfg == nil || request == nil {
return "", "", "", nil
}
providerChanged := cfg.DNS.Provider != strings.TrimSpace(previous.DNS.Provider)
hasVerificationInput := request.CredentialRef != nil || request.VerificationToken != nil || request.TestDomain != nil
if !hasVerificationInput {
if providerChanged {
cfg.DNS.CredentialRef = ""
cfg.DNS.VerificationState = "pending"
}
return "", "", "", nil
}
if s.setupDNS == nil {
return "", "", "", errors.New("DNS 在线验证服务尚未初始化")
}
if request.CredentialRef == nil || request.VerificationToken == nil || request.TestDomain == nil {
return "", "", "", errors.New("DNS 验证参数不完整,请先完成在线测试")
}
credentialRef := strings.TrimSpace(*request.CredentialRef)
verificationToken := strings.TrimSpace(*request.VerificationToken)
testDomain := strings.TrimSpace(*request.TestDomain)
if credentialRef == "" || verificationToken == "" || testDomain == "" {
return credentialRef, verificationToken, testDomain, errors.New("DNS 验证参数不完整,请先完成在线测试")
}
if err := s.setupDNS.Check(verificationToken, cfg.DNS.Provider, credentialRef, testDomain); err != nil {
return credentialRef, verificationToken, testDomain, err
}
cfg.DNS.CredentialRef = credentialRef
cfg.DNS.VerificationState = "verified"
return credentialRef, verificationToken, testDomain, nil
}
func renewalPolicyFromConfig(cfg config.Config) certificate.RenewalPolicy {
return certificate.RenewalPolicy{
CheckInterval: time.Duration(cfg.ACME.RenewalCheckIntervalDays) * 24 * time.Hour,
NearExpiryThreshold: time.Duration(cfg.ACME.RenewalNearExpiryDays) * 24 * time.Hour,
RenewalThreshold: time.Duration(cfg.ACME.RenewalThresholdDays) * 24 * time.Hour,
UrgentThreshold: time.Duration(cfg.ACME.RenewalUrgentThresholdDays) * 24 * time.Hour,
MaxRetries: cfg.ACME.RenewalMaxRetries,
RetryInterval: time.Duration(cfg.ACME.RenewalRetryIntervalMins) * time.Minute,
}
}
func nonRenewalACMEConfig(cfg config.ACMEConfig) config.ACMEConfig {
cfg.RenewalCheckIntervalDays = 0
cfg.RenewalNearExpiryDays = 0
cfg.RenewalThresholdDays = 0
cfg.RenewalUrgentThresholdDays = 0
cfg.RenewalMaxRetries = 0
cfg.RenewalRetryIntervalMins = 0
return cfg
}
func settingsViewFromConfig(cfg config.Config) settingsView {
return settingsView{
Server: settingsServerView{Host: cfg.Server.Host, Port: cfg.Server.Port, HTTPSMode: cfg.Server.HTTPSMode, ProxyHTTPSPort: cfg.Server.ProxyHTTPSPort},
Web: settingsWebView{Enabled: cfg.Web.Enabled, Hostname: cfg.Web.Hostname, PublicIP: cfg.Web.PublicIP},
Paths: settingsPathsView{Certificate: cfg.Paths.Certificate},
ACME: settingsACMEView{
CA: cfg.ACME.CA, Environment: cfg.ACME.Environment, AccountEmail: cfg.ACME.AccountEmail,
RenewalCheckIntervalDays: cfg.ACME.RenewalCheckIntervalDays, RenewalNearExpiryDays: cfg.ACME.RenewalNearExpiryDays,
RenewalThresholdDays: cfg.ACME.RenewalThresholdDays, RenewalUrgentThresholdDays: cfg.ACME.RenewalUrgentThresholdDays,
RenewalMaxRetries: cfg.ACME.RenewalMaxRetries, RenewalRetryIntervalMins: cfg.ACME.RenewalRetryIntervalMins,
PropagationTimeoutSecs: cfg.ACME.PropagationTimeoutSecs, PropagationPollingIntervalSecs: cfg.ACME.PropagationPollingIntervalSecs,
},
DNS: settingsDNSView{
Provider: cfg.DNS.Provider, VerificationState: cfg.DNS.VerificationState,
CredentialConfigured: strings.TrimSpace(cfg.DNS.CredentialRef) != "",
},
Notification: settingsNotificationView{
HTTPEnabled: cfg.Notification.HTTPEnabled, SMTPEnabled: cfg.Notification.SMTPEnabled, Provider: cfg.Notification.Provider,
SMTPHost: cfg.Notification.SMTPHost, SMTPPort: cfg.Notification.SMTPPort, SMTPUsername: cfg.Notification.SMTPUsername,
SMTPMode: cfg.Notification.SMTPMode, SMTPAllowPlaintextAuth: cfg.Notification.SMTPAllowPlaintextAuth,
SMTPCredentialConfigured: strings.TrimSpace(cfg.Notification.SMTPCredentialRef) != "",
Endpoint: cfg.Notification.Endpoint, HTTPPath: cfg.Notification.HTTPPath, HTTPLoginPath: cfg.Notification.HTTPLoginPath,
HTTPAuthMode: cfg.Notification.HTTPAuthMode, HTTPCredentialConfigured: strings.TrimSpace(cfg.Notification.HTTPCredentialRef) != "",
EventMode: cfg.Notification.EventMode, IncludeDeploymentDetails: cfg.Notification.IncludeDeploymentDetails,
IncludeCertificateSerial: cfg.Notification.IncludeCertificateSerial, IncludeManagementURL: cfg.Notification.IncludeManagementURL,
SenderName: cfg.Notification.SenderName, SenderEmail: cfg.Notification.SenderEmail,
Recipients: append([]string(nil), cfg.Notification.Recipients...), TimeoutSecs: cfg.Notification.TimeoutSecs,
RetryCount: cfg.Notification.RetryCount, RetryIntervalsSecs: append([]int(nil), cfg.Notification.RetryIntervalsSecs...),
},
}
}
func applySettingsRequest(cfg *config.Config, request settingsRequest) {
if cfg == nil {
return
}
if request.Server != nil {
if request.Server.Host != nil {
cfg.Server.Host = strings.TrimSpace(*request.Server.Host)
}
if request.Server.Port != nil {
cfg.Server.Port = *request.Server.Port
}
if request.Server.HTTPSMode != nil {
cfg.Server.HTTPSMode = strings.TrimSpace(*request.Server.HTTPSMode)
}
if request.Server.ProxyHTTPSPort != nil {
cfg.Server.ProxyHTTPSPort = *request.Server.ProxyHTTPSPort
}
}
if request.Web != nil {
if request.Web.Enabled != nil {
cfg.Web.Enabled = *request.Web.Enabled
}
if request.Web.Hostname != nil {
cfg.Web.Hostname = strings.TrimSpace(*request.Web.Hostname)
}
if request.Web.PublicIP != nil {
cfg.Web.PublicIP = strings.TrimSpace(*request.Web.PublicIP)
}
}
if request.Paths != nil && request.Paths.Certificate != nil {
cfg.Paths.Certificate = strings.TrimSpace(*request.Paths.Certificate)
}
if request.ACME != nil {
if request.ACME.CA != nil {
cfg.ACME.CA = strings.TrimSpace(*request.ACME.CA)
}
if request.ACME.Environment != nil {
cfg.ACME.Environment = strings.TrimSpace(*request.ACME.Environment)
}
if request.ACME.AccountEmail != nil {
cfg.ACME.AccountEmail = strings.TrimSpace(*request.ACME.AccountEmail)
}
if request.ACME.RenewalThresholdDays != nil {
cfg.ACME.RenewalThresholdDays = *request.ACME.RenewalThresholdDays
}
if request.ACME.RenewalCheckIntervalDays != nil {
cfg.ACME.RenewalCheckIntervalDays = *request.ACME.RenewalCheckIntervalDays
}
if request.ACME.RenewalNearExpiryDays != nil {
cfg.ACME.RenewalNearExpiryDays = *request.ACME.RenewalNearExpiryDays
}
if request.ACME.RenewalUrgentThresholdDays != nil {
cfg.ACME.RenewalUrgentThresholdDays = *request.ACME.RenewalUrgentThresholdDays
}
if request.ACME.RenewalMaxRetries != nil {
cfg.ACME.RenewalMaxRetries = *request.ACME.RenewalMaxRetries
}
if request.ACME.RenewalRetryIntervalMins != nil {
cfg.ACME.RenewalRetryIntervalMins = *request.ACME.RenewalRetryIntervalMins
}
if request.ACME.PropagationTimeoutSecs != nil {
cfg.ACME.PropagationTimeoutSecs = *request.ACME.PropagationTimeoutSecs
}
if request.ACME.PropagationPollingIntervalSecs != nil {
cfg.ACME.PropagationPollingIntervalSecs = *request.ACME.PropagationPollingIntervalSecs
}
}
if request.DNS != nil && request.DNS.Provider != nil {
cfg.DNS.Provider = strings.TrimSpace(*request.DNS.Provider)
}
if request.Notification != nil {
item := request.Notification
if item.HTTPEnabled != nil {
cfg.Notification.HTTPEnabled = *item.HTTPEnabled
}
if item.SMTPEnabled != nil {
cfg.Notification.SMTPEnabled = *item.SMTPEnabled
}
if item.SMTPHost != nil {
cfg.Notification.SMTPHost = strings.TrimSpace(*item.SMTPHost)
}
if item.SMTPPort != nil {
cfg.Notification.SMTPPort = *item.SMTPPort
}
if item.SMTPUsername != nil {
cfg.Notification.SMTPUsername = strings.TrimSpace(*item.SMTPUsername)
}
if item.SMTPMode != nil {
cfg.Notification.SMTPMode = strings.TrimSpace(*item.SMTPMode)
}
if item.SMTPAllowPlaintextAuth != nil {
cfg.Notification.SMTPAllowPlaintextAuth = *item.SMTPAllowPlaintextAuth
}
if item.Provider != nil {
cfg.Notification.Provider = strings.TrimSpace(*item.Provider)
}
if item.Endpoint != nil {
cfg.Notification.Endpoint = strings.TrimSpace(*item.Endpoint)
}
if item.HTTPPath != nil {
cfg.Notification.HTTPPath = strings.TrimSpace(*item.HTTPPath)
}
if item.HTTPLoginPath != nil {
cfg.Notification.HTTPLoginPath = strings.TrimSpace(*item.HTTPLoginPath)
}
if item.HTTPAuthMode != nil {
cfg.Notification.HTTPAuthMode = strings.TrimSpace(*item.HTTPAuthMode)
}
if item.EventMode != nil {
cfg.Notification.EventMode = strings.TrimSpace(*item.EventMode)
}
if item.IncludeDeploymentDetails != nil {
cfg.Notification.IncludeDeploymentDetails = *item.IncludeDeploymentDetails
}
if item.IncludeCertificateSerial != nil {
cfg.Notification.IncludeCertificateSerial = *item.IncludeCertificateSerial
}
if item.IncludeManagementURL != nil {
cfg.Notification.IncludeManagementURL = *item.IncludeManagementURL
}
if item.SenderName != nil {
cfg.Notification.SenderName = strings.TrimSpace(*item.SenderName)
}
if item.SenderEmail != nil {
cfg.Notification.SenderEmail = strings.TrimSpace(*item.SenderEmail)
}
if item.Recipients != nil {
cfg.Notification.Recipients = append([]string(nil), (*item.Recipients)...)
}
if item.TimeoutSecs != nil {
cfg.Notification.TimeoutSecs = *item.TimeoutSecs
}
if item.RetryCount != nil {
cfg.Notification.RetryCount = *item.RetryCount
}
if item.RetryIntervalsSecs != nil {
cfg.Notification.RetryIntervalsSecs = append([]int(nil), (*item.RetryIntervalsSecs)...)
}
}
}