diff --git a/docs/design.md b/docs/design.md index b0f9cc40..2ed8baec 100644 --- a/docs/design.md +++ b/docs/design.md @@ -120,6 +120,7 @@ Origin 稳定约束: * 一个域名只对应一个 `origin_url` +* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名 * `proxy_routes.domain` 必须唯一 * `origin_url` 必须为合法 `http://` 或 `https://` * `config_versions` 必须保存完整快照、渲染结果与 `checksum` diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index dc05bfe2..6121ba23 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -116,6 +116,7 @@ * 不新增平台化对象,除非设计文档明确要求 * `proxy_routes` 维持一条域名对应一个 `origin_url` +* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象 * `config_versions` 必须保存完整快照与渲染结果 * 全局同时只能有一个激活版本 * 回滚通过重新激活旧版本实现 diff --git a/openflare_server/docs/docs.go b/openflare_server/docs/docs.go index 35499b79..6f60bcf8 100644 --- a/openflare_server/docs/docs.go +++ b/openflare_server/docs/docs.go @@ -1537,6 +1537,9 @@ const docTemplate = `{ "origin_url": { "type": "string" }, + "origin_host": { + "type": "string" + }, "redirect_http": { "type": "boolean" }, diff --git a/openflare_server/docs/swagger.json b/openflare_server/docs/swagger.json index d7fb20d6..52d2cd0e 100644 --- a/openflare_server/docs/swagger.json +++ b/openflare_server/docs/swagger.json @@ -1534,6 +1534,9 @@ "origin_url": { "type": "string" }, + "origin_host": { + "type": "string" + }, "redirect_http": { "type": "boolean" }, @@ -1574,4 +1577,4 @@ "in": "header" } } -} \ No newline at end of file +} diff --git a/openflare_server/docs/swagger.yaml b/openflare_server/docs/swagger.yaml index 4a514862..efb56667 100644 --- a/openflare_server/docs/swagger.yaml +++ b/openflare_server/docs/swagger.yaml @@ -80,6 +80,8 @@ definitions: type: boolean origin_url: type: string + origin_host: + type: string redirect_http: type: boolean remark: diff --git a/openflare_server/model/proxy_route.go b/openflare_server/model/proxy_route.go index d0863fba..32a55b24 100644 --- a/openflare_server/model/proxy_route.go +++ b/openflare_server/model/proxy_route.go @@ -6,6 +6,7 @@ type ProxyRoute struct { ID uint `json:"id" gorm:"primaryKey"` Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` OriginURL string `json:"origin_url" gorm:"size:2048;not null"` + OriginHost string `json:"origin_host" gorm:"size:255"` Enabled bool `json:"enabled" gorm:"not null;default:true"` EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"` CertID *uint `json:"cert_id"` @@ -40,6 +41,7 @@ func (route *ProxyRoute) Update() error { return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{ "domain": route.Domain, "origin_url": route.OriginURL, + "origin_host": route.OriginHost, "enabled": route.Enabled, "enable_https": route.EnableHTTPS, "cert_id": route.CertID, diff --git a/openflare_server/router/api_phase1_test.go b/openflare_server/router/api_phase1_test.go index bf7a4423..189fa8aa 100644 --- a/openflare_server/router/api_phase1_test.go +++ b/openflare_server/router/api_phase1_test.go @@ -44,10 +44,11 @@ func TestPhase1PublishLifecycle(t *testing.T) { token := prepareRootToken(t) createBody := map[string]any{ - "domain": "app.example.com", - "origin_url": "https://origin-a.internal", - "enabled": true, - "remark": "primary route", + "domain": "app.example.com", + "origin_url": "https://origin-a.internal", + "origin_host": "origin-a.internal", + "enabled": true, + "remark": "primary route", } resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody) var createdRoute model.ProxyRoute @@ -55,6 +56,9 @@ func TestPhase1PublishLifecycle(t *testing.T) { if createdRoute.Domain != "app.example.com" { t.Fatalf("unexpected created route domain: %s", createdRoute.Domain) } + if createdRoute.OriginHost != "origin-a.internal" { + t.Fatalf("unexpected created route origin host: %s", createdRoute.OriginHost) + } resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil) var routes []model.ProxyRoute @@ -99,10 +103,11 @@ func TestPhase1PublishLifecycle(t *testing.T) { initialRendered := version1.RenderedConfig updateBody := map[string]any{ - "domain": "app.example.com", - "origin_url": "https://origin-b.internal", - "enabled": true, - "remark": "updated route", + "domain": "app.example.com", + "origin_url": "https://origin-b.internal", + "origin_host": "origin-b.internal", + "enabled": true, + "remark": "updated route", } routePath := "/api/proxy-routes/" + toString(createdRoute.ID) resp = performJSONRequest(t, engine, token, http.MethodPut, routePath, updateBody) @@ -110,6 +115,9 @@ func TestPhase1PublishLifecycle(t *testing.T) { if createdRoute.OriginURL != "https://origin-b.internal" { t.Fatalf("unexpected updated route origin: %s", createdRoute.OriginURL) } + if createdRoute.OriginHost != "origin-b.internal" { + t.Fatalf("unexpected updated route origin host: %s", createdRoute.OriginHost) + } resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) var version2 model.ConfigVersion diff --git a/openflare_server/router/api_phase2_test.go b/openflare_server/router/api_phase2_test.go index 80654803..352e6ed3 100644 --- a/openflare_server/router/api_phase2_test.go +++ b/openflare_server/router/api_phase2_test.go @@ -382,9 +382,10 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) { token := prepareRootToken(t) createResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{ - "domain": "preview.example.com", - "origin_url": "https://origin-a.internal", - "enabled": true, + "domain": "preview.example.com", + "origin_url": "https://origin-a.internal", + "origin_host": "preview-origin.internal", + "enabled": true, "custom_headers": []map[string]any{ {"key": "X-Trace-Id", "value": "$request_id"}, }, @@ -394,13 +395,17 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) { if !strings.Contains(createdRoute.CustomHeaders, "X-Trace-Id") { t.Fatalf("expected custom headers to be stored as json, got %s", createdRoute.CustomHeaders) } + if createdRoute.OriginHost != "preview-origin.internal" { + t.Fatalf("expected origin_host to be stored, got %s", createdRoute.OriginHost) + } performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) performJSONRequest(t, engine, token, http.MethodPut, "/api/proxy-routes/"+toString(createdRoute.ID), map[string]any{ - "domain": "preview.example.com", - "origin_url": "https://origin-b.internal", - "enabled": true, + "domain": "preview.example.com", + "origin_url": "https://origin-b.internal", + "origin_host": "preview-upstream.internal", + "enabled": true, "custom_headers": []map[string]any{ {"key": "X-Trace-Id", "value": "$request_id"}, {"key": "X-Release", "value": "candidate"}, @@ -419,6 +424,9 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) { if !strings.Contains(renderedConfig, `proxy_set_header X-Release "candidate";`) { t.Fatalf("expected preview endpoint to return custom header, got %s", renderedConfig) } + if !strings.Contains(renderedConfig, `proxy_set_header Host "preview-upstream.internal";`) { + t.Fatalf("expected preview endpoint to return overridden host header, got %s", renderedConfig) + } diffResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/diff", nil) var diff map[string]any diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index c0f203d9..75a68729 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -54,6 +54,7 @@ type ConfigOptionDiffItem struct { type snapshotRoute struct { Domain string `json:"domain"` OriginURL string `json:"origin_url"` + OriginHost string `json:"origin_host,omitempty"` Enabled bool `json:"enabled"` EnableHTTPS bool `json:"enable_https"` CertID *uint `json:"cert_id,omitempty"` @@ -379,6 +380,7 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { items = append(items, snapshotRoute{ Domain: route.Domain, OriginURL: route.OriginURL, + OriginHost: route.OriginHost, Enabled: route.Enabled, EnableHTTPS: route.EnableHTTPS, CertID: route.CertID, @@ -424,7 +426,7 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute { } func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool { - if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || !uintPointerEqual(left.CertID, right.CertID) { + if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || !uintPointerEqual(left.CertID, right.CertID) { return false } if len(left.CustomHeaders) != len(right.CustomHeaders) { @@ -593,7 +595,7 @@ func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) } if !route.EnableHTTPS { - builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, customHeaders)) + builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders)) continue } if route.CertID == nil || *route.CertID == 0 { @@ -610,9 +612,9 @@ func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error if route.RedirectHTTP { builder.WriteString(renderHTTPRedirectServer(route.Domain)) } else { - builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, customHeaders)) + builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders)) } - builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, certificate.ID, customHeaders)) + builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders)) } return builder.String(), dedupeSupportFiles(supportFiles), nil } @@ -746,27 +748,31 @@ func nextVersionNumber(now time.Time) (string, error) { return fmt.Sprintf("%s-%03d", prefix, count+1), nil } -func renderHTTPProxyServer(domain string, originURL string, customHeaders []ProxyRouteCustomHeaderInput) string { - return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n location / {\n%s proxy_pass %s;\n }\n}\n\n", domain, renderExactHostGuard(domain), renderProxyHeaderBlock(customHeaders), originURL) +func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput) string { + return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n location / {\n%s proxy_pass %s;\n }\n}\n\n", domain, renderExactHostGuard(domain), renderProxyHeaderBlock(originHost, customHeaders), originURL) } func renderHTTPRedirectServer(domain string) string { return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s\n return 301 https://$host$request_uri;\n}\n\n", domain, renderExactHostGuard(domain)) } -func renderHTTPSServer(domain string, originURL string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput) string { +func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput) string { certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID)) keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID)) - return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s\n location / {\n%s proxy_pass %s;\n }\n}\n\n", domain, certPath, keyPath, renderExactHostGuard(domain), renderProxyHeaderBlock(customHeaders), originURL) + return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s\n location / {\n%s proxy_pass %s;\n }\n}\n\n", domain, certPath, keyPath, renderExactHostGuard(domain), renderProxyHeaderBlock(originHost, customHeaders), originURL) } func renderExactHostGuard(domain string) string { return fmt.Sprintf(" if ($host != %q) {\n return 404;\n }\n", domain) } -func renderProxyHeaderBlock(customHeaders []ProxyRouteCustomHeaderInput) string { +func renderProxyHeaderBlock(originHost string, customHeaders []ProxyRouteCustomHeaderInput) string { var builder strings.Builder - builder.WriteString(" proxy_set_header Host $host;\n") + if strings.TrimSpace(originHost) != "" { + builder.WriteString(fmt.Sprintf(" proxy_set_header Host %s;\n", quoteNginxHeaderValue(originHost))) + } else { + builder.WriteString(" proxy_set_header Host $host;\n") + } builder.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n") builder.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") builder.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n") diff --git a/openflare_server/service/https_phase1_test.go b/openflare_server/service/https_phase1_test.go index 675f80d9..da82edb4 100644 --- a/openflare_server/service/https_phase1_test.go +++ b/openflare_server/service/https_phase1_test.go @@ -140,6 +140,31 @@ func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) { } } +func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) { + setupServiceTestDB(t) + + _, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "git.arctel.de", + OriginURL: "https://git.arctel.net", + OriginHost: "git.arctel.net", + Enabled: true, + }) + if err != nil { + t.Fatalf("CreateProxyRoute failed: %v", err) + } + + result, err := PublishConfigVersion("root") + if err != nil { + t.Fatalf("PublishConfigVersion failed: %v", err) + } + if !strings.Contains(result.Version.RenderedConfig, `proxy_set_header Host "git.arctel.net";`) { + t.Fatal("expected rendered config to override host header for origin routing") + } + if !strings.Contains(result.Version.SnapshotJSON, `"origin_host":"git.arctel.net"`) { + t.Fatal("expected snapshot to include origin_host override") + } +} + func TestPreviewConfigVersionCanDisableWebsocketHeaders(t *testing.T) { setupServiceTestDB(t) diff --git a/openflare_server/service/proxy_route.go b/openflare_server/service/proxy_route.go index 15d61adf..474eaea6 100644 --- a/openflare_server/service/proxy_route.go +++ b/openflare_server/service/proxy_route.go @@ -19,6 +19,7 @@ type ProxyRouteCustomHeaderInput struct { type ProxyRouteInput struct { Domain string `json:"domain"` OriginURL string `json:"origin_url"` + OriginHost string `json:"origin_host"` Enabled bool `json:"enabled"` EnableHTTPS bool `json:"enable_https"` CertID *uint `json:"cert_id"` @@ -74,6 +75,7 @@ func DeleteProxyRoute(id uint) error { func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.ProxyRoute, error) { domain := strings.ToLower(strings.TrimSpace(input.Domain)) originURL := strings.TrimSpace(input.OriginURL) + originHost := strings.TrimSpace(input.OriginHost) remark := strings.TrimSpace(input.Remark) customHeaders, err := normalizeCustomHeaders(input.CustomHeaders) if err != nil { @@ -92,6 +94,9 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro if err := validateOriginURL(originURL); err != nil { return nil, err } + if err := validateOriginHost(originHost); err != nil { + return nil, err + } if !input.EnableHTTPS { input.RedirectHTTP = false input.CertID = nil @@ -112,6 +117,7 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro } route.Domain = domain route.OriginURL = originURL + route.OriginHost = originHost route.Enabled = input.Enabled route.EnableHTTPS = input.EnableHTTPS route.CertID = input.CertID @@ -178,6 +184,23 @@ func validateOriginURL(raw string) error { return nil } +func validateOriginHost(raw string) error { + if raw == "" { + return nil + } + if strings.ContainsAny(raw, "/\\ \t\r\n") || strings.Contains(raw, "://") { + return errors.New("回源主机名格式不合法") + } + parsed, err := url.Parse("//" + raw) + if err != nil || parsed.Host == "" || parsed.Host != raw { + return errors.New("回源主机名格式不合法") + } + if parsed.Hostname() == "" { + return errors.New("回源主机名格式不合法") + } + return nil +} + func isUniqueConstraintError(err error) bool { return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") } diff --git a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx index 47f4d7cb..a059de3f 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx @@ -69,6 +69,24 @@ const proxyRouteSchema = z return false; } }, '请输入合法的源站地址'), + origin_host: z + .string() + .trim() + .refine( + (value) => + !value || + (!/[\/\\\s]/.test(value) && + !value.includes('://') && + (() => { + try { + const parsed = new URL(`http://${value}`); + return parsed.host === value && Boolean(parsed.hostname); + } catch { + return false; + } + })()), + '请输入合法的回源主机名', + ), enabled: z.boolean(), enable_https: z.boolean(), cert_id: z.string(), @@ -129,6 +147,7 @@ type FeedbackState = { const defaultValues: ProxyRouteFormValues = { domain: '', origin_url: '', + origin_host: '', enabled: true, enable_https: false, cert_id: '', @@ -186,6 +205,7 @@ function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload { return { domain: values.domain.trim(), origin_url: values.origin_url.trim(), + origin_host: values.origin_host.trim(), enabled: values.enabled, enable_https: values.enable_https, cert_id: @@ -204,6 +224,7 @@ function toFormValues(route: ProxyRouteItem): ProxyRouteFormValues { return { domain: route.domain, origin_url: route.origin_url, + origin_host: route.origin_host || '', enabled: route.enabled, enable_https: route.enable_https, cert_id: route.cert_id ? String(route.cert_id) : '', @@ -510,7 +531,12 @@ export function ProxyRoutesPage() { {route.domain}
{route.origin_url}
++ 回源主机名: {route.origin_host || '$host'} +
+{route.origin_url}
+{route.origin_url}
++ 回源主机名: {route.origin_host || '$host'} +
+