Repository: fatedier/frp (language: go)

Issue:
wss support
<!-- From Chinese to English by machine translation, welcome to revise and polish. -->

**The solution you want**

Hello, as I don't understand chinese, could someone explain the status of this  pull request ? It seems that it solves my issue

https://github.com/fatedier/frp/pull/1919

<!--A clear and concise description of the solution you want. -->
As Caddy Server provides wss support out-of-the-box (I didn't need any configuration on this), I would need ro reverse proxy https + wss with FRP. For now, wss are in 'pending' mode in Chrome. 

**Alternatives considered**
<!--A clear and concise description of any alternative solutions or features you have considered. -->

I don't think there is any alternative, but please explain how to configure rpc for this !

Thanks !



Discussion / hints:
It won't be supported since the protocol is between frpc and frps.

What's your usage scenario?
Dear, 

I was using Caddy server to handle http/https requests, but it exposes the public IP of my home router. I would like to hide my public IP by tunneling thanks to FRP.

I would like to expose a Node-Red instance through FRP, but the wss flow stays in "pending" status. 
As explained [here](https://discourse.nodered.org/t/websocket-connection-handshake-404-on-comms-nginx-to-node-red-does-block-websocket-communication/15619), node-red needs wss://domain.ltd/comms to push real-time debug messages to the webinterface:

<img width="676" alt="wss - domain ltd - comms" src="https://user-images.githubusercontent.com/3525134/100627571-73888500-3327-11eb-8df2-1c20f240d86c.png">

As wss is quiet popular protocol now, I guess it would interest lots of FRP users :)
 
It works out-of-the-box with Caddyfile with this config: 
```
nodered.mydomain.com {
  log {
    output file /logs/nodered.log
  }
  reverse_proxy localhost:1880
}
```

Do you need more clarification from my side ? 

Thanks, best !
You can find more solutions from these documents:

https://github.com/fatedier/frp#visit-your-web-service-in-lan-by-custom-domains

https://github.com/fatedier/frp#enable-https-for-local-http-service

Or just use `TCP` type https://github.com/fatedier/frp#access-your-computer-in-lan-by-ssh
Hi @fatedier, our frps is behind a https load balancer which forces http to https, so we can't use insecure websocket connection. The proposed change is simple and it works (could it be refined?, maybe, I don't know), but it would be super useful to have the feature out of the box instead of having to merge the code ourself.
@fewkso You problem isn't same here.

 It's not the protocol we want to support between frpc and frps. It will limit the future design for frp's self protocol.

You can try to combine frps with other tunnel projects which can tunnel TCP on other protocols like websocket or UDP.
Hello, I digg into this issue and found that websocket are working properly in http mode: 

```
[nodered-http]
type = http
local_ip = my-server-ip
local_port = 1880
custom_domains = nodered.mydomain.com
```
<img width="888" alt="ws" src="https://user-images.githubusercontent.com/3525134/101511554-f4c0c700-397a-11eb-976f-ef41e0861871.png">

but when I switch to https, the websocket protocol switch from _ws://nodered.mydomain.com/comms_ to _**wss**://nodered.mydomain.com/comms_
 
and status is endless '_pending_' as described at the beginning of this ticket. 

```
[nodered-https]
type = https
custom_domains = nodered.mydomain.com

plugin = https2http
plugin_local_addr = my-server-ip:1880
plugin_crt_path = /etc/fullchain.pem
plugin_key_path = /etc/privkey.pem
plugin_host_header_rewrite = my-server-ip:1880
```

Do you have any idea how to proxy wss protocol thanks to FRP ? 

Thanks ! all the best !
I encounter the same problem when using `https2http` plugin, when the backend is not nginx. I think it might because the backend does not provide as much information as nginx to the frp for it's normally functioning. As suggested by #2125, you could use nginx as an intermediate server to do the http to https convert, like `backend <---http---> nginx <---https---> frp`.
a websocket bug fixed by #2199 , now you can try new release v0.35.0

wss will be supported later.

> Hello, I digg into this issue and found that websocket are working properly in http mode:
> 
> ```
> [nodered-http]
> type = http
> local_ip = my-server-ip
> local_port = 1880
> custom_domains = nodered.mydomain.com
> ```
> 
> <img alt="ws" width="888" src="https://user-images.githubusercontent.com/3525134/101511554-f4c0c700-397a-11eb-976f-ef41e0861871.png">
> 
> but when I switch to https, the websocket protocol switch from _ws://nodered.mydomain.com/comms_ to _**wss**://nodered.mydomain.com/comms_
> 
> and status is endless '_pending_' as described at the beginning of this ticket.
> 
> ```
> [nodered-https]
> type = https
> custom_domains = nodered.mydomain.com
> 
> plugin = https2http
> plugin_local_addr = my-server-ip:1880
> plugin_crt_path = /etc/fullchain.pem
> plugin_key_path = /etc/privkey.pem
> plugin_host_header_rewrite = my-server-ip:1880
> ```
> 
> Do you have any idea how to proxy wss protocol thanks to FRP ?
> 
> Thanks ! all the best !


I test it in the lastest dev branch and it seems that the problem persists.

I created a [test case](https://github.com/fatedier/frp/files/5851428/wss_test.tar.gz) to reproduce this issue. It seems that it's a different issue from the KeepAlive one, because in the test case, all the requests is simulated in the tcp layer.

```bash
# Ubuntu 20.04
tar zxf wss_test.tar.gz
cd wss_test
frps -c frps.ini &
frpc -c frpc.ini &
```

In another shell
```bash
python3 wss_test.py
# The read operation timed out when client accepting handshaking message
```

Some explanation can be seen in `wss_test.py`.

@fredericmalo https proxy can support wss. Give a frpc.ini demo if the server can accept **https** request:

```
[nodered-https]
type = https
local_ip = my-server-ip
local_port = 1880
custom_domains = nodered.mydomain.com
```

And make sure that request Host same to OriginHost.

> Hello, I digg into this issue and found that websocket are working properly in http mode:
> 
> ```
> [nodered-http]
> type = http
> local_ip = my-server-ip
> local_port = 1880
> custom_domains = nodered.mydomain.com
> ```
> 
> <img alt="ws" width="888" src="https://user-images.githubusercontent.com/3525134/101511554-f4c0c700-397a-11eb-976f-ef41e0861871.png">
> 
> but when I switch to https, the websocket protocol switch from _ws://nodered.mydomain.com/comms_ to _**wss**://nodered.mydomain.com/comms_
> 
> and status is endless '_pending_' as described at the beginning of this ticket.
> 
> ```
> [nodered-https]
> type = https
> custom_domains = nodered.mydomain.com
> 
> plugin = https2http
> plugin_local_addr = my-server-ip:1880
> plugin_crt_path = /etc/fullchain.pem
> plugin_key_path = /etc/privkey.pem
> plugin_host_header_rewrite = my-server-ip:1880
> ```
> 
> Do you have any idea how to proxy wss protocol thanks to FRP ?
> 
> Thanks ! all the best !

@dizzam there is some problem in http(s) plugin to support ws(s). Can you use http(s) proxy directly ?

Fix patch:
diff --git a/Release.md b/Release.md
index ab58d8dbcc4..5d9623f008e 100644
--- a/Release.md
+++ b/Release.md
@@ -1,18 +1,7 @@
-## Notes
-
-**For enhanced security, the default values for `tls_enable` and `disable_custom_tls_first_byte` have been set to true.**
-
-If you wish to revert to the previous default values, you need to manually set the values of these two parameters to false.
-
 ### Features
 
-* Added support for `allow_users` in stcp, sudp, xtcp. By default, only the same user is allowed to access. Use `*` to allow access from any user. The visitor configuration now supports `server_user` to connect to proxies of other users.
-* Added fallback support to a specified alternative visitor when xtcp connection fails.
-
-### Improvements
-
-* Increased the default value of `MaxStreamWindowSize` for yamux to 6MB, improving traffic forwarding rate in high-latency scenarios.
+* frpc supports connecting to frps via the wss protocol by enabling the configuration `protocol = wss`.
 
 ### Fixes
 
-* Fixed an issue where having proxies with the same name would cause previously working proxies to become ineffective in `xtcp`.
+* Fix an issue caused by a bug in yamux that prevents wss from working properly in certain plugins.
diff --git a/client/service.go b/client/service.go
index df85c05f4b1..2f7782d5e32 100644
--- a/client/service.go
+++ b/client/service.go
@@ -135,7 +135,7 @@ func (svr *Service) Run() error {
 			if svr.cfg.LoginFailExit {
 				return err
 			}
-			util.RandomSleep(10*time.Second, 0.9, 1.1)
+			util.RandomSleep(5*time.Second, 0.9, 1.1)
 		} else {
 			// login success
 			ctl := NewControl(svr.ctx, svr.runID, conn, cm, svr.cfg, svr.pxyCfgs, svr.visitorCfgs, svr.authSetter)
@@ -427,7 +427,11 @@ func (cm *ConnectionManager) realConnect() (net.Conn, error) {
 	xl := xlog.FromContextSafe(cm.ctx)
 	var tlsConfig *tls.Config
 	var err error
-	if cm.cfg.TLSEnable {
+	tlsEnable := cm.cfg.TLSEnable
+	if cm.cfg.Protocol == "wss" {
+		tlsEnable = true
+	}
+	if tlsEnable {
 		sn := cm.cfg.TLSServerName
 		if sn == "" {
 			sn = cm.cfg.ServerAddr
@@ -451,10 +455,23 @@ func (cm *ConnectionManager) realConnect() (net.Conn, error) {
 	}
 	dialOptions := []libdial.DialOption{}
 	protocol := cm.cfg.Protocol
-	if protocol == "websocket" {
+	switch protocol {
+	case "websocket":
+		protocol = "tcp"
+		dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: utilnet.DialHookWebsocket(protocol, "")}))
+		dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{
+			Hook: utilnet.DialHookCustomTLSHeadByte(tlsConfig != nil, cm.cfg.DisableCustomTLSFirstByte),
+		}))
+		dialOptions = append(dialOptions, libdial.WithTLSConfig(tlsConfig))
+	case "wss":
 		protocol = "tcp"
-		dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: utilnet.DialHookWebsocket()}))
+		dialOptions = append(dialOptions, libdial.WithTLSConfigAndPriority(100, tlsConfig))
+		// Make sure that if it is wss, the websocket hook is executed after the tls hook.
+		dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: utilnet.DialHookWebsocket(protocol, tlsConfig.ServerName), Priority: 110}))
+	default:
+		dialOptions = append(dialOptions, libdial.WithTLSConfig(tlsConfig))
 	}
+
 	if cm.cfg.ConnectServerLocalIP != "" {
 		dialOptions = append(dialOptions, libdial.WithLocalAddr(cm.cfg.ConnectServerLocalIP))
 	}
@@ -464,10 +481,6 @@ func (cm *ConnectionManager) realConnect() (net.Conn, error) {
 		libdial.WithKeepAlive(time.Duration(cm.cfg.DialServerKeepAlive)*time.Second),
 		libdial.WithProxy(proxyType, addr),
 		libdial.WithProxyAuth(auth),
-		libdial.WithTLSConfig(tlsConfig),
-		libdial.WithAfterHook(libdial.AfterHook{
-			Hook: utilnet.DialHookCustomTLSHeadByte(tlsConfig != nil, cm.cfg.DisableCustomTLSFirstByte),
-		}),
 	)
 	conn, err := libdial.DialContext(
 		cm.ctx,
diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go
index 515a1936586..82c61720bba 100644
--- a/cmd/frpc/sub/root.go
+++ b/cmd/frpc/sub/root.go
@@ -76,7 +76,8 @@ var (
 	bindAddr           string
 	bindPort           int
 
-	tlsEnable bool
+	tlsEnable     bool
+	tlsServerName string
 )
 
 func init() {
@@ -88,13 +89,14 @@ func init() {
 func RegisterCommonFlags(cmd *cobra.Command) {
 	cmd.PersistentFlags().StringVarP(&serverAddr, "server_addr", "s", "127.0.0.1:7000", "frp server's address")
 	cmd.PersistentFlags().StringVarP(&user, "user", "u", "", "user")
-	cmd.PersistentFlags().StringVarP(&protocol, "protocol", "p", "tcp", "tcp or kcp or websocket")
+	cmd.PersistentFlags().StringVarP(&protocol, "protocol", "p", "tcp", "tcp, kcp, quic, websocket, wss")
 	cmd.PersistentFlags().StringVarP(&token, "token", "t", "", "auth token")
 	cmd.PersistentFlags().StringVarP(&logLevel, "log_level", "", "info", "log level")
 	cmd.PersistentFlags().StringVarP(&logFile, "log_file", "", "console", "console or file path")
 	cmd.PersistentFlags().IntVarP(&logMaxDays, "log_max_days", "", 3, "log file reversed days")
 	cmd.PersistentFlags().BoolVarP(&disableLogColor, "disable_log_color", "", false, "disable log color in console")
 	cmd.PersistentFlags().BoolVarP(&tlsEnable, "tls_enable", "", true, "enable frpc tls")
+	cmd.PersistentFlags().StringVarP(&tlsServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate")
 	cmd.PersistentFlags().StringVarP(&dnsServer, "dns_server", "", "", "specify dns server instead of using system default one")
 }
 
@@ -186,6 +188,7 @@ func parseClientCommonCfgFromCmd() (cfg config.ClientCommonConf, err error) {
 	cfg.ClientConfig = auth.GetDefaultClientConf()
 	cfg.Token = token
 	cfg.TLSEnable = tlsEnable
+	cfg.TLSServerName = tlsServerName
 
 	cfg.Complete()
 	if err = cfg.Validate(); err != nil {
diff --git a/conf/frpc_full.ini b/conf/frpc_full.ini
index 4982f4fbf17..f8eca6b774c 100644
--- a/conf/frpc_full.ini
+++ b/conf/frpc_full.ini
@@ -95,7 +95,7 @@ user = your_name
 login_fail_exit = true
 
 # communication protocol used to connect to server
-# supports tcp, kcp, quic and websocket now, default is tcp
+# supports tcp, kcp, quic, websocket and wss now, default is tcp
 protocol = tcp
 
 # set client binding ip when connect server, default is empty.
diff --git a/go.mod b/go.mod
index 86801843a12..b42d8801d2e 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
 	github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
 	github.com/coreos/go-oidc/v3 v3.4.0
 	github.com/fatedier/beego v0.0.0-20171024143340-6c6a4f5bd5eb
-	github.com/fatedier/golib v0.1.1-0.20230320133937-a7edcc8c793d
+	github.com/fatedier/golib v0.1.1-0.20230628070619-a1a0c648236a
 	github.com/fatedier/kcp-go v2.0.4-0.20190803094908-fe8645b0a904+incompatible
 	github.com/go-playground/validator/v10 v10.11.0
 	github.com/google/uuid v1.3.0
@@ -75,3 +75,6 @@ require (
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	k8s.io/utils v0.0.0-20221107191617-1a15be271d1d // indirect
 )
+
+// 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-20230628132301-7aca4898904d
diff --git a/go.sum b/go.sum
index 7950ad621fa..c259697ed5c 100644
--- a/go.sum
+++ b/go.sum
@@ -121,10 +121,12 @@ github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/fatedier/beego v0.0.0-20171024143340-6c6a4f5bd5eb h1:wCrNShQidLmvVWn/0PikGmpdP0vtQmnvyRg3ZBEhczw=
 github.com/fatedier/beego v0.0.0-20171024143340-6c6a4f5bd5eb/go.mod h1:wx3gB6dbIfBRcucp94PI9Bt3I0F2c/MyNEWuhzpWiwk=
-github.com/fatedier/golib v0.1.1-0.20230320133937-a7edcc8c793d h1:/m9Atycn9uKRwwOkxv4c+zaugxRgkdSG/Eg3IJWOpNs=
-github.com/fatedier/golib v0.1.1-0.20230320133937-a7edcc8c793d/go.mod h1:Wdn1pJ0dHB1lah6FPYwt4AO9NEmWI0OzW13dpzC9g4E=
+github.com/fatedier/golib v0.1.1-0.20230628070619-a1a0c648236a h1:HiRTFdy3ary86Vi2nsoINy2/YgjDPQ+21j3ikwJSD2E=
+github.com/fatedier/golib v0.1.1-0.20230628070619-a1a0c648236a/go.mod h1:Wdn1pJ0dHB1lah6FPYwt4AO9NEmWI0OzW13dpzC9g4E=
 github.com/fatedier/kcp-go v2.0.4-0.20190803094908-fe8645b0a904+incompatible h1:ssXat9YXFvigNge/IkkZvFMn8yeYKFX+uI6wn2mLJ74=
 github.com/fatedier/kcp-go v2.0.4-0.20190803094908-fe8645b0a904+incompatible/go.mod h1:YpCOaxj7vvMThhIQ9AfTOPW2sfztQR5WDfs7AflSy4s=
+github.com/fatedier/yamux v0.0.0-20230628132301-7aca4898904d h1:ynk1ra0RUqDWQfvFi5KtMiSobkVQ3cNc0ODb8CfIETo=
+github.com/fatedier/yamux v0.0.0-20230628132301-7aca4898904d/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -270,8 +272,6 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
 github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
 github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
-github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
-github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
diff --git a/pkg/config/client.go b/pkg/config/client.go
index b60e480158b..029470dc1de 100644
--- a/pkg/config/client.go
+++ b/pkg/config/client.go
@@ -20,6 +20,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/samber/lo"
 	"gopkg.in/ini.v1"
 
 	"github.com/fatedier/frp/pkg/auth"
@@ -117,7 +118,7 @@ type ClientCommonConf struct {
 	Start []string `ini:"start" json:"start"`
 	// Start map[string]struct{} `json:"start"`
 	// Protocol specifies the protocol to use when interacting with the server.
-	// Valid values are "tcp", "kcp", "quic" and "websocket". By default, this value
+	// Valid values are "tcp", "kcp", "quic", "websocket" and "wss". By default, this value
 	// is "tcp".
 	Protocol string `ini:"protocol" json:"protocol"`
 	// QUIC protocol options
@@ -230,7 +231,7 @@ func (cfg *ClientCommonConf) Validate() error {
 		}
 	}
 
-	if cfg.Protocol != "tcp" && cfg.Protocol != "kcp" && cfg.Protocol != "websocket" && cfg.Protocol != "quic" {
+	if !lo.Contains([]string{"tcp", "kcp", "quic", "websocket", "wss"}, cfg.Protocol) {
 		return fmt.Errorf("invalid protocol")
 	}
 
diff --git a/pkg/util/net/dial.go b/pkg/util/net/dial.go
index 251ebbff7ae..bc670643a0b 100644
--- a/pkg/util/net/dial.go
+++ b/pkg/util/net/dial.go
@@ -21,9 +21,15 @@ func DialHookCustomTLSHeadByte(enableTLS bool, disableCustomTLSHeadByte bool) li
 	}
 }
 
-func DialHookWebsocket() libdial.AfterHookFunc {
+func DialHookWebsocket(protocol string, host string) libdial.AfterHookFunc {
 	return func(ctx context.Context, c net.Conn, addr string) (context.Context, net.Conn, error) {
-		addr = "ws://" + addr + FrpWebsocketPath
+		if protocol != "wss" {
+			protocol = "ws"
+		}
+		if host == "" {
+			host = addr
+		}
+		addr = protocol + "://" + host + FrpWebsocketPath
 		uri, err := url.Parse(addr)
 		if err != nil {
 			return nil, nil, err


Test patch:
diff --git a/test/e2e/basic/basic.go b/test/e2e/basic/basic.go
index d9deafb7c87..d032a0d9a4d 100644
--- a/test/e2e/basic/basic.go
+++ b/test/e2e/basic/basic.go
@@ -331,24 +331,29 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
 					proxyExtraConfig   string
 					visitorExtraConfig string
 					expectError        bool
-					user2              bool
+					deployUser2Client  bool
+					// skipXTCP is used to skip xtcp test case
+					skipXTCP bool
 				}{
 					{
 						proxyName:    "normal",
 						bindPortName: port.GenName("Normal"),
 						visitorSK:    correctSK,
+						skipXTCP:     true,
 					},
 					{
 						proxyName:         "with-encryption",
 						bindPortName:      port.GenName("WithEncryption"),
 						visitorSK:         correctSK,
 						commonExtraConfig: "use_encryption = true",
+						skipXTCP:          true,
 					},
 					{
 						proxyName:         "with-compression",
 						bindPortName:      port.GenName("WithCompression"),
 						visitorSK:         correctSK,
 						commonExtraConfig: "use_compression = true",
+						skipXTCP:          true,
 					},
 					{
 						proxyName:    "with-encryption-and-compression",
@@ -371,7 +376,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
 						visitorSK:          correctSK,
 						proxyExtraConfig:   "allow_users = another, user2",
 						visitorExtraConfig: "server_user = user1",
-						user2:              true,
+						deployUser2Client:  true,
 					},
 					{
 						proxyName:          "not-allowed-user",
@@ -387,7 +392,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
 						visitorSK:          correctSK,
 						proxyExtraConfig:   "allow_users = *",
 						visitorExtraConfig: "server_user = user1",
-						user2:              true,
+						deployUser2Client:  true,
 					},
 				}
 
@@ -399,7 +404,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
 					config := getProxyVisitorConf(
 						test.proxyName, test.bindPortName, test.visitorSK, test.commonExtraConfig+"\n"+test.visitorExtraConfig,
 					) + "\n"
-					if test.user2 {
+					if test.deployUser2Client {
 						clientUser2VisitorConf += config
 					} else {
 						clientVisitorConf += config
@@ -411,7 +416,10 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
 				for _, test := range tests {
 					timeout := time.Second
 					if t == "xtcp" {
-						timeout = 4 * time.Second
+						if test.skipXTCP {
+							continue
+						}
+						timeout = 10 * time.Second
 					}
 					framework.NewRequestExpect(f).
 						RequestModify(func(r *request.Request) {
diff --git a/test/e2e/basic/client_server.go b/test/e2e/basic/client_server.go
index a02e4544a5b..e7730f459ce 100644
--- a/test/e2e/basic/client_server.go
+++ b/test/e2e/basic/client_server.go
@@ -3,6 +3,7 @@ package basic
 import (
 	"fmt"
 	"strings"
+	"time"
 
 	"github.com/onsi/ginkgo/v2"
 
@@ -13,9 +14,13 @@ import (
 )
 
 type generalTestConfigures struct {
-	server      string
-	client      string
-	expectError bool
+	server        string
+	client        string
+	clientPrefix  string
+	client2       string
+	client2Prefix string
+	testDelay     time.Duration
+	expectError   bool
 }
 
 func renderBindPortConfig(protocol string) string {
@@ -30,6 +35,9 @@ func renderBindPortConfig(protocol string) string {
 func runClientServerTest(f *framework.Framework, configures *generalTestConfigures) {
 	serverConf := consts.DefaultServerConfig
 	clientConf := consts.DefaultClientConfig
+	if configures.clientPrefix != "" {
+		clientConf = configures.clientPrefix
+	}
 
 	serverConf += fmt.Sprintf(`
 				%s
@@ -54,7 +62,23 @@ func runClientServerTest(f *framework.Framework, configures *generalTestConfigur
 		framework.UDPEchoServerPort, udpPortName,
 	)
 
-	f.RunProcesses([]string{serverConf}, []string{clientConf})
+	clientConfs := []string{clientConf}
+	if configures.client2 != "" {
+		client2Conf := consts.DefaultClientConfig
+		if configures.client2Prefix != "" {
+			client2Conf = configures.client2Prefix
+		}
+		client2Conf += fmt.Sprintf(`
+			%s
+		`, configures.client2)
+		clientConfs = append(clientConfs, client2Conf)
+	}
+
+	f.RunProcesses([]string{serverConf}, clientConfs)
+
+	if configures.testDelay > 0 {
+		time.Sleep(configures.testDelay)
+	}
 
 	framework.NewRequestExpect(f).PortName(tcpPortName).ExpectError(configures.expectError).Explain("tcp proxy").Ensure()
 	framework.NewRequestExpect(f).Protocol("udp").
@@ -84,6 +108,33 @@ var _ = ginkgo.Describe("[Feature: Client-Server]", func() {
 		}
 	})
 
+	// wss is special, it needs to be tested separately.
+	// frps only supports ws, so there should be a proxy to terminate TLS before frps.
+	ginkgo.Describe("Protocol wss", func() {
+		wssPort := f.AllocPort()
+		configures := &generalTestConfigures{
+			clientPrefix: fmt.Sprintf(`
+				[common]
+				server_addr = 127.0.0.1
+				server_port = %d
+				protocol = wss
+				log_level = trace
+				login_fail_exit = false
+			`, wssPort),
+			// Due to the fact that frps cannot directly accept wss connections, we use the https2http plugin of another frpc to terminate TLS.
+			client2: fmt.Sprintf(`
+				[wss2ws]
+				type = tcp
+				remote_port = %d
+				plugin = https2http
+				plugin_local_addr = 127.0.0.1:{{ .%s }}
+			`, wssPort, consts.PortServerName),
+			testDelay: 10 * time.Second,
+		}
+
+		defineClientServerTest("wss", f, configures)
+	})
+
 	ginkgo.Describe("Authentication", func() {
 		defineClientServerTest("Token Correct", f, &generalTestConfigures{
 			server: "token = 123456",