775 lines
23 KiB
Go
775 lines
23 KiB
Go
package cli
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"git.pcux.cn/xuans/Scert/backend/internal/app"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||
)
|
||
|
||
func newCertificateCommand() *cobra.Command {
|
||
command := &cobra.Command{Use: "certificate", Short: "查询和校验证书"}
|
||
command.AddCommand(newCertificateListCommand(), newCertificateShowCommand(), newCertificateValidateCommand())
|
||
return command
|
||
}
|
||
|
||
func newCertificateListCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "list",
|
||
Short: "查看证书列表",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
service, err := openApp(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer service.Close()
|
||
items, err := service.Repository.List(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
result := make([]map[string]any, 0, len(items))
|
||
for _, item := range items {
|
||
entry := map[string]any{"domain": item}
|
||
current, currentErr := service.Certificates.Current(cmd.Context(), item.ID)
|
||
if currentErr != nil {
|
||
entry["certificate_error"] = currentErr.Error()
|
||
} else {
|
||
entry["certificate"] = current
|
||
}
|
||
result = append(result, entry)
|
||
}
|
||
return printJSON(cmd, result)
|
||
},
|
||
}
|
||
}
|
||
|
||
func newCertificateShowCommand() *cobra.Command {
|
||
var id string
|
||
command := &cobra.Command{
|
||
Use: "show",
|
||
Short: "查看当前证书详情",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if strings.TrimSpace(id) == "" {
|
||
return errors.New("域名 ID 不能为空")
|
||
}
|
||
service, err := openApp(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer service.Close()
|
||
result, err := service.Certificates.Current(cmd.Context(), id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return printJSON(cmd, result)
|
||
},
|
||
}
|
||
command.Flags().StringVar(&id, "id", "", "域名 ID")
|
||
_ = command.MarkFlagRequired("id")
|
||
return command
|
||
}
|
||
|
||
func newCertificateValidateCommand() *cobra.Command {
|
||
var id string
|
||
command := &cobra.Command{
|
||
Use: "validate",
|
||
Short: "校验当前证书",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if strings.TrimSpace(id) == "" {
|
||
return errors.New("域名 ID 不能为空")
|
||
}
|
||
service, err := openApp(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer service.Close()
|
||
result, err := service.Certificates.ValidateCurrent(cmd.Context(), id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return printJSON(cmd, map[string]any{"valid": true, "certificate": result})
|
||
},
|
||
}
|
||
command.Flags().StringVar(&id, "id", "", "域名 ID")
|
||
_ = command.MarkFlagRequired("id")
|
||
return command
|
||
}
|
||
|
||
func newRenewCommand() *cobra.Command {
|
||
var id string
|
||
command := &cobra.Command{
|
||
Use: "renew",
|
||
Short: "执行自动续期检查或续期指定域名",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
service, err := openApp(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer service.Close()
|
||
if strings.TrimSpace(id) != "" {
|
||
result, err := service.Workflow.IssueWithReport(cmd.Context(), id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return printJSON(cmd, result)
|
||
}
|
||
if service.Renewal == nil {
|
||
return errors.New("自动续期服务未初始化")
|
||
}
|
||
result, err := service.Renewal.RunOnce(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return printJSON(cmd, result)
|
||
},
|
||
}
|
||
command.Flags().StringVar(&id, "id", "", "域名 ID;留空时检查所有需要续期的域名")
|
||
return command
|
||
}
|
||
|
||
func newDeployCommand() *cobra.Command {
|
||
var id string
|
||
command := &cobra.Command{
|
||
Use: "deploy",
|
||
Short: "部署指定域名的当前证书",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if strings.TrimSpace(id) == "" {
|
||
return errors.New("域名 ID 不能为空")
|
||
}
|
||
service, err := openApp(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer service.Close()
|
||
result, err := service.Workflow.DeployCurrent(cmd.Context(), id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return printJSON(cmd, result)
|
||
},
|
||
}
|
||
command.Flags().StringVar(&id, "id", "", "域名 ID")
|
||
_ = command.MarkFlagRequired("id")
|
||
return command
|
||
}
|
||
|
||
func newLogsCommand() *cobra.Command {
|
||
var lines int
|
||
command := &cobra.Command{
|
||
Use: "logs",
|
||
Short: "查看服务日志",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if lines < 1 || lines > 1000 {
|
||
return errors.New("日志行数必须在 1 到 1000 之间")
|
||
}
|
||
path := filepath.Join(cfg.Paths.Log, "scert.log")
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return fmt.Errorf("打开日志文件失败:%w", err)
|
||
}
|
||
defer file.Close()
|
||
values, err := tailLines(file, lines)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, value := range values {
|
||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), value); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
command.Flags().IntVar(&lines, "lines", 100, "显示最后多少行日志")
|
||
return command
|
||
}
|
||
|
||
func newConfigCommand() *cobra.Command {
|
||
command := &cobra.Command{Use: "config", Short: "查看和修改服务配置"}
|
||
command.AddCommand(newConfigShowCommand(), newConfigSetCommand(), newConfigSetSecretCommand(), newConfigValidateCommand())
|
||
return command
|
||
}
|
||
|
||
func newConfigShowCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "show",
|
||
Short: "查看脱敏后的配置",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return printJSON(cmd, safeConfigMap(cfg))
|
||
},
|
||
}
|
||
}
|
||
|
||
func newConfigSetSecretCommand() *cobra.Command {
|
||
var key, value string
|
||
command := &cobra.Command{
|
||
Use: "set-secret",
|
||
Short: "安全更新 SMTP 敏感凭据",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if key != "notification.smtp_password" {
|
||
return fmt.Errorf("不支持更新的敏感配置项:%s", key)
|
||
}
|
||
if value == "" {
|
||
var err error
|
||
value, err = ask(bufio.NewReader(cmd.InOrStdin()), cmd.OutOrStdout(), "请输入新的 SMTP 密码或授权码", "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if value == "" {
|
||
return errors.New("SMTP 密码不能为空")
|
||
}
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
username := strings.TrimSpace(cfg.Notification.SMTPUsername)
|
||
if username == "" {
|
||
return errors.New("填写 SMTP 密码前必须配置 SMTP 用户名")
|
||
}
|
||
store, err := secretstore.Open(cfg.Paths.Config)
|
||
if err != nil {
|
||
return fmt.Errorf("初始化 SMTP 凭据存储失败:%w", err)
|
||
}
|
||
ref, err := newSMTPCredentialReference()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := store.Put(cmd.Context(), ref, map[string]string{"username": username, "password": value}); err != nil {
|
||
return fmt.Errorf("保存 SMTP 凭据失败:%w", err)
|
||
}
|
||
cfg.Notification.SMTPCredentialRef = ref
|
||
if err := cfg.Save(resolveConfigPath()); err != nil {
|
||
cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cleanupCancel()
|
||
_ = store.Delete(cleanupContext, ref)
|
||
return err
|
||
}
|
||
_, err = fmt.Fprintln(cmd.OutOrStdout(), "SMTP 凭据已加密更新。")
|
||
return err
|
||
},
|
||
}
|
||
command.Flags().StringVar(&key, "key", "", "敏感配置项名称")
|
||
command.Flags().StringVar(&value, "value", "", "SMTP 密码;留空时交互输入")
|
||
_ = command.MarkFlagRequired("key")
|
||
return command
|
||
}
|
||
func newConfigValidateCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "validate",
|
||
Short: "校验服务配置",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := cfg.Validate(); err != nil {
|
||
return err
|
||
}
|
||
_, err = fmt.Fprintln(cmd.OutOrStdout(), "配置校验通过")
|
||
return err
|
||
},
|
||
}
|
||
}
|
||
|
||
func newConfigSetCommand() *cobra.Command {
|
||
var key, value string
|
||
command := &cobra.Command{
|
||
Use: "set",
|
||
Short: "修改允许调整的配置项",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := setConfigValue(&cfg, key, value); err != nil {
|
||
return err
|
||
}
|
||
if err := cfg.Save(resolveConfigPath()); err != nil {
|
||
return err
|
||
}
|
||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "配置已更新:%s\n", key)
|
||
return err
|
||
},
|
||
}
|
||
command.Flags().StringVar(&key, "key", "", "配置项名称")
|
||
command.Flags().StringVar(&value, "value", "", "配置项值")
|
||
_ = command.MarkFlagRequired("key")
|
||
_ = command.MarkFlagRequired("value")
|
||
return command
|
||
}
|
||
|
||
func newBackupCommand() *cobra.Command {
|
||
var output string
|
||
command := &cobra.Command{
|
||
Use: "backup",
|
||
Short: "备份配置和数据库",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if strings.TrimSpace(output) == "" {
|
||
output = filepath.Join(cfg.Paths.Backup, "scert-"+time.Now().UTC().Format("20060102-150405.000000000"))
|
||
}
|
||
output = filepath.Clean(output)
|
||
if err := ensureBackupDestination(output); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(output, 0o700); err != nil {
|
||
return fmt.Errorf("创建备份目录失败:%w", err)
|
||
}
|
||
if err := copyFileExclusive(resolveConfigPath(), filepath.Join(output, "scert.json"), 0o600); err != nil {
|
||
return err
|
||
}
|
||
if cfg.Database.Driver == "sqlite" && cfg.Database.DSN != ":memory:" {
|
||
if err := copyFileExclusive(cfg.Database.DSN, filepath.Join(output, "scert.db"), 0o600); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if cfg.Database.Driver == "mysql" {
|
||
if err := backupMySQL(cmd.Context(), cfg, filepath.Join(output, "scert.sql")); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "备份已创建:%s\n", output)
|
||
return err
|
||
},
|
||
}
|
||
command.Flags().StringVar(&output, "output", "", "备份目录")
|
||
return command
|
||
}
|
||
|
||
func newRestoreCommand() *cobra.Command {
|
||
var input string
|
||
command := &cobra.Command{
|
||
Use: "restore",
|
||
Short: "恢复配置和数据库备份",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
input, err := validateRestoreInput(input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
configFile := filepath.Join(input, "scert.json")
|
||
cfg, err := config.Load(configFile)
|
||
if err != nil {
|
||
return fmt.Errorf("读取备份配置失败:%w", err)
|
||
}
|
||
|
||
currentPath := filepath.Clean(resolveConfigPath())
|
||
currentConfig, currentConfigErr := config.Load(currentPath)
|
||
if currentConfigErr != nil && !errors.Is(currentConfigErr, os.ErrNotExist) {
|
||
return fmt.Errorf("读取当前配置失败:%w", currentConfigErr)
|
||
}
|
||
if currentConfigErr == nil && currentConfig.Database.Driver != cfg.Database.Driver {
|
||
return fmt.Errorf("备份数据库类型(%s)与当前配置数据库类型(%s)不一致,拒绝恢复", cfg.Database.Driver, currentConfig.Database.Driver)
|
||
}
|
||
if info, statErr := os.Stat(currentPath); statErr == nil && !info.Mode().IsRegular() {
|
||
return errors.New("当前配置路径不是普通文件")
|
||
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||
return fmt.Errorf("检查当前配置失败:%w", statErr)
|
||
}
|
||
|
||
mysqlSource := filepath.Join(input, "scert.sql")
|
||
if cfg.Database.Driver == "mysql" && !isRegularFile(mysqlSource) {
|
||
return errors.New("MySQL 备份目录缺少有效的 scert.sql 文件")
|
||
}
|
||
|
||
databaseSource := filepath.Join(input, "scert.db")
|
||
databaseSourceInfo, databaseSourceErr := os.Stat(databaseSource)
|
||
databaseSourceExists := databaseSourceErr == nil
|
||
if databaseSourceErr != nil && !errors.Is(databaseSourceErr, os.ErrNotExist) {
|
||
return fmt.Errorf("检查备份数据库失败:%w", databaseSourceErr)
|
||
}
|
||
if databaseSourceExists && !databaseSourceInfo.Mode().IsRegular() {
|
||
return errors.New("备份数据库不是普通文件")
|
||
}
|
||
if cfg.Database.Driver == "sqlite" && cfg.Database.DSN != ":memory:" && !databaseSourceExists {
|
||
return errors.New("SQLite 备份目录缺少有效的 scert.db 文件")
|
||
}
|
||
if cfg.Database.Driver == "sqlite" && cfg.Database.DSN != ":memory:" && databaseSourceExists {
|
||
if samePath(databaseSource, cfg.Database.DSN) {
|
||
return errors.New("备份数据库与当前数据库路径相同")
|
||
}
|
||
if err := ensureParentDirectory(cfg.Database.DSN); err != nil {
|
||
return fmt.Errorf("创建当前数据库父目录失败:%w", err)
|
||
}
|
||
if databaseTargetInfo, statErr := os.Stat(cfg.Database.DSN); statErr == nil && !databaseTargetInfo.Mode().IsRegular() {
|
||
return errors.New("当前数据库不是普通文件")
|
||
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||
return fmt.Errorf("检查当前数据库失败:%w", statErr)
|
||
}
|
||
}
|
||
|
||
configBackupPath := ""
|
||
if isRegularFile(currentPath) {
|
||
configBackupPath = currentPath + ".before-restore-" + time.Now().UTC().Format("20060102-150405.000000000")
|
||
if err := copyFileExclusive(currentPath, configBackupPath, 0o600); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
databaseBackupPath := ""
|
||
if cfg.Database.Driver == "sqlite" && cfg.Database.DSN != ":memory:" && databaseSourceExists && isRegularFile(cfg.Database.DSN) {
|
||
databaseBackupPath = cfg.Database.DSN + ".before-restore-" + time.Now().UTC().Format("20060102-150405.000000000")
|
||
if err := copyFileExclusive(cfg.Database.DSN, databaseBackupPath, 0o600); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
mysqlBackupPath := ""
|
||
if cfg.Database.Driver == "mysql" {
|
||
mysqlBackupPath, err = createMySQLRestoreBackup(cmd.Context(), cfg)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
if err := cfg.Save(currentPath); err != nil {
|
||
return err
|
||
}
|
||
if cfg.Database.Driver == "sqlite" && cfg.Database.DSN != ":memory:" && databaseSourceExists {
|
||
if err := copyFile(databaseSource, cfg.Database.DSN, 0o600); err != nil {
|
||
if configBackupPath != "" {
|
||
_ = copyFile(configBackupPath, currentPath, 0o600)
|
||
}
|
||
if databaseBackupPath != "" {
|
||
_ = copyFile(databaseBackupPath, cfg.Database.DSN, 0o600)
|
||
}
|
||
return fmt.Errorf("恢复 SQLite 数据库失败:%w", err)
|
||
}
|
||
}
|
||
if cfg.Database.Driver == "mysql" {
|
||
if err := restoreMySQL(cmd.Context(), cfg, mysqlSource); err != nil {
|
||
restoreFailure := fmt.Errorf("恢复 MySQL 数据库失败:%w", err)
|
||
if mysqlBackupPath != "" {
|
||
if rollbackErr := restoreMySQL(cmd.Context(), cfg, mysqlBackupPath); rollbackErr != nil {
|
||
restoreFailure = fmt.Errorf("%w;MySQL 数据库回滚失败:%v", restoreFailure, rollbackErr)
|
||
}
|
||
}
|
||
if configBackupPath != "" {
|
||
_ = copyFile(configBackupPath, currentPath, 0o600)
|
||
}
|
||
return restoreFailure
|
||
}
|
||
}
|
||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "恢复已完成:%s\n", input)
|
||
return err
|
||
},
|
||
}
|
||
command.Flags().StringVar(&input, "input", "", "备份目录")
|
||
_ = command.MarkFlagRequired("input")
|
||
return command
|
||
}
|
||
|
||
func openApp(ctx context.Context) (*app.App, error) {
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return app.NewWithConfigPath(ctx, cfg, resolveConfigPath())
|
||
}
|
||
|
||
func tailLines(reader io.Reader, count int) ([]string, error) {
|
||
values := make([]string, 0, count)
|
||
scanner := bufio.NewScanner(reader)
|
||
scanner.Buffer(make([]byte, 4096), 1<<20)
|
||
for scanner.Scan() {
|
||
if len(values) == count {
|
||
copy(values, values[1:])
|
||
values[len(values)-1] = scanner.Text()
|
||
continue
|
||
}
|
||
values = append(values, scanner.Text())
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
return nil, fmt.Errorf("读取日志失败:%w", err)
|
||
}
|
||
return values, nil
|
||
}
|
||
|
||
func safeConfigMap(cfg config.Config) map[string]any {
|
||
data, _ := json.Marshal(cfg)
|
||
result := make(map[string]any)
|
||
_ = json.Unmarshal(data, &result)
|
||
if admin, ok := result["admin"].(map[string]any); ok {
|
||
if _, exists := admin["password_hash"]; exists {
|
||
admin["password_hash"] = "已配置"
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func setConfigValue(cfg *config.Config, key, value string) error {
|
||
if cfg == nil {
|
||
return errors.New("配置不能为空")
|
||
}
|
||
key = strings.ToLower(strings.TrimSpace(key))
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return errors.New("配置值不能为空")
|
||
}
|
||
parseBool := func() (bool, error) { return strconv.ParseBool(value) }
|
||
parseInt := func() (int, error) { return strconv.Atoi(value) }
|
||
switch key {
|
||
case "server.host":
|
||
cfg.Server.Host = value
|
||
case "server.port":
|
||
parsed, err := parseInt()
|
||
if err != nil {
|
||
return fmt.Errorf("端口格式无效:%w", err)
|
||
}
|
||
cfg.Server.Port = parsed
|
||
case "server.https_mode":
|
||
cfg.Server.HTTPSMode = value
|
||
case "web.enabled":
|
||
parsed, err := parseBool()
|
||
if err != nil {
|
||
return fmt.Errorf("管理页面开关格式无效:%w", err)
|
||
}
|
||
cfg.Web.Enabled = parsed
|
||
case "web.hostname":
|
||
cfg.Web.Hostname = value
|
||
case "web.public_ip":
|
||
cfg.Web.PublicIP = value
|
||
case "paths.install":
|
||
cfg.Paths.Install = value
|
||
case "paths.certificate":
|
||
cfg.Paths.Certificate = value
|
||
case "paths.config":
|
||
cfg.Paths.Config = value
|
||
case "paths.data":
|
||
cfg.Paths.Data = value
|
||
case "paths.log":
|
||
cfg.Paths.Log = value
|
||
case "paths.backup":
|
||
cfg.Paths.Backup = value
|
||
case "acme.environment":
|
||
cfg.ACME.Environment = value
|
||
case "acme.account_email":
|
||
cfg.ACME.AccountEmail = value
|
||
case "acme.renewal_threshold_days":
|
||
parsed, err := parseInt()
|
||
if err != nil {
|
||
return fmt.Errorf("续期阈值格式无效:%w", err)
|
||
}
|
||
cfg.ACME.RenewalThresholdDays = parsed
|
||
case "acme.propagation_timeout_secs":
|
||
parsed, err := parseInt()
|
||
if err != nil {
|
||
return fmt.Errorf("DNS 传播等待时间格式无效:%w", err)
|
||
}
|
||
cfg.ACME.PropagationTimeoutSecs = parsed
|
||
case "acme.propagation_polling_interval_secs":
|
||
parsed, err := parseInt()
|
||
if err != nil {
|
||
return fmt.Errorf("DNS 传播检测间隔格式无效:%w", err)
|
||
}
|
||
cfg.ACME.PropagationPollingIntervalSecs = parsed
|
||
case "dns.provider":
|
||
cfg.DNS.Provider = value
|
||
case "dns.credential_ref":
|
||
cfg.DNS.CredentialRef = value
|
||
case "dns.verification_state":
|
||
cfg.DNS.VerificationState = value
|
||
case "notification.http_enabled":
|
||
parsed, err := parseBool()
|
||
if err != nil {
|
||
return fmt.Errorf("HTTP 通知开关格式无效:%w", err)
|
||
}
|
||
cfg.Notification.HTTPEnabled = parsed
|
||
case "notification.smtp_enabled":
|
||
parsed, err := parseBool()
|
||
if err != nil {
|
||
return fmt.Errorf("SMTP 通知开关格式无效:%w", err)
|
||
}
|
||
cfg.Notification.SMTPEnabled = parsed
|
||
|
||
case "notification.smtp_host":
|
||
cfg.Notification.SMTPHost = value
|
||
case "notification.smtp_port":
|
||
parsed, err := parseInt()
|
||
if err != nil {
|
||
return fmt.Errorf("SMTP 端口格式无效:%w", err)
|
||
}
|
||
cfg.Notification.SMTPPort = parsed
|
||
case "notification.smtp_username":
|
||
cfg.Notification.SMTPUsername = value
|
||
case "notification.smtp_mode":
|
||
cfg.Notification.SMTPMode = value
|
||
case "notification.smtp_allow_plaintext_auth":
|
||
parsed, err := parseBool()
|
||
if err != nil {
|
||
return fmt.Errorf("SMTP 明文认证开关格式无效:%w", err)
|
||
}
|
||
cfg.Notification.SMTPAllowPlaintextAuth = parsed
|
||
case "notification.smtp_password":
|
||
return errors.New("SMTP 密码属于敏感信息,请使用 scert config set-secret --key notification.smtp_password")
|
||
case "notification.provider":
|
||
cfg.Notification.Provider = value
|
||
case "notification.endpoint":
|
||
cfg.Notification.Endpoint = value
|
||
case "notification.http_path":
|
||
cfg.Notification.HTTPPath = value
|
||
case "notification.http_login_path":
|
||
cfg.Notification.HTTPLoginPath = value
|
||
case "notification.http_auth_mode":
|
||
cfg.Notification.HTTPAuthMode = value
|
||
case "notification.http_credential_ref":
|
||
cfg.Notification.HTTPCredentialRef = value
|
||
case "notification.event_mode":
|
||
cfg.Notification.EventMode = value
|
||
case "notification.sender_name":
|
||
cfg.Notification.SenderName = value
|
||
case "notification.sender_email":
|
||
cfg.Notification.SenderEmail = value
|
||
case "notification.recipients":
|
||
cfg.Notification.Recipients = splitCommaValues(value)
|
||
case "notification.timeout_secs":
|
||
parsed, err := parseInt()
|
||
if err != nil {
|
||
return fmt.Errorf("通知超时时间格式无效:%w", err)
|
||
}
|
||
cfg.Notification.TimeoutSecs = parsed
|
||
default:
|
||
return fmt.Errorf("不允许通过命令行修改配置项:%s", key)
|
||
}
|
||
return cfg.Validate()
|
||
}
|
||
|
||
func splitCommaValues(value string) []string {
|
||
parts := strings.Split(value, ",")
|
||
result := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if part != "" {
|
||
result = append(result, part)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func copyFile(source, target string, mode os.FileMode) error {
|
||
input, err := os.Open(source)
|
||
if err != nil {
|
||
return fmt.Errorf("打开文件失败:%w", err)
|
||
}
|
||
defer input.Close()
|
||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||
return fmt.Errorf("创建目标目录失败:%w", err)
|
||
}
|
||
output, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||
if err != nil {
|
||
return fmt.Errorf("创建目标文件失败:%w", err)
|
||
}
|
||
if _, err := io.Copy(output, input); err != nil {
|
||
_ = output.Close()
|
||
return fmt.Errorf("复制文件失败:%w", err)
|
||
}
|
||
if err := output.Close(); err != nil {
|
||
return fmt.Errorf("关闭目标文件失败:%w", err)
|
||
}
|
||
return os.Chmod(target, mode)
|
||
}
|
||
|
||
func copyFileExclusive(source, target string, mode os.FileMode) error {
|
||
input, err := os.Open(source)
|
||
if err != nil {
|
||
return fmt.Errorf("打开文件失败:%w", err)
|
||
}
|
||
defer input.Close()
|
||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||
return fmt.Errorf("创建目标目录失败:%w", err)
|
||
}
|
||
output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
||
if err != nil {
|
||
return fmt.Errorf("创建目标文件失败:%w", err)
|
||
}
|
||
if _, err := io.Copy(output, input); err != nil {
|
||
_ = output.Close()
|
||
return fmt.Errorf("复制文件失败:%w", err)
|
||
}
|
||
if err := output.Close(); err != nil {
|
||
return fmt.Errorf("关闭目标文件失败:%w", err)
|
||
}
|
||
return os.Chmod(target, mode)
|
||
}
|
||
|
||
func ensureBackupDestination(path string) error {
|
||
if strings.TrimSpace(path) == "" || path == "." {
|
||
return errors.New("备份目录不能为空")
|
||
}
|
||
if _, err := os.Stat(path); err == nil {
|
||
return fmt.Errorf("备份目录已存在,为避免覆盖原备份请更换路径:%s", path)
|
||
} else if !errors.Is(err, os.ErrNotExist) {
|
||
return fmt.Errorf("检查备份目录失败:%w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateRestoreInput(path string) (string, error) {
|
||
path = filepath.Clean(strings.TrimSpace(path))
|
||
if path == "." || path == "" {
|
||
return "", errors.New("备份目录不能为空")
|
||
}
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
return "", fmt.Errorf("检查备份目录失败:%w", err)
|
||
}
|
||
if !info.IsDir() {
|
||
return "", errors.New("备份路径必须是目录")
|
||
}
|
||
configFile := filepath.Join(path, "scert.json")
|
||
if !isRegularFile(configFile) {
|
||
return "", errors.New("备份目录缺少有效的 scert.json 文件")
|
||
}
|
||
return path, nil
|
||
}
|
||
|
||
func isRegularFile(path string) bool {
|
||
info, err := os.Lstat(path)
|
||
return err == nil && info.Mode().IsRegular()
|
||
}
|
||
|
||
func ensureParentDirectory(path string) error {
|
||
parent := filepath.Dir(path)
|
||
info, err := os.Stat(parent)
|
||
if err != nil {
|
||
return fmt.Errorf("目标目录不可用:%w", err)
|
||
}
|
||
if !info.IsDir() {
|
||
return errors.New("目标父路径不是目录")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func samePath(left, right string) bool {
|
||
leftAbs, leftErr := filepath.Abs(filepath.Clean(left))
|
||
rightAbs, rightErr := filepath.Abs(filepath.Clean(right))
|
||
if leftErr != nil || rightErr != nil {
|
||
return false
|
||
}
|
||
return strings.EqualFold(leftAbs, rightAbs)
|
||
}
|