package services import ( "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "fmt" "os" "path/filepath" "regexp" "strings" "sync" "time" "server/models" "github.com/beego/beego/v2/core/logs" beego "github.com/beego/beego/v2/server/web" "golang.org/x/crypto/acme" ) const ( acmeAccountDir = "_account" acmeAccountKeyFile = "key.pem" acmeChallengeDir = "_challenges" certFileName = "fullchain.pem" keyFileName = "privkey.pem" // 签发失败后的冷却时间。Let's Encrypt 对同一账号的验证失败次数有频控 // (每小时 5 次),冷却可避免租户反复点击把额度打满。 issueFailureCooldown = 30 * time.Minute // 单次签发的整体超时。HTTP-01 需要等 CA 回访,给足时间但不能无限等。 issueTimeout = 3 * time.Minute // 证书到期前多少天开始续期 renewBeforeDays = 30 ) var ( challengeMu sync.RWMutex challengeStore = make(map[string]string) // token -> keyAuthorization issueMu sync.Mutex issuing = make(map[string]bool) // host -> 是否正在签发 lastFailureAt = make(map[string]time.Time) // host -> 最近一次签发失败时间 ) // ===== challenge 存取(供 /.well-known/acme-challenge/:token 路由使用)===== // challengeTokenRe ACME token 的合法字符集(base64url)。 // token 会被拼进文件名,必须先校验,避免路径穿越。 var challengeTokenRe = regexp.MustCompile(`^[A-Za-z0-9_-]{16,128}$`) func challengeFilePath(token string) string { return filepath.Join(SSLCertDir(), acmeChallengeDir, token) } // GetHTTP01Challenge 返回 token 对应的 keyAuthorization。 // // 先查内存,再查磁盘。磁盘这层是为拆进程部署准备的:签发是由 backend/platform // 接口触发的,而 CA 的回访只会落到 Nginx 扑底站点指向的那个进程,两者可能不是 // 同一个进程,只靠内存会验证失败。 func GetHTTP01Challenge(token string) (string, bool) { challengeMu.RLock() v, ok := challengeStore[token] challengeMu.RUnlock() if ok { return v, true } if !challengeTokenRe.MatchString(token) { return "", false } raw, err := os.ReadFile(challengeFilePath(token)) if err != nil { return "", false } return strings.TrimSpace(string(raw)), true } func putHTTP01Challenge(token, keyAuth string) { challengeMu.Lock() challengeStore[token] = keyAuth challengeMu.Unlock() if !challengeTokenRe.MatchString(token) { return } dir := filepath.Join(SSLCertDir(), acmeChallengeDir) if err := os.MkdirAll(dir, 0o755); err != nil { logs.Warn("[acme] 创建 challenge 目录失败(将只依赖内存): %v", err) return } if err := os.WriteFile(challengeFilePath(token), []byte(keyAuth), 0o644); err != nil { logs.Warn("[acme] 写入 challenge 文件失败(将只依赖内存): %v", err) } } func deleteHTTP01Challenge(token string) { challengeMu.Lock() delete(challengeStore, token) challengeMu.Unlock() if challengeTokenRe.MatchString(token) { _ = os.Remove(challengeFilePath(token)) } } // ===== 配置 ===== // SSLCertDir 证书落盘根目录 func SSLCertDir() string { dir, _ := beego.AppConfig.String("ssl_cert_dir") dir = strings.TrimSpace(dir) if dir == "" { dir = "/www/wwwroot/ssl-certs" } return dir } func acmeDirectoryURL() string { u, _ := beego.AppConfig.String("acme_directory") return strings.TrimSpace(u) } func acmeContactEmail() string { e, _ := beego.AppConfig.String("acme_email") return strings.TrimSpace(e) } // CertPaths 返回某域名的证书与私钥落盘路径。 // host 会先做归一化:域名来自数据库,归一化可避免脏数据里的 ../ 之类写法拼出目录外的路径。 func CertPaths(host string) (certPath, keyPath string) { h := NormalizeHost(host) if h == "" { h = "_invalid" } base := filepath.Join(SSLCertDir(), h) return filepath.Join(base, certFileName), filepath.Join(base, keyFileName) } // ===== ACME 账号 ===== // loadOrCreateAccountKey 读取(或首次生成)ACME 账号私钥。 // 账号私钥必须持久化:换了 key 等于换账号,历史签发记录和频控额度都会重置。 func loadOrCreateAccountKey() (crypto.Signer, error) { dir := filepath.Join(SSLCertDir(), acmeAccountDir) path := filepath.Join(dir, acmeAccountKeyFile) if raw, err := os.ReadFile(path); err == nil { block, _ := pem.Decode(raw) if block == nil { return nil, fmt.Errorf("账号私钥格式错误: %s", path) } key, err := x509.ParseECPrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("解析账号私钥失败: %v", err) } return key, nil } if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("创建账号目录失败: %v", err) } key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, fmt.Errorf("生成账号私钥失败: %v", err) } der, err := x509.MarshalECPrivateKey(key) if err != nil { return nil, fmt.Errorf("序列化账号私钥失败: %v", err) } pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) if err := os.WriteFile(path, pemBytes, 0o600); err != nil { return nil, fmt.Errorf("写入账号私钥失败: %v", err) } return key, nil } // newACMEClient 构造 ACME 客户端并确保账号已注册 func newACMEClient(ctx context.Context) (*acme.Client, error) { key, err := loadOrCreateAccountKey() if err != nil { return nil, err } client := &acme.Client{ Key: key, DirectoryURL: acmeDirectoryURL(), // 留空则 acme 包默认使用 Let's Encrypt 正式环境 } acct := &acme.Account{} if email := acmeContactEmail(); email != "" { acct.Contact = []string{"mailto:" + email} } // 已注册过会返回 ErrAccountAlreadyExists,属于正常情况 if _, err := client.Register(ctx, acct, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists { return nil, fmt.Errorf("注册 ACME 账号失败: %v", err) } return client, nil } // ===== 证书签发 ===== // obtainCertificate 走完整的 HTTP-01 流程拿到证书并落盘,返回证书到期时间。 func obtainCertificate(ctx context.Context, host string) (time.Time, error) { client, err := newACMEClient(ctx) if err != nil { return time.Time{}, err } order, err := client.AuthorizeOrder(ctx, acme.DomainIDs(host)) if err != nil { return time.Time{}, fmt.Errorf("创建证书订单失败: %v", err) } // 逐个完成 pending 的授权 for _, authzURL := range order.AuthzURLs { authz, err := client.GetAuthorization(ctx, authzURL) if err != nil { return time.Time{}, fmt.Errorf("获取授权信息失败: %v", err) } if authz.Status != acme.StatusPending { continue } var chal *acme.Challenge for _, c := range authz.Challenges { if c.Type == "http-01" { chal = c break } } if chal == nil { return time.Time{}, fmt.Errorf("证书颁发机构未提供 http-01 验证方式") } keyAuth, err := client.HTTP01ChallengeResponse(chal.Token) if err != nil { return time.Time{}, fmt.Errorf("生成验证响应失败: %v", err) } putHTTP01Challenge(chal.Token, keyAuth) if _, err := client.Accept(ctx, chal); err != nil { deleteHTTP01Challenge(chal.Token) return time.Time{}, fmt.Errorf("提交验证失败: %v", err) } _, err = client.WaitAuthorization(ctx, authz.URI) deleteHTTP01Challenge(chal.Token) if err != nil { return time.Time{}, fmt.Errorf("域名验证未通过(请确认 80 端口可从公网访问且未强制跳转 HTTPS): %v", err) } } if _, err := client.WaitOrder(ctx, order.URI); err != nil { return time.Time{}, fmt.Errorf("订单未就绪: %v", err) } // 生成证书私钥与 CSR certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return time.Time{}, fmt.Errorf("生成证书私钥失败: %v", err) } csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ Subject: pkix.Name{CommonName: host}, DNSNames: []string{host}, }, certKey) if err != nil { return time.Time{}, fmt.Errorf("生成 CSR 失败: %v", err) } chainDER, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csrDER, true) if err != nil { return time.Time{}, fmt.Errorf("签发证书失败: %v", err) } if len(chainDER) == 0 { return time.Time{}, fmt.Errorf("证书颁发机构返回了空证书链") } leaf, err := x509.ParseCertificate(chainDER[0]) if err != nil { return time.Time{}, fmt.Errorf("解析证书失败: %v", err) } if err := writeCertFiles(host, chainDER, certKey); err != nil { return time.Time{}, err } return leaf.NotAfter, nil } // writeCertFiles 把证书链与私钥写入 {ssl_cert_dir}/{host}/。 // 先写临时文件再 rename,避免 nginx 读到写了一半的文件。 func writeCertFiles(host string, chainDER [][]byte, key *ecdsa.PrivateKey) error { certPath, keyPath := CertPaths(host) dir := filepath.Dir(certPath) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("创建证书目录失败: %v", err) } var chainPEM strings.Builder for _, der := range chainDER { if err := pem.Encode(&chainPEM, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { return fmt.Errorf("编码证书失败: %v", err) } } if err := writeFileAtomic(certPath, []byte(chainPEM.String()), 0o644); err != nil { return fmt.Errorf("写入证书失败: %v", err) } keyDER, err := x509.MarshalECPrivateKey(key) if err != nil { return fmt.Errorf("序列化证书私钥失败: %v", err) } keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) if err := writeFileAtomic(keyPath, keyPEM, 0o600); err != nil { return fmt.Errorf("写入证书私钥失败: %v", err) } return nil } func writeFileAtomic(path string, data []byte, perm os.FileMode) error { tmp := path + ".tmp" if err := os.WriteFile(tmp, data, perm); err != nil { return err } // Linux 上 rename 覆盖同名文件是原子的,nginx 不会读到半个文件。 // Windows 上 rename 到已存在文件会失败,才退回「先删再改名」。 if err := os.Rename(tmp, path); err != nil { _ = os.Remove(path) if err2 := os.Rename(tmp, path); err2 != nil { _ = os.Remove(tmp) return err2 } } return nil } // ===== 与域名记录联动 ===== // tryLockIssue 抢占某域名的签发权。返回 false 表示正在签发或处于失败冷却期。 func tryLockIssue(host string) (bool, string) { issueMu.Lock() defer issueMu.Unlock() if issuing[host] { return false, "该域名正在签发证书,请稍后查看结果" } if at, ok := lastFailureAt[host]; ok { if wait := issueFailureCooldown - time.Since(at); wait > 0 { return false, fmt.Sprintf("上次签发失败,请在 %d 分钟后重试", int(wait.Minutes())+1) } } issuing[host] = true return true, "" } func unlockIssue(host string, failed bool) { issueMu.Lock() defer issueMu.Unlock() delete(issuing, host) if failed { lastFailureAt[host] = time.Now() } else { delete(lastFailureAt, host) } } // IssueCertificateAsync 异步为域名记录签发证书。 // 立即返回;成功后把记录置为 ssl_status=2 且 status=1(已生效)。 // 返回 false 表示当前不可发起(正在签发或冷却中),第二个返回值为原因。 func IssueCertificateAsync(domainID uint64, host string) (bool, string) { host = NormalizeHost(host) if host == "" { return false, "域名格式不正确" } ok, reason := tryLockIssue(host) if !ok { return false, reason } updateSSLState(domainID, models.SSLStatusIssuing, nil, nil) go func() { ctx, cancel := context.WithTimeout(context.Background(), issueTimeout) defer cancel() expireAt, err := obtainCertificate(ctx, host) if err != nil { logs.Error("[acme] 签发证书失败 host=%s: %v", host, err) msg := err.Error() updateSSLState(domainID, models.SSLStatusFailed, nil, &msg) unlockIssue(host, true) return } logs.Info("[acme] 签发证书成功 host=%s 到期=%s", host, expireAt.Format("2006-01-02")) updateSSLState(domainID, models.SSLStatusIssued, &expireAt, nil) // 证书就绪,域名正式生效。 // 只把「待生效(0)」推到「已生效(1)」:被平台禁用(2)的记录不能靠续期自己复活。 now := time.Now() if _, err := models.Orm.QueryTable(new(models.SystemTenantDomain)). Filter("id", domainID). Filter("status", 0). Filter("delete_time__isnull", true). Update(map[string]interface{}{"status": 1, "update_time": now}); err != nil { logs.Error("[acme] 更新域名生效状态失败 id=%d: %v", domainID, err) } unlockIssue(host, false) }() return true, "" } // updateSSLState 更新证书状态字段。 // 注意:beego ORM 的 Update 会对参数做 reflect 解引用,直接塞 nil 的 *string 会 panic, // 因此这里统一转成空字符串 / 显式值。 func updateSSLState(domainID uint64, sslStatus int, expireAt *time.Time, errMsg *string) { msg := "" if errMsg != nil { msg = *errMsg // ssl_error 字段 varchar(500),超长会被 MySQL 严格模式拒绝 if len(msg) > 480 { msg = msg[:480] } } values := map[string]interface{}{ "ssl_status": sslStatus, "ssl_error": msg, "update_time": time.Now(), } if expireAt != nil { values["ssl_expire_at"] = *expireAt } if _, err := models.Orm.QueryTable(new(models.SystemTenantDomain)). Filter("id", domainID). Filter("delete_time__isnull", true). Update(values); err != nil { logs.Error("[acme] 更新证书状态失败 id=%d: %v", domainID, err) } } // ===== 续期调度 ===== // StartCertRenewScheduler 启动证书续期调度:启动 1 分钟后跑一次,之后每 12 小时扫一次。 func StartCertRenewScheduler(stopChan chan struct{}) { go func() { // 启动稍等,避开数据库初始化与首屏请求高峰 firstRun := time.NewTimer(1 * time.Minute) defer firstRun.Stop() select { case <-firstRun.C: renewExpiringCertificates() case <-stopChan: return } ticker := time.NewTicker(12 * time.Hour) defer ticker.Stop() for { select { case <-ticker.C: renewExpiringCertificates() case <-stopChan: return } } }() } // renewExpiringCertificates 扫描自有域名,为「即将到期」「证书文件缺失」「之前失败」的记录重新签发。 // 已生效域名续期失败不会下线站点,只写 ssl_error 等下一轮重试。 func renewExpiringCertificates() { var rows []models.SystemTenantDomain _, err := models.Orm.QueryTable(new(models.SystemTenantDomain)). Filter("domain_type", models.DomainTypeCustom). Filter("delete_time__isnull", true). Exclude("status", 2). // 已禁用的不续 All(&rows) if err != nil { logs.Error("[acme] 扫描待续期域名失败: %v", err) return } deadline := time.Now().AddDate(0, 0, renewBeforeDays) for i := range rows { row := rows[i] if row.FullDomain == nil || *row.FullDomain == "" { continue } // DNS 还没通过的不去打 CA,避免浪费频控额度 if row.VerifyStatus != models.VerifyStatusPassed { continue } if !needsRenew(row, deadline) { continue } if ok, reason := IssueCertificateAsync(row.ID, *row.FullDomain); !ok { logs.Info("[acme] 跳过续期 host=%s: %s", *row.FullDomain, reason) } } } // needsRenew 判断一条记录是否需要(重新)签发 func needsRenew(row models.SystemTenantDomain, deadline time.Time) bool { if row.SSLStatus == models.SSLStatusIssuing { return false } if row.SSLStatus != models.SSLStatusIssued { // 待处理或曾失败:重试(失败冷却由 tryLockIssue 兜住) return true } // 证书文件被误删也要补签 certPath, keyPath := CertPaths(*row.FullDomain) if !fileExists(certPath) || !fileExists(keyPath) { return true } return row.SSLExpireAt == nil || row.SSLExpireAt.Before(deadline) } func fileExists(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() }