package cli import ( "bufio" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" "github.com/spf13/cobra" "git.pcux.cn/xuans/Scert/backend/internal/app" "git.pcux.cn/xuans/Scert/backend/internal/buildinfo" "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/firewall" "git.pcux.cn/xuans/Scert/backend/internal/secretstore" "git.pcux.cn/xuans/Scert/backend/internal/setup" ) const version = buildinfo.Version var ( configPath string baseDir string ) const helpTemplate = `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}}{{end}} 用法: {{.UseLine}}{{if .HasAvailableSubCommands}} 可用命令: {{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} {{rpad .Name .NamePadding }} {{.Short}}{{"\n"}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} 参数: {{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} 全局参数: {{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} 其他帮助: {{.CommandPath}} help [命令]{{end}} ` func Execute() { if err := newRootCommand().Execute(); err != nil { fmt.Fprintln(os.Stderr, "错误:", err) os.Exit(1) } } func newRootCommand() *cobra.Command { root := &cobra.Command{Use: "scert", Short: "SSL 证书管理服务", SilenceUsage: true} root.CompletionOptions.DisableDefaultCmd = true root.SetHelpCommand(newHelpCommand()) root.SetHelpTemplate(helpTemplate) root.PersistentFlags().StringVar(&configPath, "config", "", "配置文件路径") root.PersistentFlags().StringVar(&baseDir, "base-dir", "", "Windows 默认安装根目录") root.AddCommand( &cobra.Command{Use: "version", Short: "显示版本信息", Run: func(cmd *cobra.Command, args []string) { cmd.Printf("Scert %s\n", version) }}, newInitCommand(), newStatusCommand(), newServeCommand(), newDomainCommand(), newCertificateCommand(), newRenewCommand(), newDeployCommand(), newLogsCommand(), newConfigCommand(), newBackupCommand(), newRestoreCommand(), newServerCommand(), newSSHKeyCommand(), newServiceCommand(), ) localizeHelpFlags(root) return root } func newHelpCommand() *cobra.Command { return &cobra.Command{ Use: "help [命令]", Short: "显示帮助信息", RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return cmd.Root().Help() } target, remaining, err := cmd.Root().Find(args) if err != nil { return err } if len(remaining) > 0 { return fmt.Errorf("未知命令:%s", strings.Join(remaining, " ")) } return target.Help() }, } } func localizeHelpFlags(command *cobra.Command) { command.DisableFlagsInUseLine = true command.InitDefaultHelpFlag() if flag := command.Flags().Lookup("help"); flag != nil { flag.Usage = "显示帮助信息" } for _, child := range command.Commands() { localizeHelpFlags(child) } } func newInitCommand() *cobra.Command { return newInitCommandWithFirewall(firewall.NewManager()) } func newInitCommandWithFirewall(firewallManager firewall.Manager) *cobra.Command { var ( adminUsername, adminEmail, adminPassword, emailProvider, emailEndpoint string emailHTTPPath, emailHTTPLoginPath, emailHTTPAuthMode, emailHTTPCredentialRef, emailHTTPHeaders string emailSMTPHost, emailSMTPUsername, emailSMTPPassword, emailSMTPMode string emailEventMode, emailSenderName, emailSenderEmail, emailRetryIntervals string recipients, dnsProvider, dnsCredentialRef, certificateRoot string webHostname, webPublicIP, httpsMode string serverPort, emailTimeoutSecs, emailRetryCount, emailSMTPPort int emailHTTPEnabled, emailSMTPEnabled, emailSMTPAllowPlaintextAuth, emailIncludeDeploymentDetails bool emailIncludeCertificateSerial, emailIncludeManagementURL bool dnsVerified, webEnabled bool ) command := &cobra.Command{Use: "init", Short: "交互式初始化配置", RunE: func(cmd *cobra.Command, args []string) error { path := resolveConfigPath() if _, err := os.Stat(path); err == nil { return fmt.Errorf("配置文件已存在:%s", path) } else if !errors.Is(err, os.ErrNotExist) { return err } cfg := config.Default(baseDir) emailHeaders, err := parseStringMap(emailHTTPHeaders) if err != nil { return fmt.Errorf("HTTP 自定义请求头格式无效:%w", err) } emailIntervals, err := splitInts(emailRetryIntervals) if err != nil { return fmt.Errorf("通知重试间隔格式无效:%w", err) } input := setup.Input{ AdminUsername: adminUsername, AdminEmail: adminEmail, AdminPassword: adminPassword, ServerPort: serverPort, EmailHTTPEnabled: emailHTTPEnabled, EmailSMTPEnabled: emailSMTPEnabled, EmailSMTPHost: emailSMTPHost, EmailSMTPPort: emailSMTPPort, EmailSMTPUsername: emailSMTPUsername, EmailSMTPPassword: emailSMTPPassword, EmailSMTPMode: emailSMTPMode, EmailSMTPAllowPlaintextAuth: emailSMTPAllowPlaintextAuth, EmailProvider: emailProvider, EmailEndpoint: emailEndpoint, EmailHTTPPath: emailHTTPPath, EmailHTTPLoginPath: emailHTTPLoginPath, EmailHTTPAuthMode: emailHTTPAuthMode, EmailHTTPCredentialRef: emailHTTPCredentialRef, EmailHTTPHeaders: emailHeaders, EmailEventMode: emailEventMode, EmailIncludeDeploymentDetails: emailIncludeDeploymentDetails, EmailIncludeCertificateSerial: emailIncludeCertificateSerial, EmailIncludeManagementURL: emailIncludeManagementURL, EmailSenderName: emailSenderName, EmailSenderEmail: emailSenderEmail, EmailTimeoutSecs: emailTimeoutSecs, EmailRetryCount: emailRetryCount, EmailRetryIntervalsSecs: emailIntervals, EmailRecipients: splitValues(recipients), DNSProvider: dnsProvider, DNSCredentialRef: dnsCredentialRef, DNSVerified: dnsVerified, CertificateRoot: certificateRoot, WebHostname: webHostname, WebPublicIP: webPublicIP, WebEnabled: webEnabled, HTTPSMode: httpsMode, } if !cmd.Flags().Changed("admin-username") { if err := readInteractiveInit(cmd.InOrStdin(), cmd.OutOrStdout(), &input, cfg); err != nil { return err } } cleanupDNS, err := prepareDNSCredentials(cmd.Context(), cfg, &input, cmd.OutOrStdout()) if err != nil { return err } cleanupSMTP, err := prepareSMTPCredentials(cmd.Context(), cfg, &input) if err != nil { cleanupDNS() return err } cleanupSecret := func() { cleanupSMTP() cleanupDNS() } if err := setup.Apply(&cfg, input); err != nil { cleanupSecret() return err } firewallResult, err := saveSetupWithFirewall(cmd.Context(), cfg, path, firewallManager) if err != nil { cleanupSecret() return err } fmt.Fprintf(cmd.OutOrStdout(), "初始化配置已保存:%s\n", path) fmt.Fprintf(cmd.OutOrStdout(), "DNS API 已完成在线验证,凭据已加密保存;真实 Web 证书申请将在后续任务接入。\n") fmt.Fprintf(cmd.OutOrStdout(), "本机防火墙:%s\n", firewallResult.Message) fmt.Fprintf(cmd.OutOrStdout(), "请确认云安全组已放行主服务端口 %d。Scert-admin 默认关闭;需要重置密码时启动后访问:%s\n", cfg.Server.Port, cfg.ResetURL()) return nil }} command.Flags().StringVar(&adminUsername, "admin-username", "", "超级管理员用户名") command.Flags().StringVar(&adminEmail, "admin-email", "", "超级管理员绑定邮箱") command.Flags().StringVar(&adminPassword, "admin-password", "", "超级管理员密码(仅建议临时使用参数,优先交互输入)") command.Flags().IntVar(&serverPort, "server-port", 8080, "Scert 主服务端口") command.Flags().BoolVar(&emailHTTPEnabled, "email-http-enabled", false, "是否启用 HTTP 邮件通知") command.Flags().BoolVar(&emailSMTPEnabled, "email-smtp-enabled", false, "是否启用 SMTP 邮件通知") command.Flags().StringVar(&emailSMTPHost, "email-smtp-host", "", "SMTP 服务器主机") command.Flags().IntVar(&emailSMTPPort, "email-smtp-port", 587, "SMTP 服务器端口") command.Flags().StringVar(&emailSMTPUsername, "email-smtp-username", "", "SMTP 用户名") command.Flags().StringVar(&emailSMTPPassword, "email-smtp-password", "", "SMTP 密码(仅建议临时使用参数,优先交互输入)") command.Flags().StringVar(&emailSMTPMode, "email-smtp-mode", "starttls", "SMTP 连接模式:plain、starttls、implicit_tls") command.Flags().BoolVar(&emailSMTPAllowPlaintextAuth, "email-smtp-allow-plaintext-auth", false, "是否允许 SMTP 明文连接认证") command.Flags().StringVar(&emailHTTPPath, "email-http-path", "/api/email/send", "HTTP 邮件通知请求路径") command.Flags().StringVar(&emailHTTPLoginPath, "email-http-login-path", "/api/login", "HTTP 会话认证登录请求路径") command.Flags().StringVar(&emailHTTPAuthMode, "email-http-auth-mode", "none", "HTTP 邮件通知认证方式:none、bearer、hmac_sha256、session") command.Flags().StringVar(&emailHTTPCredentialRef, "email-http-credential-ref", "", "HTTP 邮件通知凭据引用") command.Flags().StringVar(&emailHTTPHeaders, "email-http-headers", "", "HTTP 自定义请求头 JSON") command.Flags().StringVar(&emailEventMode, "email-event-mode", "detailed", "通知事件模式:detailed、unified") command.Flags().StringVar(&emailSenderName, "email-sender-name", "Scert", "通知发件人名称") command.Flags().StringVar(&emailSenderEmail, "email-sender-email", "", "通知发件人地址,留空使用管理员邮箱") command.Flags().BoolVar(&emailIncludeDeploymentDetails, "email-include-deployment-details", true, "是否发送服务器部署明细") command.Flags().BoolVar(&emailIncludeCertificateSerial, "email-include-certificate-serial", true, "是否发送证书序列号") command.Flags().BoolVar(&emailIncludeManagementURL, "email-include-management-url", true, "是否发送管理后台链接") command.Flags().StringVar(&emailProvider, "email-provider", "", "第三方邮箱服务标识") command.Flags().StringVar(&emailEndpoint, "email-endpoint", "", "第三方邮箱 API 地址") command.Flags().StringVar(&recipients, "recipients", "", "通知收件人,多个地址用逗号分隔") command.Flags().IntVar(&emailTimeoutSecs, "email-timeout-secs", 10, "通知请求超时时间(秒)") command.Flags().IntVar(&emailRetryCount, "email-retry-count", 3, "通知失败重试次数") command.Flags().StringVar(&emailRetryIntervals, "email-retry-intervals", "30,120,600", "通知重试间隔(秒,逗号分隔)") command.Flags().StringVar(&dnsProvider, "dns-provider", "", "DNS API 服务商标识") command.Flags().StringVar(&dnsCredentialRef, "dns-credential-ref", "", "DNS 凭据引用") command.Flags().BoolVar(&dnsVerified, "dns-verified", false, "DNS API 是否已通过验证测试") command.Flags().StringVar(&certificateRoot, "certificate-root", "", "证书部署根目录,默认使用系统目录") command.Flags().StringVar(&webHostname, "web-hostname", "", "管理页面域名") command.Flags().StringVar(&webPublicIP, "web-public-ip", "", "没有域名时使用的公网 IP") command.Flags().BoolVar(&webEnabled, "web-enabled", true, "是否开启管理页面") command.Flags().StringVar(&httpsMode, "https-mode", "direct", "HTTPS 模式:direct、proxy") return command } func saveSetupWithFirewall(ctx context.Context, cfg config.Config, path string, firewallManager firewall.Manager) (firewall.Result, error) { result := firewall.Result{ Backend: firewall.BackendNone, Port: cfg.Server.Port, Message: "未配置防火墙管理器,已跳过本机规则创建", } if firewallManager != nil { firewallContext, cancel := context.WithTimeout(ctx, 30*time.Second) var err error result, err = firewallManager.OpenTCP(firewallContext, cfg.Server.Port) cancel() if err != nil { return firewall.Result{}, fmt.Errorf("自动配置本机防火墙失败,请使用管理员权限重新运行初始化程序:%w", err) } } if err := cfg.Save(path); err != nil { if result.Created && firewallManager != nil { rollbackContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) rollbackErr := firewallManager.Rollback(rollbackContext, result) cancel() if rollbackErr != nil { return firewall.Result{}, fmt.Errorf("保存初始化配置失败,且自动创建的防火墙规则回滚失败,请手动检查本机防火墙:保存错误:%v;回滚错误:%w", err, rollbackErr) } } return firewall.Result{}, err } return result, nil } func readInteractiveInit(input io.Reader, output io.Writer, value *setup.Input, cfg config.Config) error { reader := bufio.NewReader(input) fmt.Fprintln(output, "开始初始化 Scert。带 * 的项目为必填项。") var err error value.AdminUsername, err = ask(reader, output, "超级管理员用户名 *", "admin") if err != nil { return err } value.AdminEmail, err = ask(reader, output, "超级管理员绑定邮箱 *", "") if err != nil { return err } value.AdminPassword, err = ask(reader, output, "超级管理员密码 *", "") if err != nil { return err } port, err := askInt(reader, output, "Scert 主服务端口 *", cfg.Server.Port) if err != nil { return err } value.ServerPort = port value.EmailHTTPEnabled, err = askYesNo(reader, output, "是否启用 HTTP 邮件通知?", false) if err != nil { return err } if value.EmailHTTPEnabled { value.EmailProvider, err = ask(reader, output, "第三方邮箱服务标识", "") if err != nil { return err } value.EmailEndpoint, err = ask(reader, output, "第三方邮箱 API 地址", "") if err != nil { return err } } value.EmailSMTPEnabled, err = askYesNo(reader, output, "是否启用 SMTP 邮件通知?", false) if err != nil { return err } if value.EmailSMTPEnabled { value.EmailSMTPHost, err = ask(reader, output, "SMTP 服务器主机 *", "") if err != nil { return err } value.EmailSMTPPort, err = askInt(reader, output, "SMTP 服务器端口 *", 587) if err != nil { return err } value.EmailSMTPUsername, err = ask(reader, output, "SMTP 用户名 *", "") if err != nil { return err } value.EmailSMTPPassword, err = ask(reader, output, "SMTP 密码或授权码 *", "") if err != nil { return err } value.EmailSMTPMode, err = ask(reader, output, "SMTP 连接模式(plain/starttls/implicit_tls)", "starttls") if err != nil { return err } if value.EmailSMTPMode == "plain" { value.EmailSMTPAllowPlaintextAuth, err = askYesNo(reader, output, "是否允许 SMTP 明文认证?", false) if err != nil { return err } } } receiver, err := ask(reader, output, "通知收件人(逗号分隔)", "") if err != nil { return err } value.EmailRecipients = splitValues(receiver) value.DNSProvider, err = askDNSProvider(reader, output) if err != nil { return err } value.DNSCredentials, err = askDNSCredentials(reader, output, value.DNSProvider) if err != nil { return err } value.DNSTestDomain, err = ask(reader, output, "DNS 权限测试域名 *", "") if err != nil { return err } value.CertificateRoot, err = ask(reader, output, "证书部署根目录", cfg.Paths.Certificate) if err != nil { return err } value.WebHostname, err = ask(reader, output, "管理页面域名(无域名可留空)", "") if err != nil { return err } if value.WebHostname == "" { value.WebPublicIP, err = ask(reader, output, "管理页面公网 IP", "") if err != nil { return err } } value.WebEnabled, err = askYesNo(reader, output, "是否开启管理页面?", true) return err } func askDNSCredentials(reader *bufio.Reader, output io.Writer, providerID string) (map[string]string, error) { descriptor, err := dnsprovider.GetDescriptor(providerID) if err != nil { return nil, err } credentials := make(map[string]string, len(descriptor.CredentialFields)) fmt.Fprintln(output, "请输入 DNS API 凭据。输入内容会在当前配置过程中明文显示,提交后只保存加密内容。") for _, field := range descriptor.CredentialFields { label := field.Label if field.Required { label += " *" } value, err := ask(reader, output, label, "") if err != nil { return nil, err } if strings.TrimSpace(value) != "" { credentials[field.Name] = value } } return credentials, nil } func prepareSMTPCredentials(ctx context.Context, cfg config.Config, input *setup.Input) (func(), error) { if input == nil { return func() {}, errors.New("初始化输入不能为空") } if !input.EmailSMTPEnabled { return func() {}, nil } if strings.TrimSpace(input.EmailSMTPCredentialRef) != "" { return func() {}, nil } if strings.TrimSpace(input.EmailSMTPUsername) == "" { return func() {}, errors.New("启用 SMTP 通知后必须填写 SMTP 用户名") } if input.EmailSMTPPassword == "" { return func() {}, errors.New("启用 SMTP 通知后必须填写 SMTP 密码") } if cfg.Paths.Config == "" { return func() {}, errors.New("SMTP 敏感凭据存储目录不能为空") } store, err := secretstore.Open(cfg.Paths.Config) if err != nil { return func() {}, fmt.Errorf("初始化 SMTP 凭据存储失败:%w", err) } reference, err := newSMTPCredentialReference() if err != nil { return func() {}, err } if err := store.Put(ctx, reference, map[string]string{ "username": strings.TrimSpace(input.EmailSMTPUsername), "password": input.EmailSMTPPassword, }); err != nil { return func() {}, fmt.Errorf("保存 SMTP 凭据失败:%w", err) } input.EmailSMTPCredentialRef = reference return func() { cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) defer cleanupCancel() _ = store.Delete(cleanupContext, reference) }, nil } func newSMTPCredentialReference() (string, error) { buffer := make([]byte, 18) if _, err := rand.Read(buffer); err != nil { return "", fmt.Errorf("生成 SMTP 凭据引用失败:%w", err) } return "secret://notification/smtp/" + hex.EncodeToString(buffer), nil } func prepareDNSCredentials(ctx context.Context, cfg config.Config, input *setup.Input, output io.Writer) (func(), error) { if input == nil { return func() {}, errors.New("初始化输入不能为空") } if strings.TrimSpace(input.DNSCredentialRef) != "" { return func() {}, nil } if len(input.DNSCredentials) == 0 { return func() {}, errors.New("DNS 凭据不能为空") } if strings.TrimSpace(input.DNSTestDomain) == "" { return func() {}, errors.New("DNS 权限测试域名不能为空") } store, err := secretstore.Open(cfg.Paths.Config) if err != nil { return func() {}, fmt.Errorf("初始化 DNS 凭据存储失败:%w", err) } manager := setup.NewDNSVerificationManager( dnsprovider.NewTester(dnsprovider.NewLegoFactory()), store, ) return prepareDNSCredentialsWithManager(ctx, input, output, manager, store) } func prepareDNSCredentialsWithManager(ctx context.Context, input *setup.Input, output io.Writer, manager *setup.DNSVerificationManager, store *secretstore.Store) (func(), error) { if input == nil { return func() {}, errors.New("初始化输入不能为空") } if manager == nil || store == nil { return func() {}, errors.New("DNS 凭据验证服务未初始化") } if strings.TrimSpace(input.DNSCredentialRef) != "" { return func() {}, nil } if len(input.DNSCredentials) == 0 { return func() {}, errors.New("DNS 凭据不能为空") } if strings.TrimSpace(input.DNSTestDomain) == "" { return func() {}, errors.New("DNS 权限测试域名不能为空") } testContext, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() result, err := manager.Verify(testContext, setup.DNSVerificationInput{ ProviderID: input.DNSProvider, TestDomain: input.DNSTestDomain, Credentials: input.DNSCredentials, }) if err != nil { return func() {}, fmt.Errorf("DNS API 在线验证失败:%w", err) } input.DNSCredentialRef = result.CredentialRef input.DNSVerified = true input.DNSCredentials = nil fmt.Fprintf(output, "DNS API 在线验证通过,凭据已加密保存,有效期至 %s。\n", result.ExpiresAt.Local().Format("2006-01-02 15:04:05")) return func() { cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) defer cleanupCancel() _ = store.Delete(cleanupContext, result.CredentialRef) }, nil } func askDNSProvider(reader *bufio.Reader, output io.Writer) (string, error) { providers := dnsprovider.Descriptors() fmt.Fprintln(output, "请选择 DNS API 服务商:") for index, provider := range providers { status := "" if !provider.Available { status = "(暂未开放)" } fmt.Fprintf(output, "%d. %s%s\n", index+1, provider.Name, status) } for { value, err := ask(reader, output, "请输入编号", "") if err != nil { return "", err } index, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || index < 1 || index > len(providers) { fmt.Fprintln(output, "编号无效,请重新输入。") continue } provider := providers[index-1] if !provider.Available { fmt.Fprintf(output, "%s 当前暂未开放,请选择其他服务商。\n", provider.Name) continue } return provider.ID, nil } } func ask(reader *bufio.Reader, output io.Writer, label, defaultValue string) (string, error) { if defaultValue != "" { fmt.Fprintf(output, "%s [%s]:", label, defaultValue) } else { fmt.Fprintf(output, "%s:", label) } line, err := reader.ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return "", err } line = strings.TrimSpace(line) if line == "" { line = defaultValue } return line, nil } func askInt(reader *bufio.Reader, output io.Writer, label string, defaultValue int) (int, error) { value, err := ask(reader, output, label, strconv.Itoa(defaultValue)) if err != nil { return 0, err } parsed, err := strconv.Atoi(value) if err != nil { return 0, fmt.Errorf("%s 必须是数字", label) } return parsed, nil } func askYesNo(reader *bufio.Reader, output io.Writer, label string, defaultValue bool) (bool, error) { defaultText := "否" if defaultValue { defaultText = "是" } value, err := ask(reader, output, label+"(是/否)", defaultText) if err != nil { return false, err } switch strings.ToLower(value) { case "是", "y", "yes": return true, nil case "否", "n", "no": return false, nil default: return false, fmt.Errorf("%s 请输入是或否", label) } } func splitValues(value string) []string { if strings.TrimSpace(value) == "" { return nil } return strings.Split(value, ",") } func splitInts(value string) ([]int, error) { if strings.TrimSpace(value) == "" { return nil, nil } items := strings.Split(value, ",") result := make([]int, 0, len(items)) for _, item := range items { parsed, err := strconv.Atoi(strings.TrimSpace(item)) if err != nil { return nil, fmt.Errorf("%q 不是有效数字", strings.TrimSpace(item)) } result = append(result, parsed) } return result, nil } func parseStringMap(value string) (map[string]string, error) { if strings.TrimSpace(value) == "" { return nil, nil } var result map[string]string if err := json.Unmarshal([]byte(value), &result); err != nil { return nil, err } return result, nil } func newStatusCommand() *cobra.Command { return &cobra.Command{Use: "status", Short: "查看服务状态", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } return printJSON(cmd, commandStatus(cfg)) }} } func commandStatus(cfg config.Config) map[string]any { return map[string]any{"service": "scert", "version": buildinfo.Version, "config_version": cfg.Version, "setup_ready": cfg.SetupReady, "https_mode": cfg.Server.HTTPSMode, "database_driver": cfg.Database.Driver, "server_port": cfg.Server.Port, "reset_url": cfg.ResetURL(), "web_enabled": cfg.Web.Enabled, "web_hostname": cfg.Web.Hostname, "web_public_ip": cfg.Web.PublicIP, "dns_verification_state": cfg.DNS.VerificationState} } func newServeCommand() *cobra.Command { return &cobra.Command{Use: "serve", Short: "启动网站服务", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } if !cfg.SetupReady { cfg.Server.Host = "127.0.0.1" fmt.Fprintln(cmd.OutOrStdout(), "系统尚未初始化,临时服务仅监听本机;请完成初始化后重启服务。") } if cfg.SetupReady && !cfg.Web.Enabled { return fmt.Errorf("管理页面已关闭") } service, err := app.NewWithConfigPath(cmd.Context(), cfg, resolveConfigPath()) if err != nil { return err } defer service.Close() ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) defer stop() if service.Renewal != nil && cfg.SetupReady { service.Renewal.Start(ctx) } errCh := make(chan error, 1) go func() { errCh <- service.HTTP.ListenAndServe() }() select { case <-ctx.Done(): return nil case err := <-errCh: if errors.Is(err, http.ErrServerClosed) { return nil } return err } }} } func newDomainCommand() *cobra.Command { command := &cobra.Command{Use: "domain", Short: "管理托管域名"} command.AddCommand(newDomainListCommand(), newDomainAddCommand(), newDomainEditCommand(), newDomainDeleteCommand(), newDomainIssueCommand(), newDomainImportCommand(), newDomainServersCommand()) return command } func newDomainIssueCommand() *cobra.Command { var id string command := &cobra.Command{Use: "issue", Short: "申请或续期域名证书", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } service, err := app.New(cmd.Context(), cfg) if err != nil { return err } defer service.Close() if service.Workflow == nil { return fmt.Errorf("证书申请服务未初始化") } result, err := service.Workflow.IssueWithReport(cmd.Context(), id) if err != nil { return err } return printJSON(cmd, result) }} command.Flags().StringVar(&id, "id", "", "域名 ID") _ = command.MarkFlagRequired("id") return command } func newDomainImportCommand() *cobra.Command { var id, certificatePath, privateKeyPath, issuerPath string command := &cobra.Command{Use: "import", Short: "导入自有证书", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } service, err := app.New(cmd.Context(), cfg) if err != nil { return err } defer service.Close() if service.Workflow == nil { return fmt.Errorf("证书导入服务未初始化") } result, err := service.Workflow.Import(cmd.Context(), certificate.ImportRequest{ DomainID: id, Certificate: certificatePath, PrivateKey: privateKeyPath, IssuerBundle: issuerPath, }) if err != nil { return err } return printJSON(cmd, result) }} command.Flags().StringVar(&id, "id", "", "域名 ID") command.Flags().StringVar(&certificatePath, "certificate", "", "证书 PEM 文件路径") command.Flags().StringVar(&privateKeyPath, "private-key", "", "私钥 PEM 文件路径") command.Flags().StringVar(&issuerPath, "issuer", "", "签发者证书 PEM 文件路径,可选") _ = command.MarkFlagRequired("id") _ = command.MarkFlagRequired("certificate") _ = command.MarkFlagRequired("private-key") return command } func newDomainListCommand() *cobra.Command { return &cobra.Command{Use: "list", Short: "查看域名列表", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } service, err := app.New(cmd.Context(), cfg) if err != nil { return err } defer service.Close() items, err := service.Repository.List(cmd.Context()) if err != nil { return err } return printJSON(cmd, items) }} } func newDomainAddCommand() *cobra.Command { var name, certificateType, acmeEnvironment, dnsProvider string var sans []string var autoRenew bool command := &cobra.Command{Use: "add", Short: "添加托管域名", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } service, err := app.New(cmd.Context(), cfg) if err != nil { return err } defer service.Close() item, err := domain.NewDomainWithOptions(name, domain.CertificateType(certificateType), sans, autoRenew, acmeEnvironment, dnsProvider) if err != nil { return err } if err := service.Repository.Create(cmd.Context(), item); err != nil { return err } fmt.Printf("域名已添加:%s\n", item.Name) return nil }} command.Flags().StringVar(&name, "name", "", "域名") command.Flags().StringVar(&certificateType, "type", string(domain.CertificateSingle), "证书类型:single、wildcard、san") command.Flags().StringSliceVar(&sans, "sans", nil, "SAN 域名列表") command.Flags().BoolVar(&autoRenew, "auto-renew", true, "是否启用自动续期") command.Flags().StringVar(&acmeEnvironment, "acme-environment", domain.ACMEEnvironmentStaging, "ACME 环境:staging、production") command.Flags().StringVar(&dnsProvider, "dns-provider", "", "DNS 验证服务商标识") _ = command.MarkFlagRequired("name") return command } func newDomainEditCommand() *cobra.Command { var id, name, certificateType, acmeEnvironment, dnsProvider string var sans []string var autoRenew bool command := &cobra.Command{Use: "edit", Short: "编辑托管域名", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } service, err := app.New(cmd.Context(), cfg) if err != nil { return err } defer service.Close() existing, err := service.Repository.GetByID(cmd.Context(), id) if err != nil { return err } item, err := domain.NewDomainWithOptions(name, domain.CertificateType(certificateType), sans, autoRenew, acmeEnvironment, dnsProvider) if err != nil { return err } item.ID = existing.ID item.CreatedAt = existing.CreatedAt item.Status = "pending" item.ExpiresAt = nil if err := service.Repository.Update(cmd.Context(), item); err != nil { return err } fmt.Printf("域名已更新:%s\n", item.Name) return nil }} command.Flags().StringVar(&id, "id", "", "域名 ID") command.Flags().StringVar(&name, "name", "", "域名") command.Flags().StringVar(&certificateType, "type", string(domain.CertificateSingle), "证书类型:single、wildcard、san") command.Flags().StringSliceVar(&sans, "sans", nil, "SAN 域名列表") command.Flags().BoolVar(&autoRenew, "auto-renew", true, "是否启用自动续期") command.Flags().StringVar(&acmeEnvironment, "acme-environment", domain.ACMEEnvironmentStaging, "ACME 环境:staging、production") command.Flags().StringVar(&dnsProvider, "dns-provider", "", "DNS 验证服务商标识") _ = command.MarkFlagRequired("id") _ = command.MarkFlagRequired("name") return command } func newDomainDeleteCommand() *cobra.Command { var id string command := &cobra.Command{Use: "delete", Short: "删除托管域名", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } service, err := app.New(cmd.Context(), cfg) if err != nil { return err } defer service.Close() if err := service.Repository.Delete(cmd.Context(), id); err != nil { return err } fmt.Printf("域名已删除:%s\n", id) return nil }} command.Flags().StringVar(&id, "id", "", "域名 ID") _ = command.MarkFlagRequired("id") return command } func printJSON(cmd *cobra.Command, value any) error { data, err := json.MarshalIndent(value, "", " ") if err != nil { return fmt.Errorf("编码 JSON 失败:%w", err) } _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data)) return err } func loadConfig() (config.Config, error) { return config.Load(resolveConfigPath()) } func resolveConfigPath() string { if configPath != "" { return configPath } paths := config.DefaultPaths(baseDir) return filepath.Join(paths.Config, "scert.json") }