diff --git a/README.md b/README.md index fc822a0e..299f9a3c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
-
+
-
+
Requestly - Free & Open-Source alternative to Postman
diff --git a/README_zh.md b/README_zh.md
index d3ec8aaf..82ac8a50 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -25,7 +25,7 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
Requestly - Free & Open-Source alternative to Postman
diff --git a/Release.md b/Release.md
index 2ea047fa..e237538b 100644
--- a/Release.md
+++ b/Release.md
@@ -1,5 +1,3 @@
## Features
-* Add NAT traversal configuration options for XTCP proxies and visitors. Support disabling assisted addresses to avoid using slow VPN connections during NAT hole punching.
-* Enhanced OIDC client configuration with support for custom TLS certificate verification and proxy settings. Added `trustedCaFile`, `insecureSkipVerify`, and `proxyURL` options for OIDC token endpoint connections.
-* Added detailed Prometheus metrics with `proxy_counts_detailed` metric that includes both proxy type and proxy name labels, enabling monitoring of individual proxy connections instead of just aggregate counts.
+* HTTPS proxies now support load balancing groups. Multiple HTTPS proxies can be configured with the same `loadBalancer.group` and `loadBalancer.groupKey` to share the same custom domain and distribute traffic across multiple backend services, similar to the existing TCP and HTTP load balancing capabilities.
diff --git a/server/controller/resource.go b/server/controller/resource.go
index 9d14b18d..717c9616 100644
--- a/server/controller/resource.go
+++ b/server/controller/resource.go
@@ -35,6 +35,9 @@ type ResourceController struct {
// HTTP Group Controller
HTTPGroupCtl *group.HTTPGroupController
+ // HTTPS Group Controller
+ HTTPSGroupCtl *group.HTTPSGroupController
+
// TCP Mux Group Controller
TCPMuxGroupCtl *group.TCPMuxGroupCtl
diff --git a/server/group/https.go b/server/group/https.go
new file mode 100644
index 00000000..4089b0cb
--- /dev/null
+++ b/server/group/https.go
@@ -0,0 +1,197 @@
+// Copyright 2025 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 group
+
+import (
+ "context"
+ "net"
+ "sync"
+
+ gerr "github.com/fatedier/golib/errors"
+
+ "github.com/fatedier/frp/pkg/util/vhost"
+)
+
+type HTTPSGroupController struct {
+ groups map[string]*HTTPSGroup
+
+ httpsMuxer *vhost.HTTPSMuxer
+
+ mu sync.Mutex
+}
+
+func NewHTTPSGroupController(httpsMuxer *vhost.HTTPSMuxer) *HTTPSGroupController {
+ return &HTTPSGroupController{
+ groups: make(map[string]*HTTPSGroup),
+ httpsMuxer: httpsMuxer,
+ }
+}
+
+func (ctl *HTTPSGroupController) Listen(
+ ctx context.Context,
+ group, groupKey string,
+ routeConfig vhost.RouteConfig,
+) (l net.Listener, err error) {
+ indexKey := group
+ ctl.mu.Lock()
+ g, ok := ctl.groups[indexKey]
+ if !ok {
+ g = NewHTTPSGroup(ctl)
+ ctl.groups[indexKey] = g
+ }
+ ctl.mu.Unlock()
+
+ return g.Listen(ctx, group, groupKey, routeConfig)
+}
+
+func (ctl *HTTPSGroupController) RemoveGroup(group string) {
+ ctl.mu.Lock()
+ defer ctl.mu.Unlock()
+ delete(ctl.groups, group)
+}
+
+type HTTPSGroup struct {
+ group string
+ groupKey string
+ domain string
+
+ acceptCh chan net.Conn
+ httpsLn *vhost.Listener
+ lns []*HTTPSGroupListener
+ ctl *HTTPSGroupController
+ mu sync.Mutex
+}
+
+func NewHTTPSGroup(ctl *HTTPSGroupController) *HTTPSGroup {
+ return &HTTPSGroup{
+ lns: make([]*HTTPSGroupListener, 0),
+ ctl: ctl,
+ acceptCh: make(chan net.Conn),
+ }
+}
+
+func (g *HTTPSGroup) Listen(
+ ctx context.Context,
+ group, groupKey string,
+ routeConfig vhost.RouteConfig,
+) (ln *HTTPSGroupListener, err error) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if len(g.lns) == 0 {
+ // the first listener, listen on the real address
+ httpsLn, errRet := g.ctl.httpsMuxer.Listen(ctx, &routeConfig)
+ if errRet != nil {
+ return nil, errRet
+ }
+ ln = newHTTPSGroupListener(group, g, httpsLn.Addr())
+
+ g.group = group
+ g.groupKey = groupKey
+ g.domain = routeConfig.Domain
+ g.httpsLn = httpsLn
+ g.lns = append(g.lns, ln)
+ go g.worker()
+ } else {
+ // route config in the same group must be equal
+ if g.group != group || g.domain != routeConfig.Domain {
+ return nil, ErrGroupParamsInvalid
+ }
+ if g.groupKey != groupKey {
+ return nil, ErrGroupAuthFailed
+ }
+ ln = newHTTPSGroupListener(group, g, g.lns[0].Addr())
+ g.lns = append(g.lns, ln)
+ }
+ return
+}
+
+func (g *HTTPSGroup) worker() {
+ for {
+ c, err := g.httpsLn.Accept()
+ if err != nil {
+ return
+ }
+ err = gerr.PanicToError(func() {
+ g.acceptCh <- c
+ })
+ if err != nil {
+ return
+ }
+ }
+}
+
+func (g *HTTPSGroup) Accept() <-chan net.Conn {
+ return g.acceptCh
+}
+
+func (g *HTTPSGroup) CloseListener(ln *HTTPSGroupListener) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ for i, tmpLn := range g.lns {
+ if tmpLn == ln {
+ g.lns = append(g.lns[:i], g.lns[i+1:]...)
+ break
+ }
+ }
+ if len(g.lns) == 0 {
+ close(g.acceptCh)
+ if g.httpsLn != nil {
+ g.httpsLn.Close()
+ }
+ g.ctl.RemoveGroup(g.group)
+ }
+}
+
+type HTTPSGroupListener struct {
+ groupName string
+ group *HTTPSGroup
+
+ addr net.Addr
+ closeCh chan struct{}
+}
+
+func newHTTPSGroupListener(name string, group *HTTPSGroup, addr net.Addr) *HTTPSGroupListener {
+ return &HTTPSGroupListener{
+ groupName: name,
+ group: group,
+ addr: addr,
+ closeCh: make(chan struct{}),
+ }
+}
+
+func (ln *HTTPSGroupListener) Accept() (c net.Conn, err error) {
+ var ok bool
+ select {
+ case <-ln.closeCh:
+ return nil, ErrListenerClosed
+ case c, ok = <-ln.group.Accept():
+ if !ok {
+ return nil, ErrListenerClosed
+ }
+ return c, nil
+ }
+}
+
+func (ln *HTTPSGroupListener) Addr() net.Addr {
+ return ln.addr
+}
+
+func (ln *HTTPSGroupListener) Close() (err error) {
+ close(ln.closeCh)
+
+ // remove self from HTTPSGroup
+ ln.group.CloseListener(ln)
+ return
+}
diff --git a/server/proxy/https.go b/server/proxy/https.go
index 4575ac13..f137ea7a 100644
--- a/server/proxy/https.go
+++ b/server/proxy/https.go
@@ -15,6 +15,7 @@
package proxy
import (
+ "net"
"reflect"
"strings"
@@ -58,27 +59,24 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {
continue
}
- routeConfig.Domain = domain
- l, errRet := pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, routeConfig)
- if errRet != nil {
- err = errRet
- return
+ l, err := pxy.listenForDomain(routeConfig, domain)
+ if err != nil {
+ return "", err
}
- xl.Infof("https proxy listen for host [%s]", routeConfig.Domain)
pxy.listeners = append(pxy.listeners, l)
- addrs = append(addrs, util.CanonicalAddr(routeConfig.Domain, pxy.serverCfg.VhostHTTPSPort))
+ addrs = append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.VhostHTTPSPort))
+ xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group)
}
if pxy.cfg.SubDomain != "" {
- routeConfig.Domain = pxy.cfg.SubDomain + "." + pxy.serverCfg.SubDomainHost
- l, errRet := pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, routeConfig)
- if errRet != nil {
- err = errRet
- return
+ domain := pxy.cfg.SubDomain + "." + pxy.serverCfg.SubDomainHost
+ l, err := pxy.listenForDomain(routeConfig, domain)
+ if err != nil {
+ return "", err
}
- xl.Infof("https proxy listen for host [%s]", routeConfig.Domain)
pxy.listeners = append(pxy.listeners, l)
- addrs = append(addrs, util.CanonicalAddr(routeConfig.Domain, pxy.serverCfg.VhostHTTPSPort))
+ addrs = append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.VhostHTTPSPort))
+ xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group)
}
pxy.startCommonTCPListenersHandler()
@@ -89,3 +87,18 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {
func (pxy *HTTPSProxy) Close() {
pxy.BaseProxy.Close()
}
+
+func (pxy *HTTPSProxy) listenForDomain(routeConfig *vhost.RouteConfig, domain string) (net.Listener, error) {
+ tmpRouteConfig := *routeConfig
+ tmpRouteConfig.Domain = domain
+
+ if pxy.cfg.LoadBalancer.Group != "" {
+ return pxy.rc.HTTPSGroupCtl.Listen(
+ pxy.ctx,
+ pxy.cfg.LoadBalancer.Group,
+ pxy.cfg.LoadBalancer.GroupKey,
+ tmpRouteConfig,
+ )
+ }
+ return pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, &tmpRouteConfig)
+}
diff --git a/server/service.go b/server/service.go
index 7ca80dc8..1fe882d2 100644
--- a/server/service.go
+++ b/server/service.go
@@ -322,6 +322,9 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
if err != nil {
return nil, fmt.Errorf("create vhost httpsMuxer error, %v", err)
}
+
+ // Init HTTPS group controller after HTTPSMuxer is created
+ svr.rc.HTTPSGroupCtl = group.NewHTTPSGroupController(svr.rc.VhostHTTPSMuxer)
}
// frp tls listener
diff --git a/test/e2e/framework/process.go b/test/e2e/framework/process.go
index 10b3611b..0b837e39 100644
--- a/test/e2e/framework/process.go
+++ b/test/e2e/framework/process.go
@@ -75,8 +75,8 @@ func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
if err != nil {
return p, p.StdOutput(), err
}
- // sleep for a while to get std output
- time.Sleep(2 * time.Second)
+ // Give frps extra time to finish binding ports before proceeding.
+ time.Sleep(4 * time.Second)
return p, p.StdOutput(), nil
}
diff --git a/test/e2e/v1/features/group.go b/test/e2e/v1/features/group.go
index fe0c957b..f6bb1856 100644
--- a/test/e2e/v1/features/group.go
+++ b/test/e2e/v1/features/group.go
@@ -1,6 +1,7 @@
package features
import (
+ "crypto/tls"
"fmt"
"strconv"
"sync"
@@ -8,6 +9,7 @@ import (
"github.com/onsi/ginkgo/v2"
+ "github.com/fatedier/frp/pkg/transport"
"github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
@@ -112,6 +114,80 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount)
})
+
+ ginkgo.It("HTTPS", func() {
+ vhostHTTPSPort := f.AllocPort()
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+ vhostHTTPSPort = %d
+ `, vhostHTTPSPort)
+ clientConf := consts.DefaultClientConfig
+
+ tlsConfig, err := transport.NewServerTLSConfig("", "", "")
+ framework.ExpectNoError(err)
+
+ fooPort := f.AllocPort()
+ fooServer := httpserver.New(
+ httpserver.WithBindPort(fooPort),
+ httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte("foo"))),
+ httpserver.WithTLSConfig(tlsConfig),
+ )
+ f.RunServer("", fooServer)
+
+ barPort := f.AllocPort()
+ barServer := httpserver.New(
+ httpserver.WithBindPort(barPort),
+ httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte("bar"))),
+ httpserver.WithTLSConfig(tlsConfig),
+ )
+ f.RunServer("", barServer)
+
+ clientConf += fmt.Sprintf(`
+ [[proxies]]
+ name = "foo"
+ type = "https"
+ localPort = %d
+ customDomains = ["example.com"]
+ loadBalancer.group = "test"
+ loadBalancer.groupKey = "123"
+
+ [[proxies]]
+ name = "bar"
+ type = "https"
+ localPort = %d
+ customDomains = ["example.com"]
+ loadBalancer.group = "test"
+ loadBalancer.groupKey = "123"
+ `, fooPort, barPort)
+
+ f.RunProcesses([]string{serverConf}, []string{clientConf})
+
+ fooCount := 0
+ barCount := 0
+ for i := 0; i < 10; i++ {
+ framework.NewRequestExpect(f).
+ Explain("times " + strconv.Itoa(i)).
+ Port(vhostHTTPSPort).
+ RequestModify(func(r *request.Request) {
+ r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
+ ServerName: "example.com",
+ InsecureSkipVerify: true,
+ })
+ }).
+ Ensure(func(resp *request.Response) bool {
+ switch string(resp.Content) {
+ case "foo":
+ fooCount++
+ case "bar":
+ barCount++
+ default:
+ return false
+ }
+ return true
+ })
+ }
+
+ framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount)
+ })
})
ginkgo.Describe("Health Check", func() {