Smail/backend/controllers/config.go

495 lines
16 KiB
Go

package controllers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/session"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
// ConfigStatus 返回配置管理状态,不返回任何密文、路径或 DSN。
func ConfigStatus(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
manager := config.CurrentManager()
if manager == nil {
response.NewSuccessResponse(map[string]any{
"available": false,
"unavailableReason": "当前使用旧版配置,在线配置管理尚未启用。请由部署管理员完成分区配置迁移后重启服务。",
}).FPrint(w)
return
}
status := manager.Status()
response.NewSuccessResponse(map[string]any{
"available": true,
"setupState": status.SetupState,
"tlsMode": status.TLSMode,
"activeGeneration": status.ActiveGeneration,
"lastValidAt": status.LastValidAt,
"pendingRestart": status.PendingRestart,
"degradedReasons": status.DegradedReasons,
}).FPrint(w)
}
// GetConfigSection 返回指定分区的脱敏视图。
func GetConfigSection(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
parts := strings.Split(strings.Trim(req.URL.Path, "/"), "/")
if len(parts) != 4 {
writeConfigError(w, errors.New("invalid configuration section"))
return
}
manager, section, ok := configRequestManager(w, req)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
public, err := publicConfigSection(manager.Snapshot(), section)
if err != nil {
writeConfigError(w, err)
return
}
response.NewSuccessResponse(public).FPrint(w)
}
type configWriteRequest struct {
ExpectedGeneration uint64 `json:"expectedGeneration"`
Payload json.RawMessage `json:"payload"`
SecretChanges map[string]config.SecretChange `json:"secretChanges,omitempty"`
Confirmation config.StepUpConfirmation `json:"confirmation"`
}
// ValidateConfig 只验证候选配置,不写入磁盘、不改变运行时快照。
func ValidateConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, section, ok := configRequestManager(w, req)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
var input configWriteRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.ExpectedGeneration == 0 {
writeConfigError(w, errors.New("invalid configuration request"))
return
}
result, err := manager.Validate(req.Context(), config.ValidateRequest{
Section: section, ExpectedGeneration: input.ExpectedGeneration,
Payload: input.Payload, SecretChanges: input.SecretChanges,
})
if err != nil {
writeConfigError(w, err)
return
}
response.NewSuccessResponse(result).FPrint(w)
}
// UpdateConfig 保存经过授权和校验的候选配置,等待显式应用。
func UpdateConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, section, ok := configRequestManager(w, req)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
var input configWriteRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.ExpectedGeneration == 0 {
writeConfigError(w, errors.New("invalid configuration request"))
return
}
result, err := manager.Update(req.Context(), config.UpdateRequest{
Section: section, ExpectedGeneration: input.ExpectedGeneration,
Payload: input.Payload, SecretChanges: input.SecretChanges,
Actor: configActor(ctx, req), Confirmation: input.Confirmation,
})
if err != nil {
writeConfigError(w, err)
return
}
response.NewSuccessResponse(map[string]any{"generation": result.Generation, "pending": result.Pending, "candidateId": result.CandidateID}).FPrint(w)
}
type configApplyRequest struct {
Generation uint64 `json:"generation"`
CandidateID string `json:"candidateId"`
}
// ApplyConfig 应用已保存的候选配置代次。
func ApplyConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, section, ok := configRequestManager(w, req)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigWriteGlobal, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
var input configApplyRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.Generation == 0 {
writeConfigError(w, errors.New("invalid configuration generation"))
return
}
result, err := manager.ApplyPendingAuthorized(req.Context(), input.Generation, input.CandidateID, section, configActor(ctx, req))
if err != nil {
writeConfigError(w, err)
return
}
response.NewSuccessResponse(map[string]any{"generation": result.Generation, "appliedAt": result.AppliedAt}).FPrint(w)
}
// ListConfigBackups 返回脱敏备份代次列表。
func ListConfigBackups(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, ok := configManagerOrError(w)
if !ok {
return
}
// 读取脱敏代次不执行回滚;预览、票据和执行仍要求高风险授权。
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
items, err := manager.ListBackups()
if err != nil {
writeConfigError(w, err)
return
}
response.NewSuccessResponse(items).FPrint(w)
}
type rollbackRequest struct {
TargetGeneration uint64 `json:"targetGeneration"`
Ticket string `json:"ticket"`
Confirmation config.StepUpConfirmation `json:"confirmation"`
}
// PreviewConfigRollback 生成回滚预览,不执行回滚。
func PreviewConfigRollback(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, ok := configManagerOrError(w)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigRollback, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
var input rollbackRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.TargetGeneration == 0 {
writeConfigError(w, errors.New("invalid rollback request"))
return
}
preview, err := manager.PreviewRollback(req.Context(), input.TargetGeneration, configActor(ctx, req))
if err != nil {
writeConfigError(w, err)
return
}
response.NewSuccessResponse(preview).FPrint(w)
}
// IssueConfigRollbackTicket 为预览结果签发一次性回滚票据。
func IssueConfigRollbackTicket(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, ok := configManagerOrError(w)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigRollback, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
var input rollbackRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.TargetGeneration == 0 {
writeConfigError(w, errors.New("invalid rollback request"))
return
}
ticket, err := manager.IssueRollbackTicket(req.Context(), input.TargetGeneration, configActor(ctx, req), input.Confirmation)
if err != nil {
writeConfigError(w, err)
return
}
response.NewSuccessResponse(map[string]any{"ticket": ticket}).FPrint(w)
}
// RollbackConfig 使用一次性票据执行回滚。
func RollbackConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, ok := configManagerOrError(w)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigRollback, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
var input rollbackRequest
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Ticket) == "" {
writeConfigError(w, errors.New("invalid rollback ticket"))
return
}
result, err := manager.RollbackWithTicket(req.Context(), input.Ticket, configActor(ctx, req))
if err != nil {
writeConfigError(w, err)
return
}
response.NewSuccessResponse(map[string]any{"generation": result.Generation, "appliedAt": result.AppliedAt}).FPrint(w)
}
func ConfigAuditQuery(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, ok := configManagerOrError(w)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityAuditRead, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
query, err := parseAuditQuery(req)
if err != nil {
writeConfigError(w, err)
return
}
items, err := manager.AuditQuery(req.Context(), query)
if err != nil {
writeConfigError(w, err)
return
}
response.NewSuccessResponse(items).FPrint(w)
}
func ExportConfigAudit(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, ok := configManagerOrError(w)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityAuditExport, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
var input struct {
Format string `json:"format"`
Limit int `json:"limit"`
}
if err := decodeJSONRequest(w, req, &input); err != nil {
writeConfigError(w, err)
return
}
query, err := parseAuditQuery(req)
if err != nil {
writeConfigError(w, err)
return
}
query.Limit = input.Limit
if err := config.ValidateConfigAuditQuery(query); err != nil {
writeConfigError(w, err)
return
}
format := strings.ToLower(strings.TrimSpace(input.Format))
if format == "" {
format = "json"
}
if format != "json" && format != "csv" {
writeConfigError(w, config.ErrInvalidAuditFormat)
return
}
var data []byte
if format == "csv" {
data, err = manager.ExportAuditCSV(req.Context(), query)
} else {
data, err = manager.ExportAuditJSON(req.Context(), query)
}
if err != nil {
writeConfigError(w, err)
return
}
contentType := "application/json; charset=utf-8"
if format == "csv" {
contentType = "text/csv; charset=utf-8"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
}
func configRequestManager(w http.ResponseWriter, req *http.Request) (*config.ConfigManager, config.Section, bool) {
manager, ok := configManagerOrError(w)
if !ok {
return nil, "", false
}
section, valid := configSectionFromPath(req.URL.Path)
if !valid {
writeConfigError(w, errors.New("invalid configuration section"))
return nil, "", false
}
return manager, section, true
}
func configManagerOrError(w http.ResponseWriter) (*config.ConfigManager, bool) {
manager := config.CurrentManager()
if manager == nil {
writeConfigError(w, config.ErrManagerStorage)
return nil, false
}
return manager, true
}
func configSectionFromPath(path string) (config.Section, bool) {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 4 || parts[0] != "api" || parts[1] != "v1" || parts[2] != "config" {
return "", false
}
switch parts[3] {
case "base":
return config.SectionRoot, true
case "smtp":
return config.SectionSMTP, true
case "email":
return config.SectionEmail, true
case "ssl":
return config.SectionSSL, true
case "plugins":
return config.SectionPlugins, true
default:
return "", false
}
}
func configActor(ctx *appcontext.Context, req *http.Request) config.AuditActor {
actor := config.AuditActor{UserID: strconv.Itoa(ctx.UserID), Role: string(ctx.EffectiveRole()), RecentAuth: ctx.RecentAuth, RecoveryCodeConfigured: ctx.RecoveryCodeConfigured}
if sessionManager := session.Instance; sessionManager != nil {
if cookie, err := req.Cookie(sessionManager.Cookie.Name); err == nil {
actor.SessionID = cookie.Value
}
}
return actor
}
func parseAuditQuery(req *http.Request) (config.ConfigAuditQuery, error) {
query := req.URL.Query()
for key, values := range query {
if len(values) != 1 {
return config.ConfigAuditQuery{}, config.ErrInvalidAuditQuery
}
switch key {
case "actorId", "operation", "section", "limit", "from", "to":
default:
return config.ConfigAuditQuery{}, config.ErrInvalidAuditQuery
}
}
result := config.ConfigAuditQuery{
ActorID: strings.TrimSpace(query.Get("actorId")),
Operation: config.ConfigOperation(strings.TrimSpace(query.Get("operation"))),
Section: config.Section(strings.TrimSpace(query.Get("section"))),
}
if rawLimit := strings.TrimSpace(query.Get("limit")); rawLimit != "" {
limit, err := strconv.Atoi(rawLimit)
if err != nil {
return config.ConfigAuditQuery{}, config.ErrInvalidAuditQuery
}
result.Limit = limit
}
if rawFrom := strings.TrimSpace(query.Get("from")); rawFrom != "" {
value, err := time.Parse(time.RFC3339, rawFrom)
if err != nil {
return config.ConfigAuditQuery{}, config.ErrInvalidAuditQuery
}
result.From = value
}
if rawTo := strings.TrimSpace(query.Get("to")); rawTo != "" {
value, err := time.Parse(time.RFC3339, rawTo)
if err != nil {
return config.ConfigAuditQuery{}, config.ErrInvalidAuditQuery
}
result.To = value
}
if err := config.ValidateConfigAuditQuery(result); err != nil {
return config.ConfigAuditQuery{}, err
}
return result, nil
}
func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (any, error) {
publicSecret := func(value config.StoredSecret) config.SecretStatus { return value.Status() }
switch section {
case config.SectionRoot:
return map[string]any{
"configVersion": runtime.Root.ConfigVersion,
"schemaVersion": runtime.Root.SchemaVersion,
"generation": runtime.Root.Generation,
"setupState": runtime.Root.SetupState,
"logLevel": runtime.Root.LogLevel,
"domains": runtime.Root.Domains,
"http": runtime.Root.HTTP,
"database": map[string]any{
"type": runtime.Root.Database.Type,
"host": runtime.Root.Database.Host,
"port": runtime.Root.Database.Port,
"username": runtime.Root.Database.Username,
"name": runtime.Root.Database.Name,
"localRef": runtime.Root.Database.LocalRef,
"password": nil,
},
"databasePasswordStatus": publicSecret(runtime.Root.Database.Password),
"security": runtime.Root.Security,
}, nil
case config.SectionSMTP:
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
return map[string]any{
"meta": runtime.SMTP.Meta,
"listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS},
"relay": map[string]any{
"relayHost": runtime.SMTP.Relay.RelayHost,
"relayPort": runtime.SMTP.Relay.RelayPort,
"username": runtime.SMTP.Relay.Username,
"security": runtime.SMTP.Relay.Security,
"from": runtime.SMTP.Relay.From,
"fromDomain": runtime.SMTP.Relay.FromDomain,
"verificationEnabled": runtime.SMTP.Relay.VerificationEnabled,
"loginPolicy": runtime.SMTP.Relay.LoginPolicy,
"password": nil,
},
"relayPasswordStatus": publicSecret(runtime.SMTP.Relay.Password),
}, nil
case config.SectionEmail:
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
value := runtime.Email
value.IMAP = config.IMAPListenerConfig{TLSPort: ports.IMAP}
value.POP3 = config.POP3ListenerConfig{Port: ports.POP3, TLSPort: ports.POP3TLS}
return value, nil
case config.SectionSSL:
return runtime.SSL, nil
case config.SectionPlugins:
plugins := make(map[string]any, len(runtime.Plugins.Plugins))
for id, item := range runtime.Plugins.Plugins {
settings := any(nil)
if len(item.Settings) > 0 {
var value any
if err := config.ParseStrict(item.Settings, &value); err == nil {
settings = value
}
}
plugins[id] = map[string]any{
"enabled": item.Enabled,
"executable": item.Executable,
"permissions": item.Permissions,
"settings": settings,
"secretStatus": publicSecret(item.Secret),
}
}
return map[string]any{"meta": runtime.Plugins.Meta, "plugins": plugins}, nil
default:
return nil, errors.New("invalid configuration section")
}
}