增加自定义绑定域名
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// hostnameRe 合法主机名:多段标签,每段字母数字或连字符,末段为字母(顶级域)
|
||||
var hostnameRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
|
||||
|
||||
// NormalizeHost 归一化用户输入的域名:去协议、去路径、去端口、去尾点、转小写。
|
||||
// 返回空字符串表示输入不是合法主机名。
|
||||
func NormalizeHost(raw string) string {
|
||||
h := strings.TrimSpace(strings.ToLower(raw))
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
// 去掉协议前缀
|
||||
if idx := strings.Index(h, "://"); idx >= 0 {
|
||||
h = h[idx+3:]
|
||||
}
|
||||
// 去掉路径与查询
|
||||
if idx := strings.IndexAny(h, "/?#"); idx >= 0 {
|
||||
h = h[:idx]
|
||||
}
|
||||
// 去掉可能的 user@ 前缀
|
||||
if idx := strings.LastIndex(h, "@"); idx >= 0 {
|
||||
h = h[idx+1:]
|
||||
}
|
||||
// 去掉端口
|
||||
if idx := strings.Index(h, ":"); idx >= 0 {
|
||||
h = h[:idx]
|
||||
}
|
||||
// 去掉 FQDN 末尾的点
|
||||
h = strings.TrimSuffix(h, ".")
|
||||
// 去掉通配符写法
|
||||
h = strings.TrimPrefix(h, "*.")
|
||||
|
||||
if len(h) > 253 || !hostnameRe.MatchString(h) {
|
||||
return ""
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// CustomDomainGuide 自有域名的解析配置指引,供前端展示。
|
||||
type CustomDomainGuide struct {
|
||||
CnameTarget string `json:"cname_target"`
|
||||
AIPs []string `json:"a_ips"`
|
||||
}
|
||||
|
||||
// GetCustomDomainGuide 读取配置中的 CNAME 目标与 A 记录 IP。
|
||||
func GetCustomDomainGuide() CustomDomainGuide {
|
||||
target, _ := beego.AppConfig.String("customdomain_cname_target")
|
||||
target = NormalizeHost(target)
|
||||
|
||||
ipsRaw, _ := beego.AppConfig.String("customdomain_a_ips")
|
||||
ips := make([]string, 0, 2)
|
||||
for _, item := range strings.Split(ipsRaw, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(item) == nil {
|
||||
continue
|
||||
}
|
||||
ips = append(ips, item)
|
||||
}
|
||||
return CustomDomainGuide{CnameTarget: target, AIPs: ips}
|
||||
}
|
||||
|
||||
// VerifyCustomDomainDNS 检测域名是否已解析到本平台。
|
||||
// 命中任一条件即通过:
|
||||
// 1. CNAME 链最终指向配置的 cname_target(或其本身);
|
||||
// 2. A/AAAA 记录中出现配置的任一 IP;
|
||||
// 3. cname_target 自身解析出的 IP 与域名解析出的 IP 有交集(兼容 CNAME 被 DNS 服务商拍平的情况)。
|
||||
//
|
||||
// 返回 (是否通过, 说明文案)。说明文案会写入 verify_msg 展示给租户。
|
||||
func VerifyCustomDomainDNS(host string) (bool, string) {
|
||||
host = NormalizeHost(host)
|
||||
if host == "" {
|
||||
return false, "域名格式不正确"
|
||||
}
|
||||
|
||||
guide := GetCustomDomainGuide()
|
||||
if guide.CnameTarget == "" && len(guide.AIPs) == 0 {
|
||||
return false, "平台尚未配置解析目标(customdomain_cname_target / customdomain_a_ips),请联系平台管理员"
|
||||
}
|
||||
|
||||
// 1. CNAME 校验
|
||||
if guide.CnameTarget != "" {
|
||||
if cname, err := net.LookupCNAME(host); err == nil {
|
||||
actual := NormalizeHost(cname)
|
||||
if actual != "" && actual != host && actual == guide.CnameTarget {
|
||||
return true, "CNAME 已指向 " + guide.CnameTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2/3. 解析出的 IP 与允许列表或 cname_target 的 IP 比对
|
||||
addrs, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return false, "域名无法解析,请确认解析记录已添加并等待生效:" + trimDNSError(err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return false, "域名未解析到任何 IP,请确认解析记录已添加并等待生效"
|
||||
}
|
||||
|
||||
got := make(map[string]bool, len(addrs))
|
||||
for _, a := range addrs {
|
||||
got[a] = true
|
||||
}
|
||||
|
||||
for _, want := range guide.AIPs {
|
||||
if got[want] {
|
||||
return true, "解析已指向 " + want
|
||||
}
|
||||
}
|
||||
|
||||
if guide.CnameTarget != "" {
|
||||
if targetAddrs, err := net.LookupHost(guide.CnameTarget); err == nil {
|
||||
for _, want := range targetAddrs {
|
||||
if got[want] {
|
||||
return true, "解析已指向平台服务器(" + want + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect := describeExpectedTarget(guide)
|
||||
return false, fmt.Sprintf("当前解析到 %s,与平台地址不一致。%s", strings.Join(addrs, ", "), expect)
|
||||
}
|
||||
|
||||
// describeExpectedTarget 生成「应该怎么配」的提示文案
|
||||
func describeExpectedTarget(guide CustomDomainGuide) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if guide.CnameTarget != "" {
|
||||
parts = append(parts, "子域名请添加 CNAME 记录指向 "+guide.CnameTarget)
|
||||
}
|
||||
if len(guide.AIPs) > 0 {
|
||||
parts = append(parts, "根域名请添加 A 记录指向 "+strings.Join(guide.AIPs, " 或 "))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts, ";") + "。"
|
||||
}
|
||||
|
||||
// trimDNSError 精简 DNS 错误信息,避免把内部解析器地址写进给租户看的文案
|
||||
func trimDNSError(err error) string {
|
||||
msg := err.Error()
|
||||
if idx := strings.LastIndex(msg, ": "); idx >= 0 && idx+2 < len(msg) {
|
||||
msg = msg[idx+2:]
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// TestNormalizeHost 覆盖用户在绑定框里可能输入的各种写法。
|
||||
// 归一化结果直接写进 yz_system_tenant_domain.full_domain,
|
||||
// 而官网渲染是按 Host 精确等值查这张表,所以这里一旦放宽就会出现「绑了但打不开」。
|
||||
func TestNormalizeHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"普通子域名", "www.example.com", "www.example.com"},
|
||||
{"大写转小写", "WWW.Example.COM", "www.example.com"},
|
||||
{"两端空格", " www.example.com ", "www.example.com"},
|
||||
{"带 http 协议", "http://www.example.com", "www.example.com"},
|
||||
{"带 https 与路径", "https://www.example.com/news/1", "www.example.com"},
|
||||
{"带端口", "www.example.com:8080", "www.example.com"},
|
||||
{"带协议端口路径", "https://www.example.com:443/a/b?c=1", "www.example.com"},
|
||||
{"FQDN 末尾点", "www.example.com.", "www.example.com"},
|
||||
{"通配符写法", "*.example.com", "example.com"},
|
||||
{"根域名", "example.com", "example.com"},
|
||||
{"多级子域名", "a.b.c.example.com", "a.b.c.example.com"},
|
||||
{"含连字符", "my-site.example.com", "my-site.example.com"},
|
||||
{"新顶级域", "example.technology", "example.technology"},
|
||||
|
||||
{"空字符串", "", ""},
|
||||
{"纯空格", " ", ""},
|
||||
{"无顶级域", "localhost", ""},
|
||||
{"纯 IP", "1.2.3.4", ""},
|
||||
{"顶级域含数字", "example.c0m", ""},
|
||||
{"标签以连字符开头", "-bad.example.com", ""},
|
||||
{"标签以连字符结尾", "bad-.example.com", ""},
|
||||
{"含下划线", "bad_name.example.com", ""},
|
||||
{"连续点", "www..example.com", ""},
|
||||
{"以点开头", ".example.com", ""},
|
||||
{"含空格", "www .example.com", ""},
|
||||
{"路径穿越写法", "../../etc/passwd", ""},
|
||||
{"单标签顶级域太短", "example.c", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := NormalizeHost(c.in); got != c.want {
|
||||
t.Errorf("NormalizeHost(%q) = %q, 期望 %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeHostTooLong 超过 253 字节的主机名不合法,必须拒绝。
|
||||
func TestNormalizeHostTooLong(t *testing.T) {
|
||||
long := strings.Repeat("a.", 130) + "com" // 远超 253
|
||||
if got := NormalizeHost(long); got != "" {
|
||||
t.Errorf("超长域名应被拒绝,实际返回 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertPathsStaysInsideCertDir 证书目录名来自数据库里的域名,
|
||||
// 必须保证拼出来的路径落在 ssl_cert_dir 内,不能被 ../ 之类的脏数据带出去。
|
||||
func TestCertPathsStaysInsideCertDir(t *testing.T) {
|
||||
root := filepath.Clean(SSLCertDir())
|
||||
|
||||
for _, host := range []string{
|
||||
"www.example.com",
|
||||
"../../etc/ssl",
|
||||
"..",
|
||||
"",
|
||||
"/absolute/path",
|
||||
} {
|
||||
certPath, keyPath := CertPaths(host)
|
||||
for _, p := range []string{certPath, keyPath} {
|
||||
cleaned := filepath.Clean(p)
|
||||
if !strings.HasPrefix(cleaned, root+string(filepath.Separator)) {
|
||||
t.Errorf("host=%q 生成的路径 %q 逃出了证书目录 %q", host, cleaned, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertPathsFileNames 落盘文件名必须与 Nginx 配置里引用的名字一致。
|
||||
func TestCertPathsFileNames(t *testing.T) {
|
||||
certPath, keyPath := CertPaths("www.example.com")
|
||||
if filepath.Base(certPath) != "fullchain.pem" {
|
||||
t.Errorf("证书文件名应为 fullchain.pem,实际 %q", filepath.Base(certPath))
|
||||
}
|
||||
if filepath.Base(keyPath) != "privkey.pem" {
|
||||
t.Errorf("私钥文件名应为 privkey.pem,实际 %q", filepath.Base(keyPath))
|
||||
}
|
||||
if filepath.Base(filepath.Dir(certPath)) != "www.example.com" {
|
||||
t.Errorf("证书应放在以域名命名的目录下,实际 %q", filepath.Dir(certPath))
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyCustomDomainDNSWithoutConfig 平台没配解析目标时,
|
||||
// 不能去打 DNS 也不能误判通过,要直接给出「联系管理员」的提示。
|
||||
func TestVerifyCustomDomainDNSWithoutConfig(t *testing.T) {
|
||||
// 测试环境没有加载 app.conf,GetCustomDomainGuide 返回空配置
|
||||
guide := GetCustomDomainGuide()
|
||||
if guide.CnameTarget != "" || len(guide.AIPs) > 0 {
|
||||
t.Skip("当前环境读到了解析目标配置,跳过空配置分支")
|
||||
}
|
||||
|
||||
passed, msg := VerifyCustomDomainDNS("www.example.com")
|
||||
if passed {
|
||||
t.Error("平台未配置解析目标时不应判定通过")
|
||||
}
|
||||
if !strings.Contains(msg, "平台管理员") {
|
||||
t.Errorf("提示文案应引导联系平台管理员,实际 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyCustomDomainDNSRejectsBadHost 非法域名在检测入口就要拦住。
|
||||
func TestVerifyCustomDomainDNSRejectsBadHost(t *testing.T) {
|
||||
passed, msg := VerifyCustomDomainDNS("not a domain")
|
||||
if passed {
|
||||
t.Error("非法域名不应判定通过")
|
||||
}
|
||||
if msg == "" {
|
||||
t.Error("非法域名应给出说明文案")
|
||||
}
|
||||
}
|
||||
|
||||
// withTempCertDir 把 ssl_cert_dir 指到临时目录。
|
||||
// 默认值是 /www/wwwroot/ssl-certs,在 Windows 上会落到当前盘根目录,
|
||||
// 跑测试不该在仓库外面留文件。
|
||||
func withTempCertDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old, _ := beego.AppConfig.String("ssl_cert_dir")
|
||||
if err := beego.AppConfig.Set("ssl_cert_dir", dir); err != nil {
|
||||
t.Skipf("当前环境不支持运行时改配置: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = beego.AppConfig.Set("ssl_cert_dir", old)
|
||||
})
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestChallengeTokenValidation token 会被拼进文件名,
|
||||
// 必须先过正则;否则 `../../` 这类 token 能让 Go 读写证书目录外的文件。
|
||||
func TestChallengeTokenValidation(t *testing.T) {
|
||||
// 合法:base64url,长度 16-128
|
||||
valid := []string{
|
||||
strings.Repeat("a", 16),
|
||||
strings.Repeat("a", 128),
|
||||
"abcABC012_-abcABC012_-",
|
||||
}
|
||||
for _, tok := range valid {
|
||||
if !challengeTokenRe.MatchString(tok) {
|
||||
t.Errorf("token %q 应被接受", tok)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
"short",
|
||||
strings.Repeat("a", 129),
|
||||
"../../etc/passwd",
|
||||
"has/slash1234567890",
|
||||
"has\\backslash12345678",
|
||||
"has.dot1234567890",
|
||||
"has space1234567890",
|
||||
}
|
||||
for _, tok := range invalid {
|
||||
if challengeTokenRe.MatchString(tok) {
|
||||
t.Errorf("token %q 应被拒绝", tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetHTTP01ChallengeRejectsBadToken 未命中内存时,
|
||||
// 非法 token 不能进到读文件那一步。
|
||||
func TestGetHTTP01ChallengeRejectsBadToken(t *testing.T) {
|
||||
if _, ok := GetHTTP01Challenge("../../etc/passwd"); ok {
|
||||
t.Error("非法 token 不应返回内容")
|
||||
}
|
||||
if _, ok := GetHTTP01Challenge(""); ok {
|
||||
t.Error("空 token 不应返回内容")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChallengeStoreRoundTrip 内存与磁盘两层的存取与删除。
|
||||
// 磁盘那层是给「签发进程」与「应答 CA 回访的进程」不是同一个的部署方式兜底的。
|
||||
func TestChallengeStoreRoundTrip(t *testing.T) {
|
||||
dir := withTempCertDir(t)
|
||||
|
||||
token := strings.Repeat("t", 32)
|
||||
keyAuth := token + ".someThumbprint"
|
||||
|
||||
putHTTP01Challenge(token, keyAuth)
|
||||
t.Cleanup(func() { deleteHTTP01Challenge(token) })
|
||||
|
||||
got, ok := GetHTTP01Challenge(token)
|
||||
if !ok || got != keyAuth {
|
||||
t.Fatalf("取回的 keyAuth = %q ok=%v,期望 %q true", got, ok, keyAuth)
|
||||
}
|
||||
|
||||
// 磁盘兜底:清掉内存后仍要能读到
|
||||
challengeMu.Lock()
|
||||
delete(challengeStore, token)
|
||||
challengeMu.Unlock()
|
||||
|
||||
fromDisk, ok := GetHTTP01Challenge(token)
|
||||
if !ok || fromDisk != keyAuth {
|
||||
t.Errorf("应能从磁盘读回 keyAuth,实际 %q ok=%v(目录 %s)", fromDisk, ok, dir)
|
||||
}
|
||||
|
||||
deleteHTTP01Challenge(token)
|
||||
if _, ok := GetHTTP01Challenge(token); ok {
|
||||
t.Error("删除后内存与磁盘都不应再返回内容")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryLockIssueSerializes 同一域名不能并发签发,
|
||||
// 否则会向 CA 重复下单,白白消耗频控额度。
|
||||
func TestTryLockIssueSerializes(t *testing.T) {
|
||||
host := "lock-test.example.com"
|
||||
// 清掉可能残留的状态
|
||||
t.Cleanup(func() { unlockIssue(host, false) })
|
||||
|
||||
ok, _ := tryLockIssue(host)
|
||||
if !ok {
|
||||
t.Fatal("首次抢锁应成功")
|
||||
}
|
||||
|
||||
ok2, reason := tryLockIssue(host)
|
||||
if ok2 {
|
||||
t.Error("已在签发中时不应再次拿到锁")
|
||||
}
|
||||
if reason == "" {
|
||||
t.Error("拒绝时应返回原因")
|
||||
}
|
||||
|
||||
unlockIssue(host, false)
|
||||
if ok3, _ := tryLockIssue(host); !ok3 {
|
||||
t.Error("解锁后应能重新抢到")
|
||||
}
|
||||
unlockIssue(host, false)
|
||||
}
|
||||
|
||||
// TestTryLockIssueCooldown 失败后进入冷却期,避免租户连点把 CA 额度打满。
|
||||
func TestTryLockIssueCooldown(t *testing.T) {
|
||||
host := "cooldown-test.example.com"
|
||||
t.Cleanup(func() { unlockIssue(host, false) })
|
||||
|
||||
ok, _ := tryLockIssue(host)
|
||||
if !ok {
|
||||
t.Fatal("首次抢锁应成功")
|
||||
}
|
||||
unlockIssue(host, true) // 标记失败
|
||||
|
||||
ok2, reason := tryLockIssue(host)
|
||||
if ok2 {
|
||||
t.Error("冷却期内不应放行")
|
||||
}
|
||||
if !strings.Contains(reason, "分钟") {
|
||||
t.Errorf("冷却提示应包含等待时间,实际 %q", reason)
|
||||
}
|
||||
|
||||
// 成功一次要把冷却清掉
|
||||
unlockIssue(host, false)
|
||||
if ok3, reason3 := tryLockIssue(host); !ok3 {
|
||||
t.Errorf("成功后应清除冷却,实际被拒: %s", reason3)
|
||||
}
|
||||
unlockIssue(host, false)
|
||||
}
|
||||
|
||||
// TestIssueCertificateAsyncRejectsBadHost 非法域名不该发起签发流程。
|
||||
func TestIssueCertificateAsyncRejectsBadHost(t *testing.T) {
|
||||
if ok, _ := IssueCertificateAsync(0, "not a domain"); ok {
|
||||
t.Error("非法域名不应发起签发")
|
||||
}
|
||||
if ok, _ := IssueCertificateAsync(0, ""); ok {
|
||||
t.Error("空域名不应发起签发")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCustomDomainGuideParsing 配置里的 A 记录是逗号分隔的自由文本,
|
||||
// 非法 IP 必须被丢掉,否则会把错误的地址当成期望值展示给租户。
|
||||
func TestGetCustomDomainGuideParsing(t *testing.T) {
|
||||
restore := func(key, val string) {
|
||||
if err := beego.AppConfig.Set(key, val); err != nil {
|
||||
t.Fatalf("恢复配置 %s 失败: %v", key, err)
|
||||
}
|
||||
}
|
||||
oldTarget, _ := beego.AppConfig.String("customdomain_cname_target")
|
||||
oldIPs, _ := beego.AppConfig.String("customdomain_a_ips")
|
||||
t.Cleanup(func() {
|
||||
restore("customdomain_cname_target", oldTarget)
|
||||
restore("customdomain_a_ips", oldIPs)
|
||||
})
|
||||
|
||||
if err := beego.AppConfig.Set("customdomain_cname_target", " Sites.Example.COM. "); err != nil {
|
||||
t.Skipf("当前环境不支持运行时改配置: %v", err)
|
||||
}
|
||||
if err := beego.AppConfig.Set("customdomain_a_ips", "1.2.3.4, , not-an-ip ,5.6.7.8,999.1.1.1"); err != nil {
|
||||
t.Skipf("当前环境不支持运行时改配置: %v", err)
|
||||
}
|
||||
|
||||
guide := GetCustomDomainGuide()
|
||||
if guide.CnameTarget != "sites.example.com" {
|
||||
t.Errorf("CNAME 目标应被归一化为 sites.example.com,实际 %q", guide.CnameTarget)
|
||||
}
|
||||
want := []string{"1.2.3.4", "5.6.7.8"}
|
||||
if len(guide.AIPs) != len(want) {
|
||||
t.Fatalf("应只保留 %d 个合法 IP,实际 %v", len(want), guide.AIPs)
|
||||
}
|
||||
for i := range want {
|
||||
if guide.AIPs[i] != want[i] {
|
||||
t.Errorf("第 %d 个 IP = %q,期望 %q", i, guide.AIPs[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user