增加自定义绑定域名

This commit is contained in:
2026-09-02 15:11:32 +08:00
parent db519d9af2
commit f644c278c9
23 changed files with 2966 additions and 212 deletions
+528
View File
@@ -0,0 +1,528 @@
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()
}