Scert/backend/internal/config/config.go

300 lines
9.5 KiB
Go

package config
import (
"encoding/json"
"fmt"
"net"
"net/mail"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
)
const CurrentVersion = 1
const ConfigFormat = "json"
const LinuxInstallationMetadataPath = "/scert/conf/installation.json"
type installationMetadata struct {
CertificateRoot string `json:"certificate_root"`
}
type Config struct {
Version int `json:"version"`
SetupReady bool `json:"setup_ready"`
Admin AdminConfig `json:"admin"`
Server ServerConfig `json:"server"`
Web WebConfig `json:"web"`
Database DatabaseConfig `json:"database"`
Paths PathsConfig `json:"paths"`
ACME ACMEConfig `json:"acme"`
DNS DNSConfig `json:"dns"`
Notification NotificationConfig `json:"notification"`
}
type AdminConfig struct {
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
}
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
HTTPSMode string `json:"https_mode"`
TLSCert string `json:"tls_cert"`
TLSKey string `json:"tls_key"`
}
type WebConfig struct {
Enabled bool `json:"enabled"`
Hostname string `json:"hostname"`
PublicIP string `json:"public_ip"`
InitReady bool `json:"init_ready"`
}
type DatabaseConfig struct {
Driver string `json:"driver"`
DSN string `json:"dsn"`
MaxOpenConns int `json:"max_open_conns"`
MaxIdleConns int `json:"max_idle_conns"`
ConnMaxLifetimeMins int `json:"conn_max_lifetime_mins"`
}
type PathsConfig struct {
Install string `json:"install"`
Certificate string `json:"certificate"`
Config string `json:"config"`
Data string `json:"data"`
Log string `json:"log"`
Backup string `json:"backup"`
}
type ACMEConfig struct {
CA string `json:"ca"`
Environment string `json:"environment"`
AccountEmail string `json:"account_email"`
RenewalThresholdDays int `json:"renewal_threshold_days"`
}
type DNSConfig struct {
Provider string `json:"provider"`
CredentialRef string `json:"credential_ref"`
VerificationState string `json:"verification_state"`
}
type NotificationConfig struct {
HTTPEnabled bool `json:"http_enabled"`
SMTPEnabled bool `json:"smtp_enabled"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
Recipients []string `json:"recipients"`
TimeoutSecs int `json:"timeout_secs"`
RetryCount int `json:"retry_count"`
}
func Default(baseDir string) Config {
paths := DefaultPaths(baseDir)
return Config{
Version: CurrentVersion,
SetupReady: false,
Admin: AdminConfig{},
Server: ServerConfig{Host: "0.0.0.0", Port: 8080, HTTPSMode: "direct"},
Web: WebConfig{Enabled: true},
Database: DatabaseConfig{
Driver: "sqlite", DSN: filepath.Join(paths.Data, "scert.db"),
MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetimeMins: 30,
},
Paths: paths,
ACME: ACMEConfig{CA: "letsencrypt", Environment: "staging", RenewalThresholdDays: 6},
DNS: DNSConfig{VerificationState: "not_configured"},
Notification: NotificationConfig{TimeoutSecs: 10, RetryCount: 3},
}
}
func DefaultPaths(baseDir string) PathsConfig {
if runtime.GOOS == "linux" {
certificateRoot := "/ssl"
if strings.TrimSpace(baseDir) == "" {
if installedRoot, err := readInstalledCertificateRoot(LinuxInstallationMetadataPath); err == nil {
certificateRoot = installedRoot
}
}
return PathsConfig{
Install: "/scert", Certificate: certificateRoot, Config: "/scert/conf",
Data: "/scert/data", Log: "/scert/log", Backup: "/scert/backups",
}
}
if strings.TrimSpace(baseDir) == "" {
baseDir = "."
}
install := filepath.Join(baseDir, "Scert")
return PathsConfig{
Install: install, Certificate: filepath.Join(install, "ssl"),
Config: filepath.Join(install, "config"), Data: filepath.Join(install, "data"),
Log: filepath.Join(install, "logs"), Backup: filepath.Join(install, "backups"),
}
}
func readInstalledCertificateRoot(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
var metadata installationMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return "", fmt.Errorf("解析安装路径记录失败:%w", err)
}
root := filepath.Clean(strings.TrimSpace(metadata.CertificateRoot))
if !filepath.IsAbs(root) || root == string(filepath.Separator) {
return "", fmt.Errorf("安装路径记录中的证书目录无效")
}
return root, nil
}
func Load(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("读取配置文件失败:%w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("解析配置文件失败:%w", err)
}
cfg.Normalize()
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c *Config) Normalize() {
if c == nil {
return
}
if c.Server.Host == "" {
c.Server.Host = "0.0.0.0"
}
if c.Server.HTTPSMode == "" {
c.Server.HTTPSMode = "direct"
}
if c.Web.Hostname == "" && c.Web.PublicIP == "" && !c.SetupReady {
c.Web.Enabled = true
}
if c.DNS.VerificationState == "" {
c.DNS.VerificationState = "not_configured"
}
}
func (c Config) Save(path string) error {
if err := c.Validate(); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("创建配置目录失败:%w", err)
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("编码配置失败:%w", err)
}
data = append(data, '\n')
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
return fmt.Errorf("写入临时配置文件失败:%w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("替换配置文件失败:%w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("设置配置文件权限失败:%w", err)
}
return nil
}
func (c Config) Validate() error {
if c.Version != CurrentVersion {
return fmt.Errorf("配置版本不兼容:%d", c.Version)
}
if strings.TrimSpace(c.Server.Host) == "" {
return fmt.Errorf("服务监听地址不能为空")
}
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("服务监听端口无效:%d", c.Server.Port)
}
if c.Server.HTTPSMode != "direct" && c.Server.HTTPSMode != "proxy" {
return fmt.Errorf("HTTPS 模式无效:%s", c.Server.HTTPSMode)
}
if c.Database.Driver != "sqlite" && c.Database.Driver != "mysql" {
return fmt.Errorf("数据库驱动无效:%s", c.Database.Driver)
}
if strings.TrimSpace(c.Database.DSN) == "" {
return fmt.Errorf("数据库连接信息不能为空")
}
if c.Database.MaxOpenConns < 1 || c.Database.MaxIdleConns < 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns {
return fmt.Errorf("数据库连接池配置无效")
}
if c.Database.ConnMaxLifetimeMins < 0 {
return fmt.Errorf("数据库连接生命周期不能为负数")
}
for name, value := range map[string]string{
"安装目录": c.Paths.Install, "证书目录": c.Paths.Certificate, "配置目录": c.Paths.Config,
"数据目录": c.Paths.Data, "日志目录": c.Paths.Log, "备份目录": c.Paths.Backup,
} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s不能为空", name)
}
}
if c.ACME.CA != "letsencrypt" {
return fmt.Errorf("当前仅支持 Let's Encrypt")
}
if c.ACME.Environment != "staging" && c.ACME.Environment != "production" {
return fmt.Errorf("ACME 环境无效:%s", c.ACME.Environment)
}
if c.ACME.RenewalThresholdDays < 1 {
return fmt.Errorf("续期阈值必须大于 0")
}
if c.DNS.VerificationState != "not_configured" && c.DNS.VerificationState != "pending" && c.DNS.VerificationState != "verified" && c.DNS.VerificationState != "failed" {
return fmt.Errorf("DNS 验证状态无效:%s", c.DNS.VerificationState)
}
if c.SetupReady {
if strings.TrimSpace(c.Admin.Username) == "" || strings.TrimSpace(c.Admin.PasswordHash) == "" {
return fmt.Errorf("初始化完成时必须配置超级管理员账号")
}
adminEmail := strings.ToLower(strings.TrimSpace(c.Admin.Email))
parsedEmail, emailErr := mail.ParseAddress(adminEmail)
if adminEmail == "" || emailErr != nil || parsedEmail.Address != adminEmail || parsedEmail.Name != "" {
return fmt.Errorf("初始化完成时必须配置合法的超级管理员绑定邮箱")
}
if c.DNS.VerificationState != "verified" {
return fmt.Errorf("初始化完成时必须先通过 DNS API 验证")
}
if strings.TrimSpace(c.Web.Hostname) == "" && strings.TrimSpace(c.Web.PublicIP) == "" {
return fmt.Errorf("初始化完成时必须配置管理页面域名或公网 IP")
}
}
if c.Notification.TimeoutSecs < 1 || c.Notification.RetryCount < 0 {
return fmt.Errorf("通知配置无效")
}
return nil
}
// ResetURL 返回通过主 Web 服务访问密码重置入口的公开地址。
func (c Config) ResetURL() string {
host := strings.TrimSpace(c.Web.Hostname)
if host == "" {
host = strings.TrimSpace(c.Web.PublicIP)
}
if host == "" {
return ""
}
if parsed, err := url.Parse(host); err == nil && parsed.Hostname() != "" {
host = parsed.Hostname()
}
host = strings.Trim(host, "[]")
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
host = "[" + host + "]"
}
return (&url.URL{Scheme: "https", Host: host, Path: "/reset"}).String()
}