160 lines
4.6 KiB
Go
160 lines
4.6 KiB
Go
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
|
||
}
|