增加自定义绑定域名

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
+22 -1
View File
@@ -30,4 +30,25 @@ orm = mysql
# StaticDir = /static:../front/dist
# 映射 /uploads 路径到上传文件目录(项目根目录下的 uploads 文件夹)
StaticDir = /uploads:../uploads
StaticDir = /uploads:../uploads
# ==================== 租户自有域名绑定 / 自动 HTTPS ====================
# 租户把自己的子域名 CNAME 到这个主机名(该主机名需先 A 记录指向本服务器)
# 例:customdomain_cname_target = sites.yunzer.com.cn
customdomain_cname_target =
# 允许的 A 记录目标 IP(多个用英文逗号分隔)。根域通常不能用 CNAME,只能 A 记录。
# 例:customdomain_a_ips = 1.2.3.4,5.6.7.8
customdomain_a_ips =
# Let's Encrypt 账号邮箱(证书到期提醒、账号注册用)
acme_email =
# ACME 目录地址。留空使用 Let's Encrypt 正式环境。
# 联调时建议先填 staging 避免触发正式环境频控:
# acme_directory = https://acme-staging-v02.api.letsencrypt.org/directory
acme_directory =
# 证书落盘根目录。签发后生成 {ssl_cert_dir}/{域名}/fullchain.pem 与 privkey.pem,
# Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。
ssl_cert_dir = /www/wwwroot/ssl-certs
+35
View File
@@ -0,0 +1,35 @@
package controllers
import (
"strings"
"server/services"
beego "github.com/beego/beego/v2/server/web"
)
// AcmeChallengeController 处理 Let's Encrypt 的 HTTP-01 验证回访。
//
// 路由:GET /.well-known/acme-challenge/:token
//
// 该路由在 routers/router.go 中无条件注册,不受 APP_MODE 影响——证书颁发机构
// 什么时候来验证与本服务以哪种模式启动无关。Nginx 扑底站点必须把这个路径按
// 明文 HTTP 反代过来,不能 301 跳转到 HTTPS(首次签发时还没有证书)。
type AcmeChallengeController struct {
beego.Controller
}
// Serve 返回 token 对应的 keyAuthorization(纯文本)
func (c *AcmeChallengeController) Serve() {
token := strings.TrimSpace(c.Ctx.Input.Param(":token"))
keyAuth, ok := services.GetHTTP01Challenge(token)
if !ok {
c.Ctx.Output.SetStatus(404)
_ = c.Ctx.Output.Body([]byte("not found"))
return
}
c.Ctx.Output.Header("Content-Type", "text/plain; charset=utf-8")
// challenge 是一次性的,不能被任何中间层缓存
c.Ctx.Output.Header("Cache-Control", "no-store")
_ = c.Ctx.Output.Body([]byte(keyAuth))
}
+141 -31
View File
@@ -323,20 +323,31 @@ func (c *BackendTenantDomainController) Index() {
tid, _ := c.GetUint64("tid")
statusStr := strings.TrimSpace(c.GetString("status"))
subDomain := strings.TrimSpace(c.GetString("sub_domain"))
keyword := strings.TrimSpace(c.GetString("sub_domain"))
domainTypeStr := strings.TrimSpace(c.GetString("domain_type"))
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("delete_time__isnull", true)
// 条件一次性拼好:SetCond 会覆盖 Filter 累积的条件,不能混用
cond := orm.NewCondition().And("delete_time__isnull", true)
if tid > 0 {
qs = qs.Filter("tid", tid)
cond = cond.And("tid", tid)
}
if statusStr != "" {
if st, err := strconv.Atoi(statusStr); err == nil {
qs = qs.Filter("status", st)
cond = cond.And("status", st)
}
}
if subDomain != "" {
qs = qs.Filter("sub_domain__icontains", subDomain)
if domainTypeStr != "" {
if dt, err := strconv.Atoi(domainTypeStr); err == nil {
cond = cond.And("domain_type", dt)
}
}
// 关键字同时匹配二级前缀与完整域名:自有域名没有 sub_domain,只能靠 full_domain 命中
if keyword != "" {
cond = cond.AndCond(orm.NewCondition().
Or("sub_domain__icontains", keyword).
Or("full_domain__icontains", keyword))
}
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).SetCond(cond)
total, err := qs.Count()
if err != nil {
@@ -349,8 +360,7 @@ func (c *BackendTenantDomainController) Index() {
jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error())
return
}
list := make([]models.SystemTenantDomain, 0, len(rows))
list = append(list, rows...)
list := tenantDomainList(rows)
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
@@ -359,20 +369,21 @@ func (c *BackendTenantDomainController) Index() {
_ = c.ServeJSON()
}
// MyDomains GET /backend/domain/tenant/myDomains?tid=1
// MyDomains GET /backend/domain/tenant/myDomains
// 租户 ID 取登录态,忽略请求参数里的 tid,避免越权查看其他租户域名。
func (c *BackendTenantDomainController) MyDomains() {
if _, err := requireBackend(&c.Controller); err != nil {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid, _ := c.GetUint64("tid")
if tid == 0 {
jsonErr(&c.Controller, 400, 400, "租户ID不能为空")
if claims.TenantId <= 0 {
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
return
}
var rows []models.SystemTenantDomain
_, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("tid", tid).
_, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("tid", claims.TenantId).
Filter("delete_time__isnull", true).
OrderBy("-id").
All(&rows)
@@ -380,23 +391,101 @@ func (c *BackendTenantDomainController) MyDomains() {
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": rows}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": tenantDomainList(rows)}
_ = c.ServeJSON()
}
// Apply POST /backend/domain/tenant/apply body:{tid,sub_domain,main_domain}
func (c *BackendTenantDomainController) Apply() {
// BindCustom POST /backend/domain/tenant/bindCustom body:{domain}
// 绑定租户自有域名。租户 ID 取登录态。
func (c *BackendTenantDomainController) BindCustom() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
if claims.TenantId <= 0 {
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
var p customDomainPayload
if err := json.Unmarshal(raw, &p); err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
id, status, errMsg := bindCustomDomain(uint64(claims.TenantId), p.Domain)
if errMsg != "" {
jsonErr(&c.Controller, status, status, errMsg)
return
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "绑定成功,请按提示配置解析后点击「检测解析」",
"data": map[string]interface{}{"id": id},
}
_ = c.ServeJSON()
}
// CheckCustomDns POST /backend/domain/tenant/checkCustomDns body:{id}
// 检测自有域名解析,通过后异步申请 HTTPS 证书。
func (c *BackendTenantDomainController) CheckCustomDns() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
if claims.TenantId <= 0 {
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
var p domainIDPayload
if err := json.Unmarshal(raw, &p); err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
data, status, errMsg := checkCustomDomainDNS(p.ID, uint64(claims.TenantId))
if errMsg != "" {
jsonErr(&c.Controller, status, status, errMsg)
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// CustomDomainGuide GET /backend/domain/tenant/customDomainGuide
// 返回租户该怎么配解析(CNAME 目标 / A 记录 IP),前端不硬编码。
func (c *BackendTenantDomainController) CustomDomainGuide() {
if _, err := requireBackend(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": customDomainGuideData()}
_ = c.ServeJSON()
}
// Apply POST /backend/domain/tenant/apply body:{sub_domain,main_domain}
// 租户 ID 取登录态,不信请求体,避免替其他租户申请。
func (c *BackendTenantDomainController) Apply() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
var p struct {
Tid uint64 `json:"tid"`
SubDomain string `json:"sub_domain"`
MainDomain string `json:"main_domain"`
}
@@ -404,10 +493,11 @@ func (c *BackendTenantDomainController) Apply() {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.Tid == 0 {
jsonErr(&c.Controller, 400, 400, "租户ID不能为空")
if claims.TenantId <= 0 {
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
return
}
tenantID := uint64(claims.TenantId)
sub := strings.TrimSpace(p.SubDomain)
main := strings.TrimSpace(p.MainDomain)
if sub == "" {
@@ -423,13 +513,14 @@ func (c *BackendTenantDomainController) Apply() {
return
}
// 该租户是否已有域名
// 该租户是否已申请过平台二级域名(自有域名单独计数,两者可以共存)
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("tid", p.Tid).
Filter("tid", tenantID).
Filter("domain_type", models.DomainTypePlatformSub).
Filter("delete_time__isnull", true).
Count()
if cnt > 0 {
jsonErr(&c.Controller, 400, 400, "该租户已有域名,请删除后再次申请")
jsonErr(&c.Controller, 400, 400, "该租户已有二级域名,请删除后再次申请")
return
}
@@ -457,9 +548,10 @@ func (c *BackendTenantDomainController) Apply() {
full := sub + "." + main
now := time.Now()
tid := p.Tid
tid := tenantID
row := &models.SystemTenantDomain{
Tid: &tid,
DomainType: models.DomainTypePlatformSub,
SubDomain: &sub,
MainDomain: &main,
FullDomain: &full,
@@ -500,6 +592,10 @@ func (c *BackendTenantDomainController) Audit() {
jsonErr(&c.Controller, 404, 404, "域名不存在")
return
}
if row.DomainType == models.DomainTypeCustom {
jsonErr(&c.Controller, 400, 400, "自有域名无需人工审核,解析检测与证书签发通过后自动生效")
return
}
if row.Status != 0 {
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
return
@@ -547,11 +643,20 @@ func (c *BackendTenantDomainController) ToggleStatus() {
return
}
if row.Status == 0 {
jsonErr(&c.Controller, 400, 400, "审核中不可操作")
if row.DomainType == models.DomainTypeCustom {
jsonErr(&c.Controller, 400, 400, "该域名尚未生效(解析或证书未就绪),无需禁用")
} else {
jsonErr(&c.Controller, 400, 400, "审核中不可操作")
}
return
}
newStatus := 2
if row.Status == 2 {
// 自有域名重新启用要求证书还在,否则启用后访问会报证书错误
if row.DomainType == models.DomainTypeCustom && row.SSLStatus != models.SSLStatusIssued {
jsonErr(&c.Controller, 400, 400, "该域名的 HTTPS 证书未就绪,请先检测解析并等待证书签发")
return
}
newStatus = 1
}
now := time.Now()
@@ -568,8 +673,10 @@ func (c *BackendTenantDomainController) ToggleStatus() {
}
// Delete DELETE /backend/domain/tenant/delete/:id
// 只能删除本租户的域名记录。
func (c *BackendTenantDomainController) Delete() {
if _, err := requireBackend(&c.Controller); err != nil {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
@@ -579,11 +686,14 @@ func (c *BackendTenantDomainController) Delete() {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
now := time.Now()
n, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("id", id).
Filter("delete_time__isnull", true).
Update(map[string]interface{}{"delete_time": now, "update_time": now})
Filter("delete_time__isnull", true)
if claims.TenantId > 0 {
qs = qs.Filter("tid", claims.TenantId)
}
now := time.Now()
n, err := qs.Update(map[string]interface{}{"delete_time": now, "update_time": now})
if err != nil {
jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
return
+280
View File
@@ -2,6 +2,11 @@ package controllers
import (
"regexp"
"strings"
"time"
"server/models"
"server/services"
beego "github.com/beego/beego/v2/server/web"
)
@@ -19,3 +24,278 @@ type domainPoolPayload struct {
}
var subDomainRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`)
// customDomainPayload 绑定自有域名的请求体。
// tid 仅平台端(运营代操作)使用;租户端一律以登录态里的租户 ID 为准。
type customDomainPayload struct {
Tid uint64 `json:"tid"`
Domain string `json:"domain"`
}
// domainIDPayload 仅带域名记录 ID 的请求体
type domainIDPayload struct {
ID uint64 `json:"id"`
}
// ===== 列表输出 =====
// tenantDomainDTO 把域名记录转成前端需要的结构。
// 直接把 ORM 结构体丢给前端会让时间字段变成 RFC3339,且拿不到租户名称,
// 因此列表统一走这里:时间格式化,租户名按 tid 批量补齐。
func tenantDomainDTO(row models.SystemTenantDomain, tenantNames map[uint64]string) map[string]interface{} {
item := map[string]interface{}{
"id": row.ID,
"tid": row.Tid,
"domain_type": row.DomainType,
"sub_domain": strPtrVal(row.SubDomain),
"main_domain": strPtrVal(row.MainDomain),
"full_domain": strPtrVal(row.FullDomain),
"status": row.Status,
"ssl_status": row.SSLStatus,
"ssl_error": strPtrVal(row.SSLError),
"verify_status": row.VerifyStatus,
"verify_msg": strPtrVal(row.VerifyMsg),
"tenant_name": "",
"ssl_expire_at": "",
"last_verify_at": "",
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
}
if row.Tid != nil && tenantNames != nil {
item["tenant_name"] = tenantNames[*row.Tid]
}
if row.SSLExpireAt != nil {
item["ssl_expire_at"] = row.SSLExpireAt.Format("2006-01-02 15:04:05")
}
if row.LastVerifyAt != nil {
item["last_verify_at"] = row.LastVerifyAt.Format("2006-01-02 15:04:05")
}
return item
}
// tenantDomainList 批量转换,顺带一次性查出涉及的租户名称
func tenantDomainList(rows []models.SystemTenantDomain) []map[string]interface{} {
tenantNames := lookupTenantNames(rows)
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, tenantDomainDTO(rows[i], tenantNames))
}
return list
}
// lookupTenantNames 按 tid 查租户名称,避免列表里逐行查库
func lookupTenantNames(rows []models.SystemTenantDomain) map[uint64]string {
ids := make([]uint64, 0, len(rows))
seen := make(map[uint64]bool, len(rows))
for i := range rows {
if rows[i].Tid == nil || *rows[i].Tid == 0 || seen[*rows[i].Tid] {
continue
}
seen[*rows[i].Tid] = true
ids = append(ids, *rows[i].Tid)
}
names := make(map[uint64]string, len(ids))
if len(ids) == 0 {
return names
}
var tenants []models.SystemTenant
if _, err := models.Orm.QueryTable(new(models.SystemTenant)).
Filter("id__in", ids).
All(&tenants, "ID", "TenantName", "TenantShortName"); err != nil {
return names
}
for i := range tenants {
name := strings.TrimSpace(tenants[i].TenantName)
if name == "" && tenants[i].TenantShortName != nil {
name = strings.TrimSpace(*tenants[i].TenantShortName)
}
names[tenants[i].ID] = name
}
return names
}
func strPtrVal(p *string) string {
if p == nil {
return ""
}
return *p
}
// ===== 以下逻辑被 backend / platform 两套控制器共用 =====
// bindCustomDomain 为租户绑定自有域名。
// 返回 (记录ID, HTTP状态码, 错误信息);错误信息为空表示成功。
func bindCustomDomain(tid uint64, rawDomain string) (uint64, int, string) {
if tid == 0 {
return 0, 400, "租户ID不能为空"
}
host := services.NormalizeHost(rawDomain)
if host == "" {
return 0, 400, "域名格式不正确,请填写完整域名(如 www.example.com)"
}
// 通配符只在平台域名池里有意义:自有域名一条记录对应一个精确 Host,
// 悄悄把 *.example.com 收成 example.com 会绑到用户没想绑的域名上。
if strings.HasPrefix(strings.TrimSpace(rawDomain), "*.") {
return 0, 400, "暂不支持通配符域名,请填写要绑定的具体域名(如 www.example.com)"
}
// 租户必须存在且未停用(平台端可以传任意 tid,这里兜住手误)
tenantOK, _ := models.Orm.QueryTable(new(models.SystemTenant)).
Filter("id", tid).
Filter("delete_time__isnull", true).
Count()
if tenantOK == 0 {
return 0, 400, "租户不存在"
}
// 不允许绑定平台自己的域名:平台主域及其任意子域都由域名池统一发放
msg, err := checkNotPlatformDomain(host)
if err != nil {
return 0, 500, "域名校验失败,请稍后重试"
}
if msg != "" {
return 0, 400, msg
}
// 全局唯一:同一个域名只能指向一个租户官网
used, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("full_domain", host).
Filter("delete_time__isnull", true).
Count()
if used > 0 {
return 0, 400, "该域名已被绑定"
}
// 每个租户一个自有域名(平台二级域名不受此限制,两者可以同时存在)
exists, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("tid", tid).
Filter("domain_type", models.DomainTypeCustom).
Filter("delete_time__isnull", true).
Count()
if exists > 0 {
return 0, 400, "该租户已绑定自有域名,请先删除后再绑定新域名"
}
now := time.Now()
tidCopy := tid
row := &models.SystemTenantDomain{
Tid: &tidCopy,
DomainType: models.DomainTypeCustom,
FullDomain: &host,
Status: 0, // DNS 检测通过且证书签发成功后自动置 1
SSLStatus: models.SSLStatusPending,
VerifyStatus: models.VerifyStatusNone,
CreateTime: now,
UpdateTime: &now,
}
id, err := models.Orm.Insert(row)
if err != nil {
return 0, 500, "绑定失败: " + err.Error()
}
return uint64(id), 200, ""
}
// checkNotPlatformDomain 校验域名不属于平台域名池(含子域)。
// 平台域名的解析与证书由平台统一维护,租户只能通过「申请二级域名」获得。
// 返回 (拒绝原因, 查询错误)。查询出错时必须由调用方拒绝,
// 否则一次数据库抖动就能让租户把平台主域绑走。
func checkNotPlatformDomain(host string) (string, error) {
var pools []models.SystemDomainPool
if _, err := models.Orm.QueryTable(new(models.SystemDomainPool)).
Filter("delete_time__isnull", true).
All(&pools); err != nil {
return "", err
}
for i := range pools {
main := services.NormalizeHost(pools[i].MainDomain)
if main == "" {
continue
}
if host == main || strings.HasSuffix(host, "."+main) {
return "该域名属于平台域名 " + main + ",请改用「申请二级域名」功能", nil
}
}
return "", nil
}
// checkCustomDomainDNS 检测自有域名解析,通过则异步触发证书签发。
// tid 传 0 表示不限制归属(平台端运营场景)。
// 返回 (给前端的数据, HTTP状态码, 错误信息)。
func checkCustomDomainDNS(id, tid uint64) (map[string]interface{}, int, string) {
if id == 0 {
return nil, 400, "参数错误"
}
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("id", id).
Filter("delete_time__isnull", true)
if tid > 0 {
qs = qs.Filter("tid", tid)
}
var row models.SystemTenantDomain
if err := qs.One(&row); err != nil {
return nil, 404, "域名不存在"
}
if row.DomainType != models.DomainTypeCustom {
return nil, 400, "平台二级域名无需检测解析"
}
if row.FullDomain == nil || *row.FullDomain == "" {
return nil, 400, "域名数据异常,请删除后重新绑定"
}
host := *row.FullDomain
passed, msg := services.VerifyCustomDomainDNS(host)
// verify_msg 字段 varchar(500),解析出的 IP 列表可能很长,截断避免写库失败
if len(msg) > 480 {
msg = msg[:480]
}
verifyStatus := models.VerifyStatusFailed
if passed {
verifyStatus = models.VerifyStatusPassed
}
now := time.Now()
if _, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("id", id).
Update(map[string]interface{}{
"verify_status": verifyStatus,
"verify_msg": msg,
"last_verify_at": now,
"update_time": now,
}); err != nil {
return nil, 500, "保存检测结果失败: " + err.Error()
}
data := map[string]interface{}{
"verify_status": verifyStatus,
"verify_msg": msg,
"ssl_status": row.SSLStatus,
}
if !passed {
return data, 200, ""
}
// 解析已通过:证书没签好就发起签发
if row.SSLStatus == models.SSLStatusIssued {
data["ssl_message"] = "解析正常,证书已就绪"
return data, 200, ""
}
if ok, reason := services.IssueCertificateAsync(id, host); ok {
data["ssl_status"] = models.SSLStatusIssuing
data["ssl_message"] = "解析已通过,正在申请 HTTPS 证书,请稍后刷新查看"
} else {
data["ssl_message"] = reason
}
return data, 200, ""
}
// customDomainGuideData 返回租户配置解析所需的信息
func customDomainGuideData() map[string]interface{} {
guide := services.GetCustomDomainGuide()
return map[string]interface{}{
"cname_target": guide.CnameTarget,
"a_ips": guide.AIPs,
"configured": guide.CnameTarget != "" || len(guide.AIPs) > 0,
}
}
+104 -12
View File
@@ -323,20 +323,31 @@ func (c *PlatformTenantDomainController) Index() {
tid, _ := c.GetUint64("tid")
statusStr := strings.TrimSpace(c.GetString("status"))
subDomain := strings.TrimSpace(c.GetString("sub_domain"))
keyword := strings.TrimSpace(c.GetString("sub_domain"))
domainTypeStr := strings.TrimSpace(c.GetString("domain_type"))
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("delete_time__isnull", true)
// 条件一次性拼好:SetCond 会覆盖 Filter 累积的条件,不能混用
cond := orm.NewCondition().And("delete_time__isnull", true)
if tid > 0 {
qs = qs.Filter("tid", tid)
cond = cond.And("tid", tid)
}
if statusStr != "" {
if st, err := strconv.Atoi(statusStr); err == nil {
qs = qs.Filter("status", st)
cond = cond.And("status", st)
}
}
if subDomain != "" {
qs = qs.Filter("sub_domain__icontains", subDomain)
if domainTypeStr != "" {
if dt, err := strconv.Atoi(domainTypeStr); err == nil {
cond = cond.And("domain_type", dt)
}
}
// 关键字同时匹配二级前缀与完整域名:自有域名没有 sub_domain,只能靠 full_domain 命中
if keyword != "" {
cond = cond.AndCond(orm.NewCondition().
Or("sub_domain__icontains", keyword).
Or("full_domain__icontains", keyword))
}
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).SetCond(cond)
total, err := qs.Count()
if err != nil {
@@ -349,8 +360,7 @@ func (c *PlatformTenantDomainController) Index() {
jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error())
return
}
list := make([]models.SystemTenantDomain, 0, len(rows))
list = append(list, rows...)
list := tenantDomainList(rows)
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
@@ -360,6 +370,7 @@ func (c *PlatformTenantDomainController) Index() {
}
// MyDomains GET /platform/domain/tenant/myDomains?tid=1
// 平台端账号不归属租户,tid 必须显式传入(运营代租户查看)。
func (c *PlatformTenantDomainController) MyDomains() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
@@ -380,7 +391,73 @@ func (c *PlatformTenantDomainController) MyDomains() {
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": rows}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": tenantDomainList(rows)}
_ = c.ServeJSON()
}
// BindCustom POST /platform/domain/tenant/bindCustom body:{tid,domain}
// 平台端代租户绑定自有域名,tid 必传。
func (c *PlatformTenantDomainController) BindCustom() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
var p customDomainPayload
if err := json.Unmarshal(raw, &p); err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
id, status, errMsg := bindCustomDomain(p.Tid, p.Domain)
if errMsg != "" {
jsonErr(&c.Controller, status, status, errMsg)
return
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "绑定成功,请按提示配置解析后点击「检测解析」",
"data": map[string]interface{}{"id": id},
}
_ = c.ServeJSON()
}
// CheckCustomDns POST /platform/domain/tenant/checkCustomDns body:{id}
// 平台端不限制域名归属,可为任意租户触发检测与签发。
func (c *PlatformTenantDomainController) CheckCustomDns() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
var p domainIDPayload
if err := json.Unmarshal(raw, &p); err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
data, status, errMsg := checkCustomDomainDNS(p.ID, 0)
if errMsg != "" {
jsonErr(&c.Controller, status, status, errMsg)
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// CustomDomainGuide GET /platform/domain/tenant/customDomainGuide
func (c *PlatformTenantDomainController) CustomDomainGuide() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": customDomainGuideData()}
_ = c.ServeJSON()
}
@@ -423,13 +500,14 @@ func (c *PlatformTenantDomainController) Apply() {
return
}
// 该租户是否已有域名
// 该租户是否已申请过平台二级域名(自有域名单独计数,两者可以共存)
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("tid", p.Tid).
Filter("domain_type", models.DomainTypePlatformSub).
Filter("delete_time__isnull", true).
Count()
if cnt > 0 {
jsonErr(&c.Controller, 400, 400, "该租户已有域名,请删除后再次申请")
jsonErr(&c.Controller, 400, 400, "该租户已有二级域名,请删除后再次申请")
return
}
@@ -460,6 +538,7 @@ func (c *PlatformTenantDomainController) Apply() {
tid := p.Tid
row := &models.SystemTenantDomain{
Tid: &tid,
DomainType: models.DomainTypePlatformSub,
SubDomain: &sub,
MainDomain: &main,
FullDomain: &full,
@@ -500,6 +579,10 @@ func (c *PlatformTenantDomainController) Audit() {
jsonErr(&c.Controller, 404, 404, "域名不存在")
return
}
if row.DomainType == models.DomainTypeCustom {
jsonErr(&c.Controller, 400, 400, "自有域名无需人工审核,解析检测与证书签发通过后自动生效")
return
}
if row.Status != 0 {
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
return
@@ -547,11 +630,20 @@ func (c *PlatformTenantDomainController) ToggleStatus() {
return
}
if row.Status == 0 {
jsonErr(&c.Controller, 400, 400, "审核中不可操作")
if row.DomainType == models.DomainTypeCustom {
jsonErr(&c.Controller, 400, 400, "该域名尚未生效(解析或证书未就绪),无需禁用")
} else {
jsonErr(&c.Controller, 400, 400, "审核中不可操作")
}
return
}
newStatus := 2
if row.Status == 2 {
// 自有域名重新启用要求证书还在,否则启用后访问会报证书错误
if row.DomainType == models.DomainTypeCustom && row.SSLStatus != models.SSLStatusIssued {
jsonErr(&c.Controller, 400, 400, "该域名的 HTTPS 证书未就绪,请先检测解析并等待证书签发")
return
}
newStatus = 1
}
now := time.Now()
+1 -1
View File
@@ -6,7 +6,7 @@ require (
github.com/beego/beego/v2 v2.1.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/qiniu/go-sdk/v7 v7.18.2
golang.org/x/crypto v0.1.0 // indirect
golang.org/x/crypto v0.1.0
)
require (
+4 -1
View File
@@ -2,8 +2,8 @@ package main
import (
"server/models"
"server/services"
_ "server/routers"
"server/services"
"server/version"
beego "github.com/beego/beego/v2/server/web"
@@ -27,5 +27,8 @@ func main() {
// 启动日程提醒定时任务
services.StartReminderScheduler(make(chan struct{}))
// 启动自有域名证书续期任务(到期前 30 天自动续签 Let's Encrypt 证书)
services.StartCertRenewScheduler(make(chan struct{}))
beego.Run()
}
+43 -7
View File
@@ -2,14 +2,50 @@ package models
import "time"
// SystemTenantDomain 租户域名表 yz_tenant_domain
// 域名类型
const (
DomainTypePlatformSub = 1 // 平台二级域名(从主域名池申请)
DomainTypeCustom = 2 // 租户自有域名(绑定后自动签发证书)
)
// 证书状态
const (
SSLStatusPending = 0 // 待处理
SSLStatusIssuing = 1 // 签发中
SSLStatusIssued = 2 // 已签发
SSLStatusFailed = 3 // 签发失败
)
// DNS 解析校验状态
const (
VerifyStatusNone = 0 // 未检测
VerifyStatusPassed = 1 // 已通过
VerifyStatusFailed = 2 // 未通过
)
// SystemTenantDomain 租户域名表 yz_system_tenant_domain
//
// 一条记录代表一个可访问租户官网的域名。两类来源:
// - domain_type=1 平台二级域名:sub_domain + main_domain 组成 full_domain,走人工审核,
// HTTPS 由平台泛域名证书覆盖,ssl_status 不参与判断。
// - domain_type=2 租户自有域名:full_domain 为租户自己的完整主机名,sub_domain / main_domain 留空,
// DNS 检测通过后自动签发 Let's Encrypt 证书,签发成功才置 status=1。
type SystemTenantDomain struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid *uint64 `orm:"column(tid);null" json:"tid"`
SubDomain *string `orm:"column(sub_domain);size(50);null" json:"sub_domain"`
MainDomain *string `orm:"column(main_domain);size(255);null" json:"main_domain"`
FullDomain *string `orm:"column(full_domain);size(255);null" json:"full_domain"`
Status int `orm:"column(status);null" json:"status"` // 1已生效 / 0审核中 / 2禁用
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid *uint64 `orm:"column(tid);null" json:"tid"`
DomainType int `orm:"column(domain_type);default(1)" json:"domain_type"`
SubDomain *string `orm:"column(sub_domain);size(50);null" json:"sub_domain"`
MainDomain *string `orm:"column(main_domain);size(255);null" json:"main_domain"`
FullDomain *string `orm:"column(full_domain);size(255);null" json:"full_domain"`
Status int `orm:"column(status);null" json:"status"` // 1已生效 / 0审核中 / 2禁用
SSLStatus int `orm:"column(ssl_status);default(0)" json:"ssl_status"`
SSLExpireAt *time.Time `orm:"column(ssl_expire_at);type(datetime);null" json:"ssl_expire_at"`
SSLError *string `orm:"column(ssl_error);size(500);null" json:"ssl_error"`
VerifyStatus int `orm:"column(verify_status);default(0)" json:"verify_status"`
VerifyMsg *string `orm:"column(verify_msg);size(500);null" json:"verify_msg"`
LastVerifyAt *time.Time `orm:"column(last_verify_at);type(datetime);null" json:"last_verify_at"`
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);auto_now;null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
+3
View File
@@ -201,6 +201,9 @@ func RegisterAuthRoutes() {
beego.Router("/backend/domain/tenant/index", &controllers.BackendTenantDomainController{}, "get:Index")
beego.Router("/backend/domain/tenant/myDomains", &controllers.BackendTenantDomainController{}, "get:MyDomains")
beego.Router("/backend/domain/tenant/apply", &controllers.BackendTenantDomainController{}, "post:Apply")
beego.Router("/backend/domain/tenant/bindCustom", &controllers.BackendTenantDomainController{}, "post:BindCustom")
beego.Router("/backend/domain/tenant/checkCustomDns", &controllers.BackendTenantDomainController{}, "post:CheckCustomDns")
beego.Router("/backend/domain/tenant/customDomainGuide", &controllers.BackendTenantDomainController{}, "get:CustomDomainGuide")
beego.Router("/backend/domain/tenant/audit", &controllers.BackendTenantDomainController{}, "post:Audit")
beego.Router("/backend/domain/tenant/toggleStatus", &controllers.BackendTenantDomainController{}, "post:ToggleStatus")
beego.Router("/backend/domain/tenant/delete/:id", &controllers.BackendTenantDomainController{}, "delete:Delete")
+3
View File
@@ -95,6 +95,9 @@ func Register() {
beego.Router("/platform/domain/tenant/index", &controllers.PlatformTenantDomainController{}, "get:Index")
beego.Router("/platform/domain/tenant/myDomains", &controllers.PlatformTenantDomainController{}, "get:MyDomains")
beego.Router("/platform/domain/tenant/apply", &controllers.PlatformTenantDomainController{}, "post:Apply")
beego.Router("/platform/domain/tenant/bindCustom", &controllers.PlatformTenantDomainController{}, "post:BindCustom")
beego.Router("/platform/domain/tenant/checkCustomDns", &controllers.PlatformTenantDomainController{}, "post:CheckCustomDns")
beego.Router("/platform/domain/tenant/customDomainGuide", &controllers.PlatformTenantDomainController{}, "get:CustomDomainGuide")
beego.Router("/platform/domain/tenant/audit", &controllers.PlatformTenantDomainController{}, "post:Audit")
beego.Router("/platform/domain/tenant/toggleStatus", &controllers.PlatformTenantDomainController{}, "post:ToggleStatus")
beego.Router("/platform/domain/tenant/delete/:id", &controllers.PlatformTenantDomainController{}, "delete:Delete")
+5
View File
@@ -3,6 +3,7 @@ package routers
import (
"os"
"server/controllers"
"server/middleware"
"server/routers/api"
"server/routers/app"
@@ -35,6 +36,10 @@ func init() {
// 全局操作日志:请求开始采集并异步写入
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
// Let's Encrypt HTTP-01 验证回访。
// 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。
beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve")
// 根据运行模式选择要注册的路由组
// 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all
mode := os.Getenv("APP_MODE")
+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()
}
+159
View File
@@ -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
}
+321
View File
@@ -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])
}
}
}