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 }