Smail/server/controllers/config.go
smail dd04bdb49e
Some checks are pending
Security Checks / security (push) Waiting to run
完成安全整改、配置重构与邮件认证优化
2026-08-28 14:33:13 +08:00

443 lines
14 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) {
manager, ok := configManagerOrError(w)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
status := manager.Status()
response.NewSuccessResponse(map[string]any{
"setupState": status.SetupState,
"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}).FPrint(w)
}
type configApplyRequest struct {
Generation uint64 `json:"generation"`
}
// ApplyConfig 应用已保存的候选配置代次。
func ApplyConfig(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, _, 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.ApplyAuthorized(req.Context(), input.Generation, 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.CapabilityConfigRollback, "", 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 := parseAuditQuery(req)
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 := parseAuditQuery(req)
query.Limit = input.Limit
var data []byte
var err error
if strings.EqualFold(input.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 strings.EqualFold(input.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, errors.New("configuration manager unavailable"))
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 {
query := req.URL.Query()
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")))}
result.Limit, _ = strconv.Atoi(query.Get("limit"))
if value, err := time.Parse(time.RFC3339, query.Get("from")); err == nil {
result.From = value
}
if value, err := time.Parse(time.RFC3339, query.Get("to")); err == nil {
result.To = value
}
return result
}
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:
return map[string]any{
"meta": runtime.SMTP.Meta,
"relay": map[string]any{
"relayHost": runtime.SMTP.Relay.RelayHost,
"relayPort": runtime.SMTP.Relay.RelayPort,
"username": runtime.SMTP.Relay.Username,
"security": runtime.SMTP.Relay.Security,
"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:
return runtime.Email, 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 {
plugins[id] = map[string]any{
"enabled": item.Enabled,
"executable": item.Executable,
"permissions": item.Permissions,
"settings": nil,
"secretStatus": publicSecret(item.Secret),
}
}
return map[string]any{"meta": runtime.Plugins.Meta, "plugins": plugins}, nil
default:
return nil, errors.New("invalid configuration section")
}
}
func writeConfigAccessError(w http.ResponseWriter, err error) { writeConfigError(w, err) }
func writeConfigError(w http.ResponseWriter, err error) {
status := response.NoAccessPrivileges
message := "无权访问或请求不满足安全条件"
var data any = ""
var denial *access.Denial
if errors.As(err, &denial) {
data = map[string]string{"code": string(denial.Code)}
}
if errors.Is(err, config.ErrManagerStorage) {
status = response.ServerError
message = "配置服务不可用"
}
if errors.Is(err, config.ErrGenerationConflict) {
status = response.ParamsError
message = "配置已发生变化,请刷新后重试"
}
response.NewErrorResponse(status, message, data).FPrint(w)
}