forked from Mxmilu666/frp
feat(proxy): add HTTP to HTTPS redirection support
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
83
pkg/util/vhost/https_redirect.go
Normal file
83
pkg/util/vhost/https_redirect.go
Normal 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
|
||||
}
|
||||
46
pkg/util/vhost/https_redirect_test.go
Normal file
46
pkg/util/vhost/https_redirect_test.go
Normal 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"))
|
||||
}
|
||||
Reference in New Issue
Block a user