diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index 7aa473c..d07a14d 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -378,6 +378,27 @@ func (h *Handler) GetPublicSiteInfo(c *gin.Context) { response.OK(c, info) } +// CheckDomain 供网关(如 Caddy on_demand_tls ask)在按需申请 SSL 证书时校验域名白名单。 +// 仅当域名属于系统中正常运行(active)的分销站点时返回 200,其余情况返回 404, +// 防止任意外部未授权域名解析到该服务器刷爆 Let's Encrypt 证书申请配额。 +func (h *Handler) CheckDomain(c *gin.Context) { + domain := strings.ToLower(strings.TrimSpace(c.Query("domain"))) + if domain == "" { + c.String(http.StatusBadRequest, "domain required") + return + } + if colon := strings.Index(domain, ":"); colon != -1 { + domain = domain[:colon] + } + + info, err := h.service.GetPublicSiteInfo(c.Request.Context(), "", domain) + if err != nil || info == nil || info.Status != "active" { + c.String(http.StatusNotFound, "domain not allowed") + return + } + c.String(http.StatusOK, "ok") +} + func (h *Handler) ListPaymentChannels(c *gin.Context) { siteID := parseQueryUint(c, "site_id") if siteID <= 0 { diff --git a/backend/internal/modules/distribution/handler_integration_test.go b/backend/internal/modules/distribution/handler_integration_test.go index 3ace8d1..fa5feec 100644 --- a/backend/internal/modules/distribution/handler_integration_test.go +++ b/backend/internal/modules/distribution/handler_integration_test.go @@ -55,6 +55,7 @@ func setupHandlerTestEnv(t *testing.T) *handlerTestEnv { pub := r.Group("/api/v1/distribution") { pub.GET("/site-info", h.GetPublicSiteInfo) + pub.GET("/check-domain", h.CheckDomain) pub.GET("/sites/:id/listings", h.ListPublicSiteListings) pub.POST("/auth/login", h.Login) } @@ -187,6 +188,28 @@ func TestHandler_PublicEndpoints(t *testing.T) { t.Fatalf("expected 404 for not found site, got %d: %s", w4.Code, w4.Body.String()) } + // 1.5 CheckDomain 验证(Caddy on_demand_tls ask 接口) + // 活跃站点域名返回 200 "ok" + w5 := env.request("GET", "/api/v1/distribution/check-domain?domain=pub.example.com", nil, 0, nil) + if w5.Code != http.StatusOK || w5.Body.String() != "ok" { + t.Fatalf("expected 200 ok, got %d: %s", w5.Code, w5.Body.String()) + } + // 带端口号时正常截断并校验通过 + w5Port := env.request("GET", "/api/v1/distribution/check-domain?domain=pub.example.com:443", nil, 0, nil) + if w5Port.Code != http.StatusOK || w5Port.Body.String() != "ok" { + t.Fatalf("expected 200 ok with port, got %d: %s", w5Port.Code, w5Port.Body.String()) + } + // 未绑定或不存在的域名返回 404 + w6 := env.request("GET", "/api/v1/distribution/check-domain?domain=unregistered.com", nil, 0, nil) + if w6.Code != http.StatusNotFound { + t.Fatalf("expected 404 for unregistered domain, got %d: %s", w6.Code, w6.Body.String()) + } + // 空域名返回 400 + w7 := env.request("GET", "/api/v1/distribution/check-domain", nil, 0, nil) + if w7.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty domain, got %d: %s", w7.Code, w7.Body.String()) + } + // 2. ListPublicSiteListings 验证 acc := model.GameAccount{ Title: "极品号-1", GameName: "cfhd", HafCoinAmount: 50_000_000, Status: "active", diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4ede3c6..fbca3a5 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -437,6 +437,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { // Gin 同一前缀只能有一种通配符名;管理端已用 /sites/:id/*,公开列表必须同名。 api.GET("/distribution/sites/:id/listings", distributionHandler.ListPublicSiteListings) api.GET("/distribution/site-info", distributionHandler.GetPublicSiteInfo) + api.GET("/distribution/check-domain", distributionHandler.CheckDomain) api.POST("/payments/leshua/notify", paymentHandler.LeshuaNotify) api.POST("/payments/lakala/notify", paymentHandler.LakalaNotify) api.POST("/payments/shuncheng/notify", paymentHandler.ShunchengNotify) diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 256cab9..74c2538 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -1,12 +1,14 @@ { email {$CADDY_EMAIL} + on_demand_tls { + ask http://backend:8080/api/distribution/check-domain + } } # 反代到后端时,用 Caddy 实测到的对端地址覆盖 X-Real-IP, # 防止客户端伪造 X-Forwarded-For 影响限流、审计和支付下单的 client_ip。 (backend_proxy) { header_up X-Real-IP {remote_host} - header_up X-Forwarded-Proto {scheme} } # 公开 API 反代片段:主站与选号网共用。 @@ -45,9 +47,9 @@ } } -# 主站与过渡子域名共享同一套配置:域名迁移期间新旧域名同时可访问, -# 避免 DNS 缓存未生效导致部分用户打不开。迁移完成后可移除 h5.{$CADDY_DOMAIN}。 -{$CADDY_DOMAIN}, h5.{$CADDY_DOMAIN} { +# 前台商城静态资源与安全头配置(主站与所有分销站点共享): +# 前端通过 window.location.hostname 自动向 /api/distribution/site-info 请求分销配置。 +(storefront) { encode zstd gzip header { @@ -67,7 +69,7 @@ respond 404 } - # 其余流量返回主站前端静态资源。 + # 其余流量返回主站/分销前台前端静态资源。 handle { root * /srv/dist try_files {path} /index.html @@ -75,6 +77,12 @@ } } +# 主站与过渡子域名共享同一套配置:域名迁移期间新旧域名同时可访问, +# 避免 DNS 缓存未生效导致部分用户打不开。迁移完成后可移除 h5.{$CADDY_DOMAIN}。 +{$CADDY_DOMAIN}, h5.{$CADDY_DOMAIN} { + import storefront +} + # 分销管理后台使用独立 React + Ant Design 静态资源,API 与主站复用 backend。 distribution.{$CADDY_DOMAIN} { encode zstd gzip @@ -107,5 +115,14 @@ monitor.{$CADDY_DOMAIN} { # gitea 自托管代码库 gitea.hao858.com { - reverse_proxy gitea:3000 + reverse_proxy gitea:3000 +} + +# 分销站点动态域名入口: +# 支持任意二级域名及分销商独立域名,首次访问时通过 on_demand_tls 触发后端白名单校验并自动签发 SSL 证书。 +https:// { + tls { + on_demand + } + import storefront }