Files

529 lines
16 KiB
Go
Raw Permalink 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 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()
}