增加自定义绑定域名

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
+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
}