Smail/backend/config/config.go
smail 7e792b1608
Some checks are pending
Security Checks / security (push) Waiting to run
修复缺失日志标识格式
避免没有请求日志标识时输出 %!s(<nil>),并补充日志格式化回归测试。
2026-09-30 15:05:44 +08:00

581 lines
17 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 config
import (
"bytes"
"crypto/ecdsa"
"encoding/json"
"fmt"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/credential"
"git.pcux.cn/xuans/Smail/utils/errors"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
log "github.com/sirupsen/logrus"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
var IsInit bool
var legacyInitState atomic.Bool
var DevelopmentMode bool
// 旧配置读取可能迁移凭据,因此读取、整份保存及 SSL 合并必须共用同一把锁。
var legacyConfigMu sync.Mutex
type Config struct {
LogLevel string `json:"logLevel"` // 日志级别
Domain string `json:"domain"`
Domains []string `json:"domains"` //多域名设置,把所有收信域名都填进去
WebDomain string `json:"webDomain"`
AdminRecoveryDomain string `json:"adminRecoveryDomain,omitempty"`
AccessHosts []string `json:"accessHosts,omitempty"`
DkimPrivateKeyPath string `json:"dkimPrivateKeyPath"`
SSLType string `json:"sslType"` // 0表示自动生成证书,HTTP挑战模式,1表示用户上传证书,2表示自动-DNS挑战模式
SSLPrivateKeyPath string `json:"SSLPrivateKeyPath"`
SSLPublicKeyPath string `json:"SSLPublicKeyPath"`
SSL SSLConfig `json:"ssl,omitempty"`
DbDSN string `json:"dbDSN"`
DbType string `json:"dbType"`
HttpsEnabled int `json:"httpsEnabled"` //后台页面是否启用https,0默认(启用),1启用,2不启用
SpamFilterLevel int `json:"spamFilterLevel"` //垃圾邮件过滤级别,0不过滤、1 spf dkim 校验均失败时过滤,2 spf校验不通过时过滤 3,dkim 校验不过的时候过滤
HttpPort int `json:"httpPort"` //http服务端口设置,默认80
HttpsPort int `json:"httpsPort"` //https服务端口,默认443
TLSMode string `json:"tlsMode,omitempty"`
PublicHTTPSPort int `json:"publicHttpsPort,omitempty"`
TrustedProxyCIDRs []string `json:"trustedProxyCidrs,omitempty"`
WeChatPushAppId string `json:"weChatPushAppId"`
WeChatPushSecret string `json:"weChatPushSecret"`
WeChatPushTemplateId string `json:"weChatPushTemplateId"`
WeChatPushUserId string `json:"weChatPushUserId"`
TgBotToken string `json:"tgBotToken"`
TgChatId string `json:"tgChatId"`
IsInit bool `json:"isInit"`
SetupState SetupState `json:"setupState,omitempty"`
WebPushUrl string `json:"webPushUrl"`
WebPushToken string `json:"webPushToken"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPSecurity string `json:"smtpSecurity"`
SMTPFrom string `json:"smtpFrom"`
SMTPFromDomain string `json:"smtpFromDomain"`
VerificationEnabled bool `json:"verificationEnabled"`
ExternalRecoveryEnabled bool `json:"externalRecoveryEnabled"`
LoginPolicy string `json:"loginPolicy"`
PluginAllowList []string `json:"pluginAllowList"`
Tables map[string]string `json:"-"`
TablesInitData map[string]string `json:"-"`
setupPort int // 初始化阶段端口
}
var ROOT_PATH = ""
func init() {
envs := os.Environ()
for _, env := range envs {
if strings.HasPrefix(env, "SMAIL_ROOT=") {
ROOT_PATH = strings.TrimSpace(strings.ReplaceAll(env, "SMAIL_ROOT=", ""))
if !strings.HasSuffix(ROOT_PATH, "/") {
ROOT_PATH += "/"
}
fmt.Println("Env Root Path:", ROOT_PATH)
return
}
}
ex, err := os.Executable()
if err != nil {
ex, err = filepath.Abs(".")
if err != nil {
ROOT_PATH = "." + string(os.PathSeparator)
return
}
}
exPath := filepath.Dir(ex)
realPath, err := filepath.EvalSymlinks(exPath)
if err != nil {
realPath = exPath
}
// 如果是Goland运行,不修改根路径
if strings.Contains(realPath, "GoLand") && strings.Contains(realPath, "JetBrains") {
return
}
if !strings.HasSuffix(realPath, "/") {
realPath += "/"
}
ROOT_PATH = realPath
fmt.Println("Root Path:", ROOT_PATH)
}
func (c *Config) GetSetupPort() int {
if port := RuntimeSetupPort(); port > 0 {
return port
}
return c.setupPort
}
func (c *Config) SetSetupPort(setupPort int) {
SetRuntimeSetupPort(setupPort)
c.setupPort = setupPort
}
const DBTypeMySQL = "mysql"
const DBTypeSQLite = "sqlite"
const DBTypePostgres = "postgres"
const (
SSLChallengeHTTP = "http"
SSLChallengeDNS = "dns"
)
const SSLTypeAutoHTTP = "0" //自动生成证书
const SSLTypeAutoDNS = "2" //自动生成证书,DNS api验证
const SSLTypeUser = "1" //用户上传证书
var DBTypes []string = []string{DBTypeMySQL, DBTypeSQLite, DBTypePostgres}
var Instance *Config = &Config{}
type logFormatter struct {
}
// Format 定义日志输出格式
func (l *logFormatter) Format(entry *log.Entry) ([]byte, error) {
b := bytes.Buffer{}
b.WriteString(fmt.Sprintf("[%s]", entry.Level.String()))
b.WriteString(fmt.Sprintf("[%s]", entry.Time.Format("2006-01-02 15:04:05")))
if entry.Context != nil {
ctx, ok := entry.Context.(*context.Context)
if ok && ctx != nil {
if logID := ctx.GetValue(context.LogID); logID != nil {
b.WriteString(fmt.Sprintf("[%v]", logID))
}
}
}
if entry.Caller != nil {
b.WriteString(fmt.Sprintf("[%s:%d]", runtimepath.CleanCallerPath(entry.Caller.File), entry.Caller.Line))
}
b.WriteString(entry.Message)
// 错误字段也是有效内容,不能在消息为空时被格式器丢弃。
{
fields := runtimeEventFields(entry)
for key, value := range fields {
if cause, ok := value.(error); ok {
fields[key] = cause.Error()
} else if _, err := json.Marshal(value); err != nil {
fields[key] = fmt.Sprintf("无法序列化的字段(%T)", value)
} else {
fields[key] = value
}
}
payload, err := json.Marshal(fields)
if err != nil {
return nil, err
}
// 固定边界使页面不必把消息中恰好出现的 JSON 当作可信事件元数据。
b.WriteString(" @event ")
b.Write(payload)
}
b.WriteString("\n")
return b.Bytes(), nil
}
func Init() error {
legacyConfigMu.Lock()
defer legacyConfigMu.Unlock()
var cfgData []byte
var err error
args := os.Args
DevelopmentMode = len(args) >= 2 && args[len(args)-1] == "dev"
configName := "config.json"
if DevelopmentMode {
configName = "config.dev.json"
cfgData, err = readConfigFile(configName)
if err != nil {
return nil
}
} else {
cfgData, err = readConfigFile(configName)
if err != nil {
log.Errorf("config file not found,%s", err.Error())
return nil
}
}
loaded := &Config{}
partitioned, err := isPartitionedConfig(cfgData)
if err != nil {
return err
}
if partitioned {
if DevelopmentMode {
return fmt.Errorf("分区配置请通过 config.json 启动,不支持 config.dev.json")
}
loaded, err = loadPartitionedCompatibility()
if err != nil {
return err
}
} else {
if err = json.Unmarshal(cfgData, loaded); err != nil {
return errors.Wrap(err)
}
applyLegacySecurityDefaults(cfgData, loaded)
migrated, err := unprotectCredentials(loaded)
if err != nil {
return errors.Wrap(err)
}
if migrated {
if err = writeProtectedConfigFile(configName, loaded); err != nil {
return errors.Wrap(err)
}
}
}
Instance = loaded
Instance.fixPath()
if !Instance.SetupState.Valid() {
if Instance.IsInit {
Instance.SetupState = SetupStateReady
} else {
Instance.SetupState = SetupStateNew
}
}
if len(Instance.Domains) == 0 && Instance.Domain != "" {
Instance.Domains = []string{Instance.Domain}
}
SetInitState(Instance.IsSetupReady())
PublishConfigSnapshot(Instance)
// 设置日志格式为json格式
log.SetFormatter(&logFormatter{})
log.SetReportCaller(true)
// 设置将日志输出到标准输出(默认的输出为stderr,标准错误)
// 日志消息输出可以是任意的io.writer类型
log.SetOutput(os.Stdout)
var cstZone = time.FixedZone("CST", 8*3600)
time.Local = cstZone
if Instance != nil {
switch Instance.LogLevel {
case "":
log.SetLevel(log.InfoLevel)
case "debug":
log.SetLevel(log.DebugLevel)
case "info":
log.SetLevel(log.InfoLevel)
case "warn":
log.SetLevel(log.WarnLevel)
case "error":
log.SetLevel(log.ErrorLevel)
default:
log.SetLevel(log.InfoLevel)
}
} else {
log.SetLevel(log.InfoLevel)
}
return nil
}
// IsSetupReady 是启动流程使用的唯一初始化状态判断。
// IsInit 仅保留用于兼容旧配置和旧测试,不再作为新业务读路径。
func (c *Config) IsSetupReady() bool {
return c != nil && c.SetupState == SetupStateReady
}
func SetInitState(value bool) {
legacyInitState.Store(value)
IsInit = value
}
func legacyInitReady() bool {
return legacyInitState.Load()
}
func ReadPrivateKey() (*ecdsa.PrivateKey, bool, error) {
privateKey, created, err := loadOrCreateACMEPrivateKey(runtimepath.SSLDirectory(ROOT_PATH), "account_private.pem")
if err != nil {
return nil, false, err
}
return privateKey, created, nil
}
func WriteConfig(cfg *Config) error {
legacyConfigMu.Lock()
defer legacyConfigMu.Unlock()
return writeConfig(cfg)
}
func writeConfig(cfg *Config) error {
if err := writeProtectedConfigFile("config.json", cfg); err != nil {
return err
}
PublishConfigSnapshot(cfg)
return nil
}
func writeProtectedConfigFile(name string, cfg *Config) error {
if data, err := readConfigFile(name); err == nil {
partitioned, parseErr := isPartitionedConfig(data)
if parseErr != nil {
return parseErr
}
if partitioned {
return fmt.Errorf("分区配置不能通过旧版写入接口覆盖,请使用配置管理")
}
} else if !os.IsNotExist(err) {
return err
}
stored, err := protectCredentials(cfg)
if err != nil {
return errors.Wrap(err)
}
stored.SSLPrivateKeyPath = persistControlledSSLPath(stored.SSLPrivateKeyPath)
stored.SSLPublicKeyPath = persistControlledSSLPath(stored.SSLPublicKeyPath)
data, err := json.MarshalIndent(stored, "", " ")
if err != nil {
return errors.Wrap(err)
}
if err = writeConfigFile(name, data); err != nil {
return errors.Wrap(err)
}
return nil
}
func ReadConfig() (*Config, error) {
legacyConfigMu.Lock()
defer legacyConfigMu.Unlock()
return readConfig()
}
func readConfig() (*Config, error) {
configData := Config{
DkimPrivateKeyPath: filepath.Join(runtimepath.DKIMDirectory(ROOT_PATH), "dkim.priv"),
SSLPrivateKeyPath: filepath.Join(runtimepath.SSLDirectory(ROOT_PATH), "private.key"),
SSLPublicKeyPath: filepath.Join(runtimepath.SSLDirectory(ROOT_PATH), "public.crt"),
VerificationEnabled: true,
ExternalRecoveryEnabled: true,
LoginPolicy: "new_device",
}
cfgData, err := readConfigFile("config.json")
if os.IsNotExist(err) {
data, marshalErr := json.MarshalIndent(configData, "", " ")
if marshalErr != nil {
return nil, errors.Wrap(marshalErr)
}
if writeErr := writeConfigFile("config.json", data); writeErr != nil {
log.Errorf("Write Config Error:%s", writeErr.Error())
return nil, errors.Wrap(writeErr)
}
return &configData, nil
}
if err != nil {
log.Errorf("Read Config Error:%s", err.Error())
return nil, errors.Wrap(err)
}
partitioned, err := isPartitionedConfig(cfgData)
if err != nil {
return nil, err
}
if partitioned {
return loadPartitionedCompatibility()
}
if err = json.Unmarshal(cfgData, &configData); err != nil {
log.Error("Read Config Unmarshal Error")
return nil, errors.Wrap(err)
}
applyLegacySecurityDefaults(cfgData, &configData)
migrated, err := unprotectCredentials(&configData)
if err != nil {
log.Error("Read Config Credential Error")
return nil, errors.Wrap(err)
}
if migrated {
if err = writeProtectedConfigFile("config.json", &configData); err != nil {
log.Error("Migrate Config Credential Error")
return nil, errors.Wrap(err)
}
}
configData.fixPath()
return &configData, nil
}
func applyLegacySecurityDefaults(data []byte, cfg *Config) {
if cfg == nil {
return
}
var legacy struct {
VerificationEnabled *bool `json:"verificationEnabled"`
ExternalRecoveryEnabled *bool `json:"externalRecoveryEnabled"`
LoginPolicy *string `json:"loginPolicy"`
VerificationLoginPolicy string `json:"verificationLoginPolicy"`
}
if json.Unmarshal(data, &legacy) != nil {
return
}
if legacy.VerificationEnabled == nil {
cfg.VerificationEnabled = true
}
if legacy.ExternalRecoveryEnabled == nil {
cfg.ExternalRecoveryEnabled = true
}
if legacy.LoginPolicy != nil && strings.TrimSpace(*legacy.LoginPolicy) != "" {
cfg.LoginPolicy = strings.TrimSpace(*legacy.LoginPolicy)
return
}
if policy := strings.TrimSpace(legacy.VerificationLoginPolicy); policy != "" {
cfg.LoginPolicy = policy
return
}
cfg.LoginPolicy = "new_device"
}
func (c *Config) fixPath() {
if c.DbType == DBTypeSQLite {
c.DbDSN = resolvePath(c.DbDSN)
}
c.SSLPublicKeyPath = resolvePath(c.SSLPublicKeyPath)
c.SSLPrivateKeyPath = resolvePath(c.SSLPrivateKeyPath)
c.DkimPrivateKeyPath = resolvePath(c.DkimPrivateKeyPath)
}
func persistControlledSSLPath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
root, err := filepath.Abs(filepath.Clean(ROOT_PATH))
if err != nil {
return ""
}
target := value
if !filepath.IsAbs(target) {
target = filepath.Join(root, target)
}
target, err = filepath.Abs(filepath.Clean(target))
if err != nil || !pathWithinRoot(root, target) {
return ""
}
relative, err := filepath.Rel(root, target)
if err != nil || filepath.IsAbs(relative) || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || relative == ".." {
return ""
}
return filepath.ToSlash(relative)
}
func resolvePath(value string) string {
value = runtimepath.SSLReference(ROOT_PATH, strings.TrimSpace(value))
if value == "" {
return ""
}
root, err := filepath.Abs(filepath.Clean(ROOT_PATH))
if err != nil {
return ""
}
if isWindowsAbsolute(value) && filepath.VolumeName(value) == "" {
return ""
}
target := value
if !filepath.IsAbs(target) {
target = filepath.Join(root, target)
}
target, err = filepath.Abs(filepath.Clean(target))
if err != nil || !pathWithinRoot(root, target) {
return ""
}
realRoot, rootErr := filepath.EvalSymlinks(root)
realTarget, targetErr := evalWithExistingAncestor(target)
if rootErr == nil && targetErr == nil && !pathWithinRoot(realRoot, realTarget) {
return ""
}
return target
}
func pathWithinRoot(root string, target string) bool {
relative, err := filepath.Rel(root, target)
return err == nil && !filepath.IsAbs(relative) && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func isWindowsAbsolute(value string) bool {
if strings.HasPrefix(value, `\\`) || strings.HasPrefix(value, "//") {
return true
}
return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' && (value[2] == '\\' || value[2] == '/')
}
func evalWithExistingAncestor(target string) (string, error) {
current := target
parts := make([]string, 0)
for {
resolved, err := filepath.EvalSymlinks(current)
if err == nil {
for i := len(parts) - 1; i >= 0; i-- {
resolved = filepath.Join(resolved, parts[i])
}
return filepath.Abs(resolved)
}
if !os.IsNotExist(err) {
return "", err
}
parent := filepath.Dir(current)
if parent == current {
return "", err
}
parts = append(parts, filepath.Base(current))
current = parent
}
}
func protectCredentials(cfg *Config) (*Config, error) {
if cfg == nil {
return nil, fmt.Errorf("invalid configuration")
}
stored := *cfg
vault, err := credential.NewVault(ROOT_PATH)
if err != nil {
return nil, fmt.Errorf("credential protection failed")
}
values := []*string{&stored.WeChatPushSecret, &stored.TgBotToken, &stored.WebPushToken, &stored.SMTPPassword}
for _, value := range values {
encrypted, err := vault.Encrypt(*value)
if err != nil {
return nil, fmt.Errorf("credential protection failed")
}
*value = encrypted
}
return &stored, nil
}
func unprotectCredentials(cfg *Config) (bool, error) {
if cfg == nil {
return false, fmt.Errorf("invalid configuration")
}
vault, err := credential.NewVault(ROOT_PATH)
if err != nil {
return false, fmt.Errorf("credential decryption failed")
}
values := []*string{&cfg.WeChatPushSecret, &cfg.TgBotToken, &cfg.WebPushToken, &cfg.SMTPPassword}
for _, value := range values {
if *value == "" {
continue
}
if !credential.IsEncrypted(*value) {
return false, fmt.Errorf("credential decryption failed")
}
plaintext, err := vault.Decrypt(credential.CurrentVersion, *value)
if err != nil {
return false, fmt.Errorf("credential decryption failed")
}
*value = plaintext
}
return false, nil
}