feat(proxy): add HTTP to HTTPS redirection support

This commit is contained in:
2026-07-04 14:30:52 +08:00
parent e487e7f96f
commit 521542b796
16 changed files with 327 additions and 6 deletions

View File

@@ -369,6 +369,12 @@ var _ ProxyConfigurer = &HTTPSProxyConfig{}
type HTTPSProxyConfig struct {
ProxyBaseConfig
DomainConfig
// HTTPRedirect requests frps to redirect plain HTTP requests for the
// proxy's domains to their HTTPS endpoint. It only takes effect when
// frps has vhostHTTPPort enabled, and never overrides a real HTTP
// proxy registered on the same domain.
HTTPRedirect bool `json:"httpRedirect,omitempty"`
}
func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
@@ -376,6 +382,7 @@ func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {
m.CustomDomains = c.CustomDomains
m.SubDomain = c.SubDomain
m.HTTPRedirect = c.HTTPRedirect
}
func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
@@ -383,6 +390,7 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.CustomDomains = m.CustomDomains
c.SubDomain = m.SubDomain
c.HTTPRedirect = m.HTTPRedirect
}
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {

View File

@@ -51,6 +51,12 @@ type ServerConfig struct {
// Vhost requests. If this value is 0, the server will not listen for HTTPS
// requests.
VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"`
// VhostHTTPSRedirectPort specifies the port used in the Location header
// when redirecting HTTP requests to HTTPS for proxies with httpRedirect
// enabled. Set it when browsers reach the HTTPS vhost through a port
// mapping, so it differs from VhostHTTPSPort. By default, this value is
// VhostHTTPSPort.
VhostHTTPSRedirectPort int `json:"vhostHTTPSRedirectPort,omitempty"`
// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP
// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP
// requests on one single port. If it's not - it will listen on this value for
@@ -118,6 +124,7 @@ func (c *ServerConfig) Complete() error {
}
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
c.VhostHTTPSRedirectPort = util.EmptyOr(c.VhostHTTPSRedirectPort, c.VhostHTTPSPort)
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
@@ -173,6 +180,12 @@ type ServerTransportConfig struct {
// before terminating the connection. It is not recommended to change this
// value. By default, this value is 90. Set negative value to disable it.
HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"`
// ProxyIdleTimeout specifies the maximum time in seconds that a proxied
// user connection can stay open without any traffic in either direction
// before frps closes it. This reclaims connections whose endpoints are
// still open at the TCP level but will never send data again. By default,
// this value is 0, which disables the idle timeout.
ProxyIdleTimeout int64 `json:"proxyIdleTimeout,omitempty"`
// QUIC options.
QUIC QUICOptions `json:"quic,omitempty"`
// TLS specifies TLS settings for the connection from the client.

View File

@@ -124,6 +124,7 @@ type NewProxy struct {
Headers map[string]string `json:"headers,omitempty"`
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
HTTPRedirect bool `json:"http_redirect,omitempty"`
// stcp, sudp, xtcp
Sk string `json:"sk,omitempty"`

View File

@@ -39,11 +39,15 @@ var ErrNoRouteFound = errors.New("no route found")
type HTTPReverseProxyOptions struct {
ResponseHeaderTimeoutS int64
// HTTPSRedirector, if set, redirects requests whose host has no HTTP
// route but opted into HTTP to HTTPS redirection.
HTTPSRedirector *HTTPSRedirector
}
type HTTPReverseProxy struct {
proxy http.Handler
vhostRouter *Routers
proxy http.Handler
vhostRouter *Routers
httpsRedirector *HTTPSRedirector
responseHeaderTimeout time.Duration
}
@@ -55,6 +59,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
rp := &HTTPReverseProxy{
responseHeaderTimeout: time.Duration(option.ResponseHeaderTimeoutS) * time.Second,
vhostRouter: vhostRouter,
httpsRedirector: option.HTTPSRedirector,
}
proxy := &httputil.ReverseProxy{
// Modify incoming requests by route policies.
@@ -281,6 +286,12 @@ func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request)
return
}
// Hosts without any real HTTP route may still ask for a redirect to
// their HTTPS endpoint; registered HTTP routes always take precedence.
if rc == nil && rp.httpsRedirector != nil && rp.httpsRedirector.Redirect(rw, newreq) {
return
}
if req.Method == http.MethodConnect {
rp.connectHandler(rw, newreq)
} else {

View File

@@ -0,0 +1,83 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package vhost
import (
"net/http"
"strconv"
"sync"
httppkg "github.com/fatedier/frp/pkg/util/http"
)
// HTTPSRedirector tracks domains of HTTPS proxies that opted into automatic
// HTTP to HTTPS redirection. The HTTP reverse proxy consults it as a fallback
// for hosts that have no real HTTP route, so registered HTTP proxies on the
// same domain always take precedence over the redirect.
type HTTPSRedirector struct {
mu sync.RWMutex
domains map[string]int // domain -> number of proxies requesting the redirect
port int // port used in the redirect Location, as seen by browsers
}
func NewHTTPSRedirector(port int) *HTTPSRedirector {
return &HTTPSRedirector{
domains: make(map[string]int),
port: port,
}
}
func (r *HTTPSRedirector) Add(domain string) {
r.mu.Lock()
defer r.mu.Unlock()
r.domains[domain]++
}
func (r *HTTPSRedirector) Remove(domain string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.domains[domain] <= 1 {
delete(r.domains, domain)
} else {
r.domains[domain]--
}
}
func (r *HTTPSRedirector) match(host string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.domains[host] > 0
}
// Redirect responds with a redirect to the HTTPS endpoint of the requested
// host if the host opted into redirection. It reports whether the request
// was handled.
func (r *HTTPSRedirector) Redirect(rw http.ResponseWriter, req *http.Request) bool {
if req.Method == http.MethodConnect {
return false
}
host, err := httppkg.CanonicalHost(req.Host)
if err != nil || !r.match(host) {
return false
}
target := "https://" + host
if r.port != 443 {
target += ":" + strconv.Itoa(r.port)
}
target += req.URL.RequestURI()
http.Redirect(rw, req, target, http.StatusMovedPermanently)
return true
}

View File

@@ -0,0 +1,46 @@
package vhost
import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestHTTPSRedirectorRedirect(t *testing.T) {
r := NewHTTPSRedirector(443)
r.Add("a.example.com")
// registered domain, port stripped from host, path and query preserved
req := httptest.NewRequest("GET", "http://a.example.com:8080/foo?x=1", nil)
rw := httptest.NewRecorder()
require.True(t, r.Redirect(rw, req))
require.Equal(t, 301, rw.Code)
require.Equal(t, "https://a.example.com/foo?x=1", rw.Header().Get("Location"))
// unknown domain is not handled
req = httptest.NewRequest("GET", "http://b.example.com/", nil)
require.False(t, r.Redirect(httptest.NewRecorder(), req))
}
func TestHTTPSRedirectorNonDefaultPort(t *testing.T) {
r := NewHTTPSRedirector(8443)
r.Add("a.example.com")
req := httptest.NewRequest("GET", "http://a.example.com/", nil)
rw := httptest.NewRecorder()
require.True(t, r.Redirect(rw, req))
require.Equal(t, "https://a.example.com:8443/", rw.Header().Get("Location"))
}
func TestHTTPSRedirectorRefCount(t *testing.T) {
r := NewHTTPSRedirector(443)
r.Add("a.example.com")
r.Add("a.example.com")
r.Remove("a.example.com")
require.True(t, r.match("a.example.com"), "domain removed while another proxy still references it")
r.Remove("a.example.com")
require.False(t, r.match("a.example.com"))
}