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

@@ -275,6 +275,10 @@ localIP = "127.0.0.1"
localPort = 8000
subdomain = "web02"
customDomains = ["web02.yourdomain.com"]
# if true, frps will redirect plain HTTP requests for the domains above to HTTPS.
# It requires vhostHTTPPort to be enabled on frps, and an HTTP proxy registered
# on the same domain always takes precedence over the redirect.
# httpRedirect = true
# if not empty, frpc will use proxy protocol to transfer connection info to your local service
# v1 or v2 or empty
transport.proxyProtocolVersion = "v2"

View File

@@ -40,6 +40,12 @@ transport.maxPoolCount = 5
# If negative, keep-alive probes are disabled.
# transport.tcpKeepalive = 7200
# 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.
# It 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.
# transport.proxyIdleTimeout = 7200
# transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false.
transport.tls.force = false
@@ -52,6 +58,12 @@ transport.tls.force = false
vhostHTTPPort = 80
vhostHTTPSPort = 443
# 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 = 443
# Response header timeout(seconds) for vhost http server, default is 60s
# vhostHTTPTimeout = 60

2
go.mod
View File

@@ -97,5 +97,3 @@ require (
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
replace github.com/fatedier/golib => github.com/Lolia-FRP/golib v0.7.4

4
go.sum
View File

@@ -2,8 +2,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Lolia-FRP/golib v0.7.4 h1:EtUJChdgXVV9iU1xqB7PkD/1/3RgATbj3n86HbrLKMA=
github.com/Lolia-FRP/golib v0.7.4/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -36,6 +34,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=

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
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"))
}

View File

@@ -50,6 +50,9 @@ type ResourceController struct {
// For HTTP proxies, forwarding HTTP requests
HTTPReverseProxy *vhost.HTTPReverseProxy
// Domains of HTTPS proxies that requested automatic HTTP to HTTPS redirection
HTTPSRedirector *vhost.HTTPSRedirector
// For HTTPS proxies, route requests to different clients by hostname and other information
VhostHTTPSMuxer *vhost.HTTPSMuxer

View File

@@ -31,6 +31,9 @@ func init() {
type HTTPSProxy struct {
*BaseProxy
cfg *v1.HTTPSProxyConfig
// domains registered for HTTP to HTTPS redirection, removed on Close
redirectDomains []string
}
func NewHTTPSProxy(baseProxy *BaseProxy) Proxy {
@@ -66,12 +69,28 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {
xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group)
}
if pxy.cfg.HTTPRedirect {
if pxy.rc.HTTPSRedirector != nil {
for _, domain := range domains {
pxy.rc.HTTPSRedirector.Add(domain)
}
pxy.redirectDomains = domains
xl.Infof("https proxy enabled http redirect for hosts %v", domains)
} else {
xl.Warnf("httpRedirect is enabled but frps has no vhostHTTPPort or vhostHTTPSPort, ignored")
}
}
pxy.startCommonTCPListenersHandler()
remoteAddr = strings.Join(addrs, ",")
return
}
func (pxy *HTTPSProxy) Close() {
for _, domain := range pxy.redirectDomains {
pxy.rc.HTTPSRedirector.Remove(domain)
}
pxy.redirectDomains = nil
pxy.BaseProxy.Close()
}

View File

@@ -0,0 +1,47 @@
package proxy
import (
"sync/atomic"
"time"
)
// idleWatcher closes a proxied connection pair when no bytes have flowed in
// either direction for the configured timeout. Without it, endpoints that
// stay open at the TCP level but never send data again would pin the join
// goroutines and their transfer buffers forever.
type idleWatcher struct {
lastActive atomic.Int64 // unix nano of the last byte transferred
stopCh chan struct{}
}
func startIdleWatcher(timeout time.Duration, closeFn func()) *idleWatcher {
w := &idleWatcher{stopCh: make(chan struct{})}
w.touch()
go func() {
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case <-w.stopCh:
return
case <-timer.C:
idle := time.Since(time.Unix(0, w.lastActive.Load()))
if idle >= timeout {
closeFn()
return
}
timer.Reset(timeout - idle)
}
}
}()
return w
}
func (w *idleWatcher) touch() {
w.lastActive.Store(time.Now().UnixNano())
}
func (w *idleWatcher) stop() {
close(w.stopCh)
}

View File

@@ -0,0 +1,52 @@
package proxy
import (
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestIdleWatcherClosesAfterTimeout(t *testing.T) {
closed := make(chan struct{})
w := startIdleWatcher(50*time.Millisecond, func() {
close(closed)
})
defer w.stop()
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("closeFn was not called after idle timeout")
}
}
func TestIdleWatcherTouchPostponesClose(t *testing.T) {
var closedFlag atomic.Bool
w := startIdleWatcher(100*time.Millisecond, func() {
closedFlag.Store(true)
})
defer w.stop()
// keep the connection "active" well past the timeout
for range 6 {
time.Sleep(30 * time.Millisecond)
w.touch()
}
require.False(t, closedFlag.Load(), "closeFn fired despite ongoing activity")
// once activity stops, it should still close
require.Eventually(t, closedFlag.Load, time.Second, 10*time.Millisecond)
}
func TestIdleWatcherStopPreventsClose(t *testing.T) {
var closedFlag atomic.Bool
w := startIdleWatcher(50*time.Millisecond, func() {
closedFlag.Store(true)
})
w.stop()
time.Sleep(150 * time.Millisecond)
require.False(t, closedFlag.Load(), "closeFn fired after watcher was stopped")
}

View File

@@ -317,15 +317,33 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
name := pxy.GetName()
proxyType := cfg.Type
var watcher *idleWatcher
touch := func() {
if watcher != nil {
watcher.touch()
}
}
local = wrapCountingReadWriteCloser(local, nil, func(bytes int64) {
touch()
metrics.Server.AddTrafficIn(name, proxyType, bytes)
})
userConn = netpkg.WrapReadWriteCloserToConn(
wrapCountingReadWriteCloser(userConn, nil, func(bytes int64) {
touch()
metrics.Server.AddTrafficOut(name, proxyType, bytes)
}),
userConn,
)
if idleTimeout := time.Duration(pxy.serverCfg.Transport.ProxyIdleTimeout) * time.Second; idleTimeout > 0 {
wrappedLocal, wrappedUserConn := local, userConn
watcher = startIdleWatcher(idleTimeout, func() {
xl.Infof("close user connection [%s] of proxy [%s]: no traffic in either direction for %s",
wrappedUserConn.RemoteAddr().String(), name, idleTimeout)
_ = wrappedUserConn.Close()
_ = wrappedLocal.Close()
})
defer watcher.stop()
}
metrics.Server.OpenConnection(name, proxyType)
// Traffic is counted incrementally via the counting wrappers above, so the
// byte totals returned by joinUserConnection are intentionally discarded here.

View File

@@ -291,8 +291,14 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
// Create http vhost muxer.
if cfg.VhostHTTPPort > 0 {
var redirector *vhost.HTTPSRedirector
if cfg.VhostHTTPSPort > 0 {
redirector = vhost.NewHTTPSRedirector(cfg.VhostHTTPSRedirectPort)
svr.rc.HTTPSRedirector = redirector
}
rp := vhost.NewHTTPReverseProxy(vhost.HTTPReverseProxyOptions{
ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout,
HTTPSRedirector: redirector,
}, svr.httpVhostRouter)
svr.rc.HTTPReverseProxy = rp