增加自定义绑定域名

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