Compare commits

...

22 Commits

Author SHA1 Message Date
fatedier
68509f5d44 Add frps proxy traffic API v2 (#5398) 2026-07-08 02:05:28 +08:00
fatedier
5cd722b177 feat: add system prune API v2 (#5395) 2026-07-07 13:00:53 +08:00
fatedier
5876beceac feat: add system info API v2 (#5394) 2026-07-07 02:00:44 +08:00
fatedier
7fe152e3aa web/frps: use API v2 for client and proxy details (#5386) 2026-06-30 01:33:57 +08:00
fatedier
7c343fc6e7 web: bump esbuild to 0.28.1 and remove unused ElPopover type (#5385) 2026-06-29 23:21:22 +08:00
fatedier
a3b3b35b69 feat(ui): default proxies view to all tab (#5384) 2026-06-29 22:49:08 +08:00
fatedier
ae1c0504ec feat(dashboard): add v2 client detail status (#5381) 2026-06-26 21:15:30 +08:00
fatedier
393a533744 docs: add release note for duplicate config names (#5379) 2026-06-24 15:03:22 +08:00
MAAZIZ Adel Ayoub
035889c360 fix(client): reject duplicate proxy and visitor names (#5378) 2026-06-24 13:37:29 +08:00
fatedier
940bde5c46 docs: update release notes (#5377) 2026-06-23 17:06:16 +08:00
fatedier
14628df63c test: cover tls2raw proxy protocol header (#5376) 2026-06-23 00:04:38 +08:00
Shani Pathak
ba7adcab8f fix(websocket): send tunnel payload as binary frames (#5363)
The ws/wss transport carries a raw byte stream (yamux), but the
golang.org/x/net/websocket Conn defaults to text frames (PayloadType
TextFrame). Per RFC 6455 §5.6 a text frame must contain valid UTF-8, so
RFC-compliant intermediaries (API gateways / reverse proxies) validate
the payload and close the connection when the binary tunnel data is not
valid UTF-8.

This goes unnoticed peer-to-peer because x/net/websocket does not
validate UTF-8 on read, but it breaks the connection through a compliant
validating proxy. Set PayloadType to BinaryFrame on both the server
listener and the client dialer so the tunnel is framed as binary.
2026-06-22 23:34:02 +08:00
kaixings
4cc826e236 fix(client): write proxy protocol header in tls2raw plugin (#5362)
Co-authored-by: futrobo <futrobo@163.com>
2026-06-22 23:08:50 +08:00
fatedier
54c6ccdfec feat: remove proxies client filter (#5375) 2026-06-22 22:53:57 +08:00
fatedier
9bde0b07de feat: paginate dashboard clients and proxies via API v2 (#5354)
Move the frps dashboard Clients and Proxies views to the paginated
/api/v2/clients and /api/v2/proxies endpoints instead of fetching all
data at once, and extend server-side proxy search so the search box
keeps working under pagination.

Frontend:
- Add V2Envelope/V2Page types and getV2 HTTP helper to api/http.ts
- Add v2 paginated fetch functions to api/client.ts and api/proxy.ts
- Add ClientV2Info and ProxyV2Info types for v2 API responses
- Rewrite Clients.vue with server-side pagination, status/user search
  filtering, and ElPagination component
- Rewrite Proxies.vue with server-side pagination, type tabs, client
  dropdown filter, and a search box that passes q to the API
- Default page size 10, selectable sizes [10, 20, 50, 100]

Backend:
- Extend /api/v2/proxies q matching to also cover online proxy spec
  fields: TCP/UDP remotePort and HTTP/HTTPS/TCPMux customDomains and
  subdomain, so dashboard search no longer needs to scan every page
- Add controller_v2 tests for the new spec-field matching
2026-06-03 14:08:45 +08:00
fatedier
c6c545289c fix: normalize web package lockfile (#5353) 2026-06-02 13:39:37 +08:00
fatedier
503afe78b7 feat: add dashboard API v2 pagination endpoints (#5351) 2026-06-01 20:09:25 +08:00
fatedier
9ea1d86f03 test: handle wire v2 compatibility baselines (#5349) 2026-06-01 17:52:57 +08:00
fatedier
ac3e82db4e Release v0.69.1 (#5348) 2026-06-01 16:22:34 +08:00
fatedier
0773938d70 feat: bridge mixed wire protocol SUDP payloads (#5347)
SUDP payload codec follows transport wireProtocol; same-protocol v1/v1 and v2/v2 keep raw join; only mixed proxy/visitor protocols use message-aware bridge; no new capability/selection field.
2026-06-01 16:22:34 +08:00
fatedier
9bacce22a2 feat: use wire v2 framing for XTCP NatHoleSid (#5343) 2026-06-01 16:22:34 +08:00
fatedier
7f8d68b666 feat: use wire v2 framing for UDP workConn payload (#5340) 2026-06-01 16:22:34 +08:00
53 changed files with 3593 additions and 837 deletions

View File

@@ -13,6 +13,16 @@ frp is an open source project with its ongoing development made possible entirel
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
@@ -30,16 +40,6 @@ frp is an open source project with its ongoing development made possible entirel
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end-->
## What is frp?

View File

@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
@@ -32,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end-->
## 为什么使用 frp

View File

@@ -1,21 +1,10 @@
## Compatibility Policy
Starting with v0.69.0, each minor release is supported until there are nine newer minor releases. For example, v0.69.0 will be supported until v0.78.0 is released. Within this window, frpc v0.69.0 is guaranteed to work with any frps from v0.61.0 to v0.77.0, and vice versa. Patch releases within the same minor are always compatible. Versions outside the support window may continue to work on a best-effort basis, but compatibility is no longer guaranteed.
For mixed-version deployments, upgrade frps first, then upgrade frpc. This keeps the server side ready for newer client-side protocol behavior before clients start using it.
## Notes
This release introduces wire protocol v2 as a transition path for future frpc/frps protocol changes. The existing wire protocol is difficult to extend without compatibility risk, and upcoming changes, including replacing deprecated stream encryption methods, require a versioned protocol.
**The default value of `transport.wireProtocol` remains `v1` in this release.** Users can keep the default for now. To test v2 early, upgrade both frpc and frps to versions that support it, then set `transport.wireProtocol = "v2"` in frpc. A v2-enabled frpc cannot connect to an older frps.
When `transport.wireProtocol = "v2"` is enabled, the control channel uses negotiated AEAD encryption after the login handshake. Both frpc and frps must be upgraded to this release to use v2.
v1 will be deprecated when v2 becomes the default in a future release. It will continue to be supported until v0.78.0 is released, and may be removed in v0.78.0 or later.
## Features
* Added `transport.wireProtocol` for frpc to select the internal message protocol used between frpc and frps. Supported values are `v1` and `v2`.
* Added client protocol visibility in the frps dashboard and `/api/clients` API. Online clients now report their negotiated protocol as `v1` or `v2`.
* Wire protocol v2 now negotiates AEAD control-channel encryption. Supported algorithms are `xchacha20-poly1305` and `aes-256-gcm`; frpc advertises its preferred order based on local AES-GCM hardware support, and frps selects the first supported algorithm from that list.
* Added dashboard API v2 pagination endpoints for users, clients, and proxies.
* The frps dashboard Clients and Proxies pages now use API v2 pagination and server-side search, including proxy type filtering and searchable proxy spec fields such as remote ports, custom domains, and subdomains.
## Fixes
* WebSocket and WSS tunnel payloads are now sent as binary frames, avoiding disconnects through RFC-compliant intermediaries that validate text frames as UTF-8.
* The `tls2raw` client plugin now writes the proxy protocol header to the local raw connection when proxy protocol is enabled.
* frpc now rejects duplicate proxy and visitor names in config files instead of silently overwriting earlier entries.

View File

@@ -87,6 +87,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
readCh := make(chan *msg.UDPPacket, 1024)
sendCh := make(chan msg.Message, 1024)
isClose := false
@@ -109,7 +110,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
// udp service <- frpc <- frps <- frpc visitor <- user
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
defer closeFn()
for {
@@ -122,7 +123,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
var udpMsg msg.UDPPacket
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
xl.Warnf("read from workConn for sudp error: %v", errRet)
return
}
@@ -137,7 +138,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
// udp service -> frpc -> frps -> frpc visitor -> user
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
defer func() {
closeFn()
xl.Infof("writer goroutine for sudp work connection closed")
@@ -148,12 +149,12 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
switch m := rawMsg.(type) {
case *msg.UDPPacket:
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())
m.LocalAddr.String(), m.RemoteAddr.String(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
case *msg.Ping:
xl.Tracef("frpc send ping message to frpc visitor")
}
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
xl.Errorf("sudp work write error: %v", errRet)
return
}
@@ -184,8 +185,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
go workConnSenderFn(workConn, sendCh)
go workConnReaderFn(workConn, readCh)
go workConnSenderFn(payloadConn, sendCh)
go workConnReaderFn(payloadConn, readCh)
go heartbeatFn(sendCh)
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)

View File

@@ -99,15 +99,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
pxy.mu.Lock()
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
// Plain UDP payload follows the configured wire protocol for message framing.
payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol)
pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024)
pxy.closed = false
pxy.mu.Unlock()
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
for {
var udpMsg msg.UDPPacket
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
xl.Warnf("read from workConn for udp error: %v", errRet)
return
}
@@ -120,7 +122,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
}
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
workConnSenderFn := func(rw msg.ReadWriter, sendCh chan msg.Message) {
defer func() {
xl.Infof("writer goroutine for udp work connection closed")
}()
@@ -132,7 +134,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
case *msg.Ping:
xl.Tracef("send ping message to udp workConn")
}
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
if errRet = rw.WriteMsg(rawMsg); errRet != nil {
xl.Errorf("udp work write error: %v", errRet)
return
}
@@ -151,8 +153,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
go workConnSenderFn(pxy.workConn, pxy.sendCh)
go workConnReaderFn(pxy.workConn, pxy.readCh)
go workConnSenderFn(payloadRW, pxy.sendCh)
go workConnReaderFn(payloadRW, pxy.readCh)
go heartbeatFn(pxy.sendCh)
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)

View File

@@ -57,8 +57,7 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
xl := pxy.xl
defer conn.Close()
var natHoleSidMsg msg.NatHoleSid
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
if err != nil {
xl.Errorf("xtcp read from workConn error: %v", err)
return
@@ -131,6 +130,15 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
}
func readNatHoleSid(conn net.Conn, wireProtocol string) (*msg.NatHoleSid, error) {
workMsgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wireProtocol))
var natHoleSidMsg msg.NatHoleSid
if err := workMsgConn.ReadMsgInto(&natHoleSidMsg); err != nil {
return nil, err
}
return &natHoleSidMsg, nil
}
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
xl := pxy.xl
listenConn.Close()

66
client/proxy/xtcp_test.go Normal file
View File

@@ -0,0 +1,66 @@
// 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.
//go:build !frps
package proxy
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestReadNatHoleSidUsesSelectedWireProtocol(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
}{
{name: "v2", wireProtocol: wire.ProtocolV2},
{name: "v1", wireProtocol: wire.ProtocolV1},
{name: "default", wireProtocol: ""},
} {
t.Run(tc.name, func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setPipeDeadline(t, client, server)
errCh := make(chan error, 1)
go func() {
writer := msg.NewConn(server, msg.NewReadWriter(server, tc.wireProtocol))
errCh <- writer.WriteMsg(&msg.NatHoleSid{Sid: "sid"})
}()
out, err := readNatHoleSid(client, tc.wireProtocol)
require.NoError(t, err)
require.Equal(t, "sid", out.Sid)
require.NoError(t, <-errCh)
})
}
}
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
t.Helper()
deadline := time.Now().Add(time.Second)
for _, conn := range conns {
require.NoError(t, conn.SetDeadline(deadline))
}
}

View File

@@ -113,15 +113,16 @@ func (sv *SUDPVisitor) dispatcher() {
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl := xlog.FromContextSafe(sv.ctx)
xl.Debugf("starting sudp proxy worker")
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
wg := &sync.WaitGroup{}
wg.Add(2)
closeCh := make(chan struct{})
// udp service -> frpc -> frps -> frpc visitor -> user
workConnReaderFn := func(conn net.Conn) {
workConnReaderFn := func(payloadConn *msg.Conn) {
defer func() {
conn.Close()
payloadConn.Close()
close(closeCh)
wg.Done()
}()
@@ -133,13 +134,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
)
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
return
}
_ = conn.SetReadDeadline(time.Time{})
_ = payloadConn.SetReadDeadline(time.Time{})
switch m := rawMsg.(type) {
case *msg.Ping:
xl.Debugf("frpc visitor get ping message from frpc")
@@ -157,15 +158,15 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
}
// udp service <- frpc <- frps <- frpc visitor <- user
workConnSenderFn := func(conn net.Conn) {
workConnSenderFn := func(payloadConn *msg.Conn) {
defer func() {
conn.Close()
payloadConn.Close()
wg.Done()
}()
var errRet error
if firstPacket != nil {
if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
@@ -180,7 +181,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
return
}
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
@@ -191,8 +192,8 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
}
}
go workConnReaderFn(workConn)
go workConnSenderFn(workConn)
go workConnReaderFn(payloadConn)
go workConnSenderFn(payloadConn)
wg.Wait()
xl.Infof("sudp worker is closed")

View File

@@ -394,6 +394,10 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
}
}
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
return nil, err
}
return result, nil
}
@@ -417,6 +421,31 @@ func LoadClientConfig(path string, strict bool) (
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
}
// validateNoDuplicateNames rejects proxies or visitors that share a name. They are
// keyed by name in the config sources, so a duplicate would otherwise be silently
// overwritten and never started, with no error or log.
func validateNoDuplicateNames(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
proxyNames := make(map[string]struct{}, len(proxies))
for _, p := range proxies {
name := p.GetBaseConfig().Name
if _, ok := proxyNames[name]; ok {
return fmt.Errorf("proxy name [%s] is duplicated", name)
}
proxyNames[name] = struct{}{}
}
visitorNames := make(map[string]struct{}, len(visitors))
for _, v := range visitors {
name := v.GetBaseConfig().Name
if _, ok := visitorNames[name]; ok {
return fmt.Errorf("visitor name [%s] is duplicated", name)
}
visitorNames[name] = struct{}{}
}
return nil
}
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
proxyCfgs := proxies
for _, c := range proxyCfgs {

View File

@@ -17,6 +17,8 @@ package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -462,6 +464,111 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
require.Equal("keep", proxies[0].GetBaseConfig().Name)
}
func TestLoadClientConfigResult_DuplicateNames(t *testing.T) {
tests := []struct {
name string
content string
errSubstr string
}{
{
name: "duplicate proxy names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "dup"
type = "tcp"
localPort = 22
remotePort = 6000
[[proxies]]
name = "dup"
type = "tcp"
localPort = 3306
remotePort = 6001
`,
errSubstr: "proxy name [dup] is duplicated",
},
{
name: "duplicate visitor names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[visitors]]
name = "dup"
type = "stcp"
serverName = "a"
secretKey = "secret"
bindPort = 9001
[[visitors]]
name = "dup"
type = "stcp"
serverName = "b"
secretKey = "secret"
bindPort = 9002
`,
errSubstr: "visitor name [dup] is duplicated",
},
{
name: "unique names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "p1"
type = "tcp"
localPort = 22
remotePort = 6000
[[proxies]]
name = "p2"
type = "tcp"
localPort = 3306
remotePort = 6001
`,
},
{
name: "same name across proxy and visitor",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "same"
type = "tcp"
localPort = 22
remotePort = 6000
[[visitors]]
name = "same"
type = "stcp"
serverName = "a"
secretKey = "secret"
bindPort = 9001
`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "frpc.toml")
require.NoError(os.WriteFile(path, []byte(tc.content), 0o600))
_, err := LoadClientConfigResult(path, false)
if tc.errSubstr == "" {
require.NoError(err)
} else {
require.ErrorContains(err, tc.errSubstr)
}
})
}
}
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
func TestYAMLEdgeCases(t *testing.T) {
require := require.New(t)

View File

@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
defer m.mu.Unlock()
total = len(m.info.ProxyStatistics)
for name, data := range m.info.ProxyStatistics {
if !data.LastCloseTime.IsZero() &&
data.LastStartTime.Before(data.LastCloseTime) &&
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
delete(m.info.ProxyStatistics, name)
count++
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
@@ -106,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
return count, total
}
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
return !data.LastCloseTime.IsZero() &&
data.LastStartTime.Before(data.LastCloseTime) &&
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
}
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
return m.clearUselessInfo(0)
}
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
return m.clearUselessInfo(0)
}
func (m *serverMetrics) NewClient() {
m.info.ClientCounts.Inc(1)
}

View File

@@ -43,6 +43,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
require.Empty(metrics.info.ProxyStatistics)
}
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
cleared, total := metrics.ClearOfflineProxies()
require.Equal(1, cleared)
require.Equal(2, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
}
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
Name: "restarted",
LastStartTime: start.Add(30 * time.Second),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
Name: "same-time",
LastStartTime: start,
LastCloseTime: start,
}
cleared, total := metrics.PruneOfflineProxies()
require.Equal(1, cleared)
require.Equal(4, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
require.True(metrics.hasProxyStatistics("restarted"))
require.True(metrics.hasProxyStatistics("same-time"))
cleared, total = metrics.PruneOfflineProxies()
require.Equal(0, cleared)
require.Equal(3, total)
}
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
require := require.New(t)

View File

@@ -85,4 +85,5 @@ type Collector interface {
GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int)
PruneOfflineProxies() (int, int)
}

View File

@@ -43,9 +43,28 @@ func TestV2ReadWriterRoundTrip(t *testing.T) {
func TestNewReadWriter(t *testing.T) {
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV1))
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, "unknown"))
require.IsType(t, &V2ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV2))
}
func TestNewReadWriterEncoding(t *testing.T) {
for _, wireProtocol := range []string{"", wire.ProtocolV1} {
var legacy bytes.Buffer
legacyRW := NewReadWriter(&legacy, wireProtocol)
require.NoError(t, legacyRW.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
require.NotEmpty(t, legacy.Bytes())
require.Equal(t, TypeUDPPacket, legacy.Bytes()[0])
}
var v2 bytes.Buffer
v2RW := NewReadWriter(&v2, wire.ProtocolV2)
require.NoError(t, v2RW.WriteMsg(&UDPPacket{Content: []byte("v2")}))
frame, err := wire.NewConn(&v2).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestV2MessageTypeIDsAreStable(t *testing.T) {
require.Equal(t, uint16(1), V2TypeLogin)
require.Equal(t, uint16(2), V2TypeLoginResp)

View File

@@ -72,6 +72,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
return
}
if connInfo.ProxyProtocolHeader != nil {
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
rawConn.Close()
tlsConn.Close()
return
}
}
libio.Join(tlsConn, rawConn)
}

View File

@@ -26,6 +26,12 @@ type GeneralResponse struct {
Msg string
}
type V2Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
// APIHandler is a handler function that returns a response object or an error.
type APIHandler func(ctx *Context) (any, error)
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
}
}
}
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := NewContext(w, r)
res, err := handler(ctx)
if err != nil {
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
code := http.StatusInternalServerError
if e, ok := err.(*Error); ok {
code = e.Code
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
}
}

View File

@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
if err != nil {
return nil, nil, err
}
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
// frames and close the connection on invalid bytes.
conn.PayloadType = websocket.BinaryFrame
return ctx, conn, nil
}
}

View File

@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
muxer := http.NewServeMux()
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
// frames and close the connection on invalid bytes.
c.PayloadType = websocket.BinaryFrame
notifyCh := make(chan struct{})
conn := WrapCloseNotifyConn(c, func(_ error) {
close(notifyCh)

View File

@@ -14,7 +14,7 @@
package version
var version = "0.69.0"
var version = "0.69.1"
func Full() string {
return version

View File

@@ -48,6 +48,17 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
v2EncodedPathRouter.UseEncodedPath()
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(

View File

@@ -112,6 +112,8 @@ type SessionContext struct {
ServerCfg *v1.ServerConfig
// client registry
ClientRegistry *registry.ClientRegistry
// negotiated wire protocol for this client session
WireProtocol string
}
type Control struct {
@@ -452,6 +454,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
Configurer: pxyConf,
ServerCfg: ctl.sessionCtx.ServerCfg,
EncryptionKey: ctl.sessionCtx.EncryptionKey,
WireProtocol: ctl.sessionCtx.WireProtocol,
})
if err != nil {
return remoteAddr, err

View File

@@ -58,8 +58,12 @@ func NewController(
// /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
return c.buildServerInfoResp(), nil
}
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
serverStats := mem.StatsCollector.GetServer()
svrResp := model.ServerInfoResp{
return model.ServerInfoResp{
Version: version.Full(),
BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
@@ -80,8 +84,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts,
}
return svrResp, nil
}
// /api/clients

View File

@@ -0,0 +1,552 @@
// 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 http
import (
"cmp"
"fmt"
"math"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/server/http/model"
"github.com/fatedier/frp/server/registry"
)
const (
defaultV2Page = 1
defaultV2PageSize = 50
maxV2PageSize = 200
v2SystemPruneTypeOfflineProxies = "offline_proxies"
v2ProxyTrafficDefaultDays = 7
v2ProxyTrafficUnit = "bytes"
v2ProxyTrafficGranularity = "day"
)
var apiV2ProxyTypes = []string{
string(v1.ProxyTypeTCP),
string(v1.ProxyTypeUDP),
string(v1.ProxyTypeHTTP),
string(v1.ProxyTypeHTTPS),
string(v1.ProxyTypeTCPMUX),
string(v1.ProxyTypeSTCP),
string(v1.ProxyTypeXTCP),
string(v1.ProxyTypeSUDP),
}
// /api/v2/system/info
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
info := c.buildServerInfoResp()
proxyTypeCounts := info.ProxyTypeCounts
if proxyTypeCounts == nil {
proxyTypeCounts = map[string]int64{}
}
return model.V2SystemInfoResp{
Version: info.Version,
Config: model.V2SystemInfoConfigResp{
BindPort: info.BindPort,
VhostHTTPPort: info.VhostHTTPPort,
VhostHTTPSPort: info.VhostHTTPSPort,
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
KCPBindPort: info.KCPBindPort,
QUICBindPort: info.QUICBindPort,
SubdomainHost: info.SubdomainHost,
MaxPoolCount: info.MaxPoolCount,
MaxPortsPerClient: info.MaxPortsPerClient,
HeartbeatTimeout: info.HeartBeatTimeout,
AllowPortsStr: info.AllowPortsStr,
TLSForce: info.TLSForce,
},
Status: model.V2SystemInfoStatusResp{
TotalTrafficIn: info.TotalTrafficIn,
TotalTrafficOut: info.TotalTrafficOut,
CurConns: info.CurConns,
ClientCounts: info.ClientCounts,
ProxyTypeCounts: proxyTypeCounts,
},
}, nil
}
// /api/v2/system/prune
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
if err != nil {
return nil, err
}
cleared, total := mem.StatsCollector.PruneOfflineProxies()
return model.V2SystemPruneResp{
Type: pruneType,
Cleared: cleared,
Total: total,
}, nil
}
// /api/v2/users
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
if err != nil {
return nil, err
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
userStats := make(map[string]*model.V2UserResp)
for _, info := range c.clientRegistry.List() {
item := getOrCreateV2User(userStats, info.User)
item.ClientCount++
}
for _, proxyInfo := range c.listV2ProxyStats("") {
item := getOrCreateV2User(userStats, proxyInfo.User)
item.ProxyCount++
}
q := strings.ToLower(ctx.Query("q"))
items := make([]model.V2UserResp, 0, len(userStats))
for _, item := range userStats {
if q != "" && !strings.Contains(strings.ToLower(item.User), q) {
continue
}
items = append(items, *item)
}
slices.SortFunc(items, func(a, b model.V2UserResp) int {
return cmp.Compare(a.User, b.User)
})
return buildV2PageResp(items, page, pageSize), nil
}
// /api/v2/clients
func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
if err != nil {
return nil, err
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
if err != nil {
return nil, err
}
userFilter, filterByUser := queryValue(ctx, "user")
clientIDFilter := ctx.Query("clientID")
runIDFilter := ctx.Query("runID")
q := strings.ToLower(ctx.Query("q"))
records := c.clientRegistry.List()
items := make([]model.ClientInfoResp, 0, len(records))
for _, info := range records {
if filterByUser && info.User != userFilter {
continue
}
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
continue
}
if runIDFilter != "" && info.RunID != runIDFilter {
continue
}
if !matchV2StatusFilter(info.Online, statusFilter) {
continue
}
resp := buildClientInfoResp(info)
if q != "" && !matchV2ClientQuery(resp, q) {
continue
}
items = append(items, resp)
}
slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
return v
}
return cmp.Compare(a.Key, b.Key)
})
return buildV2PageResp(items, page, pageSize), nil
}
// /api/v2/clients/{key}
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
key, err := decodeV2PathParam(ctx, "key", "client key")
if err != nil {
return nil, err
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
info, ok := c.clientRegistry.GetByKey(key)
if !ok {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
}
resp := buildClientInfoResp(info)
status := c.buildV2ClientStatus(info)
return model.V2ClientDetailResp{
ClientInfoResp: resp,
Status: status,
}, nil
}
// /api/v2/proxies
func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
if err != nil {
return nil, err
}
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
if err != nil {
return nil, err
}
proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type"))
if err != nil {
return nil, err
}
userFilter, filterByUser := queryValue(ctx, "user")
clientIDFilter := ctx.Query("clientID")
q := strings.ToLower(ctx.Query("q"))
stats := c.listV2ProxyStats(proxyType)
items := make([]model.V2ProxyResp, 0, len(stats))
for _, ps := range stats {
resp := c.buildV2ProxyResp(ps)
if filterByUser && resp.User != userFilter {
continue
}
if clientIDFilter != "" && resp.ClientID != clientIDFilter {
continue
}
if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) {
continue
}
if q != "" && !matchV2ProxyQuery(resp, q) {
continue
}
items = append(items, resp)
}
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
if v := cmp.Compare(a.Type, b.Type); v != 0 {
return v
}
return cmp.Compare(a.Name, b.Name)
})
return buildV2PageResp(items, page, pageSize), nil
}
// /api/v2/proxies/{name}
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
name, err := decodeV2PathParam(ctx, "name", "proxy name")
if err != nil {
return nil, err
}
ps := mem.StatsCollector.GetProxyByName(name)
if ps == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
return c.buildV2ProxyResp(ps), nil
}
// /api/v2/proxies/{name}/traffic
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
name, err := decodeV2PathParam(ctx, "name", "proxy name")
if err != nil {
return nil, err
}
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
if proxyTrafficInfo == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
}
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
raw := ctx.Param(key)
if raw == "" {
return "", fmt.Errorf("missing %s", label)
}
decoded, err := url.PathUnescape(raw)
if err != nil {
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
}
return decoded, nil
}
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
item, ok := items[user]
if !ok {
item = &model.V2UserResp{User: user}
items[user] = item
}
return item
}
func parseV2PageParams(ctx *httppkg.Context) (int, int, error) {
page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page")
if err != nil {
return 0, 0, err
}
pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize")
if err != nil {
return 0, 0, err
}
if pageSize > maxV2PageSize {
return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize))
}
if page > math.MaxInt/pageSize {
return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large")
}
return page, pageSize, nil
}
func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) {
if raw == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < 1 {
return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name))
}
return value, nil
}
func parseV2StatusFilter(raw string) (string, error) {
status := strings.ToLower(raw)
switch status {
case "", "all", "online", "offline":
return status, nil
default:
return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline")
}
}
func parseV2ProxyTypeFilter(raw string) (string, error) {
proxyType := strings.ToLower(raw)
if proxyType == "" {
return "", nil
}
if slices.Contains(apiV2ProxyTypes, proxyType) {
return proxyType, nil
}
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
}
func parseV2SystemPruneType(raw string) (string, error) {
pruneType := strings.ToLower(raw)
switch pruneType {
case "":
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
case v2SystemPruneTypeOfflineProxies:
return pruneType, nil
default:
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
}
}
func matchV2StatusFilter(online bool, filter string) bool {
switch filter {
case "", "all":
return true
case "online":
return online
case "offline":
return !online
default:
return true
}
}
func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] {
total := len(items)
return model.V2PageResp[T]{
Total: total,
Page: page,
PageSize: pageSize,
Items: paginateV2Items(items, page, pageSize),
}
}
func paginateV2Items[T any](items []T, page, pageSize int) []T {
start := (page - 1) * pageSize
if start >= len(items) {
return []T{}
}
end := min(start+pageSize, len(items))
return items[start:end]
}
func queryValue(ctx *httppkg.Context, key string) (string, bool) {
values, ok := ctx.Req.URL.Query()[key]
if !ok {
return "", false
}
if len(values) == 0 {
return "", true
}
return values[0], true
}
func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
return containsV2Query(q,
item.Key,
item.User,
item.ClientID,
item.RunID,
item.Version,
item.WireProtocol,
item.Hostname,
item.ClientIP,
)
}
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
values := []string{
item.Name,
item.Type,
item.User,
item.ClientID,
item.Status.State,
}
switch spec := item.Spec.(type) {
case *model.TCPOutConf:
values = append(values, strconv.Itoa(spec.RemotePort))
case *model.UDPOutConf:
values = append(values, strconv.Itoa(spec.RemotePort))
case *model.HTTPOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
case *model.HTTPSOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
case *model.TCPMuxOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
}
return containsV2Query(q, values...)
}
func containsV2Query(q string, values ...string) bool {
for _, value := range values {
if strings.Contains(strings.ToLower(value), q) {
return true
}
}
return false
}
func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
if proxyType != "" {
return mem.StatsCollector.GetProxiesByType(proxyType)
}
items := make([]*mem.ProxyStats, 0)
for _, t := range apiV2ProxyTypes {
items = append(items, mem.StatsCollector.GetProxiesByType(t)...)
}
return items
}
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
history = append(history, model.V2ProxyTrafficPointResp{
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
})
}
return model.V2ProxyTrafficResp{
Name: name,
Unit: v2ProxyTrafficUnit,
Granularity: v2ProxyTrafficGranularity,
History: history,
}
}
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
if todayFirstIndex >= len(values) {
return 0
}
return values[todayFirstIndex]
}
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
status := model.V2ClientStatusResp{State: "offline"}
if info.Online {
status.State = "online"
}
user := info.User
clientID := info.ClientID()
for _, ps := range c.listV2ProxyStats("") {
if ps.User != user || ps.ClientID != clientID {
continue
}
status.CurConns += ps.CurConns
status.ProxyCount++
}
return status
}
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
state := "offline"
var spec any
if c.pxyManager != nil {
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
state = "online"
spec = getConfFromConfigurer(pxy.GetConfigurer())
}
}
return model.V2ProxyResp{
Name: ps.Name,
Type: ps.Type,
User: ps.User,
ClientID: ps.ClientID,
Spec: spec,
Status: model.V2ProxyStatusResp{
State: state,
TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns,
LastStartTime: ps.LastStartTime,
LastCloseTime: ps.LastCloseTime,
},
}
}

View File

@@ -0,0 +1,854 @@
// 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 http
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/server/http/model"
serverproxy "github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
type v2EnvelopeForTest[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
type fakeStatsCollector struct {
server *mem.ServerStats
proxies map[string]*mem.ProxyStats
traffic map[string]*mem.ProxyTrafficInfo
pruneable map[string]bool
}
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
if f.server != nil {
return f.server
}
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
}
func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
items := make([]*mem.ProxyStats, 0)
for _, ps := range f.proxies {
if ps.Type == proxyType {
items = append(items, ps)
}
}
return items
}
func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
ps := f.proxies[proxyName]
if ps != nil && ps.Type == proxyType {
return ps
}
return nil
}
func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
return f.proxies[proxyName]
}
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
return f.traffic[name]
}
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
return 0, len(f.proxies)
}
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
total := len(f.proxies)
cleared := 0
for name := range f.pruneable {
if _, ok := f.proxies[name]; ok {
delete(f.proxies, name)
cleared++
}
}
f.pruneable = map[string]bool{}
return cleared, total
}
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
server: &mem.ServerStats{
TotalTrafficIn: 1024,
TotalTrafficOut: 2048,
CurConns: 3,
ClientCounts: 4,
ProxyTypeCounts: map[string]int64{
"tcp": 2,
"http": 1,
},
},
proxies: map[string]*mem.ProxyStats{},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
VhostHTTPSPort: 8443,
TCPMuxHTTPConnectPort: 9000,
KCPBindPort: 7001,
QUICBindPort: 7002,
SubDomainHost: "example.com",
MaxPortsPerClient: 8,
AllowPorts: []types.PortsRange{
{Start: 1000, End: 1002},
{Single: 2000},
},
Transport: v1.ServerTransportConfig{
MaxPoolCount: 5,
HeartbeatTimeout: 90,
TLS: v1.TLSServerConfig{
Force: true,
},
},
}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/system/info")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
"allowPortsStr",
"bindPort",
"heartbeatTimeout",
"kcpBindPort",
"maxPoolCount",
"maxPortsPerClient",
"quicBindPort",
"subdomainHost",
"tcpmuxHTTPConnectPort",
"tlsForce",
"vhostHTTPPort",
"vhostHTTPSPort",
)
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
"clientCounts",
"curConns",
"proxyTypeCount",
"totalTrafficIn",
"totalTrafficOut",
)
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
if systemResp.Data.Version == "" {
t.Fatal("version should be set at top level")
}
if systemResp.Data.Config.BindPort != 7000 ||
systemResp.Data.Config.VhostHTTPPort != 8080 ||
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
systemResp.Data.Config.KCPBindPort != 7001 ||
systemResp.Data.Config.QUICBindPort != 7002 ||
systemResp.Data.Config.SubdomainHost != "example.com" ||
systemResp.Data.Config.MaxPoolCount != 5 ||
systemResp.Data.Config.MaxPortsPerClient != 8 ||
systemResp.Data.Config.HeartbeatTimeout != 90 ||
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
!systemResp.Data.Config.TLSForce {
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
}
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
systemResp.Data.Status.TotalTrafficOut != 2048 ||
systemResp.Data.Status.CurConns != 3 ||
systemResp.Data.Status.ClientCounts != 4 ||
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
}
}
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
oldStatsCollector := mem.StatsCollector
collector := &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
"http-offline": {Name: "http-offline", Type: "http"},
"udp-offline": {Name: "udp-offline", Type: "udp"},
"tcp-online": {Name: "tcp-online", Type: "tcp"},
"http-online": {Name: "http-online", Type: "http"},
"udp-online": {Name: "udp-online", Type: "udp"},
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
},
pruneable: map[string]bool{
"tcp-offline": true,
"http-offline": true,
"udp-offline": true,
},
}
mem.StatsCollector = collector
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
}
if _, ok := collector.proxies["tcp-offline"]; ok {
t.Fatal("pruned proxy statistics should be removed")
}
if _, ok := collector.proxies["tcp-online"]; !ok {
t.Fatal("online proxy statistics should remain")
}
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
if resp.Code != http.StatusOK {
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
}
}
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
}
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
if resp.Code != http.StatusBadRequest {
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp = decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
}
}
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", pageResp)
}
if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
t.Fatalf("page data mismatch: %#v", pageResp.Data)
}
if got := pageResp.Data.Items[0].User; got != "" {
t.Fatalf("first sorted user mismatch, want empty got %q", got)
}
resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
}
resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
}
resp = performRequest(router, "/api/v2/clients?status=offline")
offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
}
}
func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/clients?page=0")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
t.Fatalf("error envelope mismatch: %#v", errResp)
}
resp = performRequest(router, "/api/v2/clients?pageSize=201")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
}
}
func TestAPIV2ClientDetailEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/clients/alice.client-a")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
}
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
}
}
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-url": {
Name: "tcp-url",
Type: "tcp",
User: "url",
ClientID: "client/a?b#c",
CurConns: 7,
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
clientRegistry := registry.NewClientRegistry()
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
router := newV2TestRouter(controller)
encodedKey := url.PathEscape("url.client/a?b#c")
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
if resp.Code != http.StatusOK {
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
}
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
}
}
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies?type=invalid")
if resp.Code != http.StatusBadRequest {
t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
}
resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
if proxyResp.Data.Total != 1 {
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
}
proxyItem := proxyResp.Data.Items[0]
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
t.Fatalf("proxy item mismatch: %#v", proxyItem)
}
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
}
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
if userResp.Data.Total != 3 {
t.Fatalf("user total mismatch: %#v", userResp.Data)
}
expectedProxyCounts := map[string]int{
"": 1,
"alice": 2,
"bob": 1,
}
for _, item := range userResp.Data.Items {
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
t.Fatalf("user counts mismatch: %#v", item)
}
}
}
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"ssh": {Name: "ssh", Type: "tcp"},
},
traffic: map[string]*mem.ProxyTrafficInfo{
"ssh": {
Name: "ssh",
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
}
if len(trafficResp.Data.History) != 7 {
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
}
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
var prevDate time.Time
for i, point := range trafficResp.Data.History {
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
}
parsedDate, err := time.Parse(time.DateOnly, point.Date)
if err != nil {
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
}
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
}
prevDate = parsedDate
}
}
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
if resp.Code != http.StatusNotFound {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
t.Fatalf("not found envelope mismatch: %#v", errResp)
}
}
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
name := "folder/ssh?x#y"
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
name: {Name: name, Type: "tcp", User: "encoded"},
},
traffic: map[string]*mem.ProxyTrafficInfo{
name: {
Name: name,
TrafficIn: []int64{1},
TrafficOut: []int64{2},
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
encodedName := url.PathEscape(name)
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
if resp.Code != http.StatusOK {
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
}
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
if resp.Code != http.StatusOK {
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
if trafficResp.Data.Name != name {
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
}
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
}
}
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
controller := newV2TestController(t)
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
}
}
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
tests := []struct {
name string
item model.V2ProxyResp
q string
want bool
}{
{
name: "tcp remote port",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
RemotePort: 6000,
}},
q: "6000",
want: true,
},
{
name: "udp remote port",
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
RemotePort: 7000,
}},
q: "7000",
want: true,
},
{
name: "remote port does not match colon form",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
RemotePort: 6000,
}},
q: ":6000",
want: false,
},
{
name: "http custom domain",
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
}},
q: "app.example.com",
want: true,
},
{
name: "https subdomain",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
}},
q: "portal",
want: true,
},
{
name: "subdomain does not match expanded host",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
}},
q: "portal.example.com",
want: false,
},
{
name: "tcpmux custom domain",
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
}},
q: "mux.example.com",
want: true,
},
{
name: "nil spec does not match spec fields",
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
q: "6000",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
}
})
}
}
func TestLegacyAPIResponsesRemainBare(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/serverinfo")
var serverInfo model.ServerInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
}
if serverInfo.Version == "" {
t.Fatal("legacy serverinfo version should be set")
}
var serverInfoRaw map[string]json.RawMessage
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
}
if _, ok := serverInfoRaw["data"]; ok {
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
}
if _, ok := serverInfoRaw["config"]; ok {
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
}
resp = performRequest(router, "/api/clients")
var clients []model.ClientInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
}
if len(clients) != 3 {
t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
}
resp = performRequest(router, "/api/proxy/tcp")
var proxies model.GetProxyInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
}
if len(proxies.Proxies) != 2 {
t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
}
var envelope httppkg.V2Response
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
}
resp = performRequest(router, "/api/traffic/tcp-alice")
var traffic model.GetProxyTrafficResp
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
}
if traffic.Name != "tcp-alice" ||
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
}
var trafficRaw map[string]json.RawMessage
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
}
if _, ok := trafficRaw["data"]; ok {
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
}
}
func newV2TestController(t *testing.T) *Controller {
t.Helper()
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-empty": {
Name: "tcp-empty",
Type: "tcp",
User: "",
ClientID: "legacy-client",
TodayTrafficIn: 10,
TodayTrafficOut: 20,
CurConns: 1,
},
"tcp-alice": {
Name: "tcp-alice",
Type: "tcp",
User: "alice",
ClientID: "client-a",
TodayTrafficIn: 30,
TodayTrafficOut: 40,
CurConns: 2,
},
"http-alice": {
Name: "http-alice",
Type: "http",
User: "alice",
ClientID: "client-a",
CurConns: 3,
},
"udp-bob": {
Name: "udp-bob",
Type: "udp",
User: "bob",
ClientID: "client-b",
},
},
traffic: map[string]*mem.ProxyTrafficInfo{
"tcp-alice": {
Name: "tcp-alice",
TrafficIn: []int64{7, 6},
TrafficOut: []int64{70, 60},
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
clientRegistry := registry.NewClientRegistry()
clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
clientRegistry.MarkOfflineByRunID("run-b")
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
}
func newV2TestRouter(controller *Controller) *mux.Router {
router := mux.NewRouter()
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
encodedPathRouter := router.NewRoute().Subrouter()
encodedPathRouter.UseEncodedPath()
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
return router
}
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
return performRequestWithMethod(handler, http.MethodGet, target)
}
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
return resp
}
func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
}
return out
}
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
t.Helper()
if len(raw) != len(want) {
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
}
for _, key := range want {
if _, ok := raw[key]; !ok {
t.Fatalf("json key %q missing from %v", key, raw)
}
}
}
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
t.Helper()
var out map[string]json.RawMessage
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
}
assertRawJSONKeys(t, out, want...)
}
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
t.Helper()
out, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json failed: %v", err)
}
return out
}

105
server/http/model/v2.go Normal file
View File

@@ -0,0 +1,105 @@
// 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 model
type V2PageResp[T any] struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Items []T `json:"items"`
}
type V2SystemInfoResp struct {
Version string `json:"version"`
Config V2SystemInfoConfigResp `json:"config"`
Status V2SystemInfoStatusResp `json:"status"`
}
type V2SystemInfoConfigResp struct {
BindPort int `json:"bindPort"`
VhostHTTPPort int `json:"vhostHTTPPort"`
VhostHTTPSPort int `json:"vhostHTTPSPort"`
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
KCPBindPort int `json:"kcpBindPort"`
QUICBindPort int `json:"quicBindPort"`
SubdomainHost string `json:"subdomainHost"`
MaxPoolCount int64 `json:"maxPoolCount"`
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
AllowPortsStr string `json:"allowPortsStr"`
TLSForce bool `json:"tlsForce"`
}
type V2SystemInfoStatusResp struct {
TotalTrafficIn int64 `json:"totalTrafficIn"`
TotalTrafficOut int64 `json:"totalTrafficOut"`
CurConns int64 `json:"curConns"`
ClientCounts int64 `json:"clientCounts"`
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
type V2SystemPruneResp struct {
Type string `json:"type"`
Cleared int `json:"cleared"`
Total int `json:"total"`
}
type V2UserResp struct {
User string `json:"user"`
ClientCount int `json:"clientCount"`
ProxyCount int `json:"proxyCount"`
}
type V2ClientDetailResp struct {
ClientInfoResp
Status V2ClientStatusResp `json:"status"`
}
type V2ClientStatusResp struct {
State string `json:"phase"`
CurConns int64 `json:"curConns"`
ProxyCount int64 `json:"proxyCount"`
}
type V2ProxyResp struct {
Name string `json:"name"`
Type string `json:"type"`
User string `json:"user"`
ClientID string `json:"clientID"`
Spec any `json:"spec"`
Status V2ProxyStatusResp `json:"status"`
}
type V2ProxyStatusResp struct {
State string `json:"phase"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
}
type V2ProxyTrafficResp struct {
Name string `json:"name"`
Unit string `json:"unit"`
Granularity string `json:"granularity"`
History []V2ProxyTrafficPointResp `json:"history"`
}
type V2ProxyTrafficPointResp struct {
Date string `json:"date"`
TrafficIn int64 `json:"trafficIn"`
TrafficOut int64 `json:"trafficOut"`
}

View File

@@ -16,6 +16,7 @@ package proxy
import (
"context"
"errors"
"fmt"
"io"
"net"
@@ -31,6 +32,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
plugin "github.com/fatedier/frp/pkg/plugin/server"
"github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/util/limit"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/xlog"
@@ -92,6 +94,7 @@ type BaseProxy struct {
userInfo plugin.UserInfo
loginMsg *msg.Login
configurer v1.ProxyConfigurer
wireProtocol string
mu sync.RWMutex
xl *xlog.Logger
@@ -315,13 +318,147 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
name := pxy.GetName()
proxyType := cfg.Type
metrics.Server.OpenConnection(name, proxyType)
inCount, outCount, _ := libio.Join(local, userConn)
inCount, outCount, _ := pxy.joinUserConnection(local, userConn, proxyType, xl)
metrics.Server.CloseConnection(name, proxyType)
metrics.Server.AddTrafficIn(name, proxyType, inCount)
metrics.Server.AddTrafficOut(name, proxyType, outCount)
xl.Debugf("join connections closed")
}
func (pxy *BaseProxy) joinUserConnection(local io.ReadWriteCloser, userConn net.Conn, proxyType string, xl *xlog.Logger) (int64, int64, []error) {
visitorWireProtocol := wireProtocolFromConn(userConn)
if proxyType == string(v1.ProxyTypeSUDP) && isMixedWireProtocol(pxy.wireProtocol, visitorWireProtocol) {
xl.Infof("bridge mixed SUDP payload codecs, proxy wireProtocol [%s], visitor wireProtocol [%s]",
normalizeWireProtocol(pxy.wireProtocol), normalizeWireProtocol(visitorWireProtocol))
return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, visitorWireProtocol, xl)
}
return libio.Join(local, userConn)
}
type wireProtocolGetter interface {
WireProtocol() string
}
func wireProtocolFromConn(conn net.Conn) string {
if getter, ok := conn.(wireProtocolGetter); ok {
return getter.WireProtocol()
}
return ""
}
func isMixedWireProtocol(left, right string) bool {
return normalizeWireProtocol(left) != normalizeWireProtocol(right)
}
func normalizeWireProtocol(wireProtocol string) string {
if wireProtocol == wire.ProtocolV2 {
return wire.ProtocolV2
}
return wire.ProtocolV1
}
func joinSUDPMessageBridge(
proxyConn io.ReadWriteCloser,
visitorConn io.ReadWriteCloser,
proxyWireProtocol string,
visitorWireProtocol string,
xl *xlog.Logger,
) (inCount int64, outCount int64, errs []error) {
// The mixed bridge decodes and re-encodes messages, so raw framed byte counts
// are not available. Count UDP payload bytes and ignore heartbeat traffic.
proxyRW := msg.NewReadWriter(proxyConn, proxyWireProtocol)
visitorRW := msg.NewReadWriter(visitorConn, visitorWireProtocol)
var (
once sync.Once
wait sync.WaitGroup
recordErrs = make([]error, 2)
)
closeBoth := func() {
_ = proxyConn.Close()
_ = visitorConn.Close()
}
wait.Add(2)
go func() {
defer wait.Done()
defer once.Do(closeBoth)
recordErrs[0] = bridgeSUDPProxyToVisitor(proxyRW, visitorRW, &outCount, xl)
}()
go func() {
defer wait.Done()
defer once.Do(closeBoth)
recordErrs[1] = bridgeSUDPVisitorToProxy(visitorRW, proxyRW, &inCount, xl)
}()
wait.Wait()
for _, err := range recordErrs {
if err != nil {
errs = append(errs, err)
}
}
return
}
func bridgeSUDPProxyToVisitor(from msg.ReadWriter, to msg.ReadWriter, count *int64, xl *xlog.Logger) error {
for {
rawMsg, err := from.ReadMsg()
if err != nil {
return normalizeSUDPBridgeError(err)
}
switch m := rawMsg.(type) {
case *msg.UDPPacket:
if err := to.WriteMsg(m); err != nil {
return normalizeSUDPBridgeError(err)
}
*count += int64(len(m.Content))
case *msg.Ping:
traceSUDPBridge(xl, "bridge SUDP ping from proxy to visitor")
if err := to.WriteMsg(m); err != nil {
return normalizeSUDPBridgeError(err)
}
default:
return fmt.Errorf("unexpected SUDP proxy message %T", rawMsg)
}
}
}
func bridgeSUDPVisitorToProxy(from msg.ReadWriter, to msg.ReadWriter, count *int64, xl *xlog.Logger) error {
for {
rawMsg, err := from.ReadMsg()
if err != nil {
return normalizeSUDPBridgeError(err)
}
switch m := rawMsg.(type) {
case *msg.UDPPacket:
if err := to.WriteMsg(m); err != nil {
return normalizeSUDPBridgeError(err)
}
*count += int64(len(m.Content))
case *msg.Ping:
traceSUDPBridge(xl, "drop SUDP ping from visitor to proxy")
continue
default:
return fmt.Errorf("unexpected SUDP visitor message %T", rawMsg)
}
}
}
func normalizeSUDPBridgeError(err error) error {
if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
func traceSUDPBridge(xl *xlog.Logger, format string, args ...any) {
if xl != nil {
xl.Tracef(format, args...)
}
}
type Options struct {
UserInfo plugin.UserInfo
LoginMsg *msg.Login
@@ -331,6 +468,7 @@ type Options struct {
Configurer v1.ProxyConfigurer
ServerCfg *v1.ServerConfig
EncryptionKey []byte
WireProtocol string
}
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
@@ -357,6 +495,7 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
userInfo: options.UserInfo,
loginMsg: options.LoginMsg,
configurer: configurer,
wireProtocol: options.WireProtocol,
}
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]

View File

@@ -15,12 +15,15 @@
package proxy
import (
"context"
"net"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
@@ -51,3 +54,56 @@ func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
require.NoError(t, result.err)
require.Same(t, serverMsgConn, result.conn)
}
func TestGetWorkConnFromPoolStartWorkConnUnchangedForUDPWireV2(t *testing.T) {
startMsg := getStartWorkConnFromPool(t, &v1.UDPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "udp", Type: string(v1.ProxyTypeUDP)},
}, wire.ProtocolV2)
require.Equal(t, msg.StartWorkConn{ProxyName: "udp"}, startMsg)
}
func TestGetWorkConnFromPoolLeavesRawTCPPayloadUnframed(t *testing.T) {
startMsg := getStartWorkConnFromPool(t, &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "tcp", Type: string(v1.ProxyTypeTCP)},
}, wire.ProtocolV2)
require.Equal(t, msg.StartWorkConn{ProxyName: "tcp"}, startMsg)
}
func getStartWorkConnFromPool(t *testing.T, cfg v1.ProxyConfigurer, wireProtocol string) msg.StartWorkConn {
t.Helper()
client, server := net.Pipe()
t.Cleanup(func() {
client.Close()
server.Close()
})
serverMsgConn := msg.NewConn(server, msg.NewV2ReadWriter(server))
clientMsgConn := msg.NewConn(client, msg.NewV2ReadWriter(client))
pxy := &BaseProxy{
name: cfg.GetBaseConfig().Name,
configurer: cfg,
poolCount: 0,
ctx: context.Background(),
wireProtocol: wireProtocol,
getWorkConnFn: func() (*WorkConn, error) {
return NewWorkConn(serverMsgConn), nil
},
}
errCh := make(chan error, 1)
go func() {
conn, err := pxy.GetWorkConnFromPool(nil, nil)
if conn != nil {
conn.Close()
}
errCh <- err
}()
var startMsg msg.StartWorkConn
require.NoError(t, clientMsgConn.ReadMsgInto(&startMsg))
require.NoError(t, <-errCh)
return startMsg
}

141
server/proxy/sudp_test.go Normal file
View File

@@ -0,0 +1,141 @@
// 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 proxy
import (
"bufio"
"bytes"
"encoding/binary"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.UDPPacket{Content: []byte("proxy-to-visitor")})
var count int64
err := bridgeSUDPProxyToVisitor(
msg.NewReadWriter(&in, wire.ProtocolV1),
msg.NewReadWriter(&out, wire.ProtocolV2),
&count,
nil,
)
require.NoError(t, err)
require.Equal(t, int64(len("proxy-to-visitor")), count)
frame, err := wire.NewConn(&out).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.GreaterOrEqual(t, len(frame.Payload), 2)
require.Equal(t, msg.V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
var got msg.UDPPacket
require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &got))
require.Equal(t, []byte("proxy-to-visitor"), got.Content)
}
func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.UDPPacket{Content: []byte("visitor-to-proxy")})
var count int64
err := bridgeSUDPVisitorToProxy(
msg.NewReadWriter(&in, wire.ProtocolV2),
msg.NewReadWriter(&out, wire.ProtocolV1),
&count,
nil,
)
require.NoError(t, err)
require.Equal(t, int64(len("visitor-to-proxy")), count)
reader := bufio.NewReader(&out)
typeByte, err := reader.ReadByte()
require.NoError(t, err)
require.Equal(t, msg.TypeUDPPacket, typeByte)
require.NoError(t, reader.UnreadByte())
var got msg.UDPPacket
require.NoError(t, msg.ReadMsgInto(reader, &got))
require.Equal(t, []byte("visitor-to-proxy"), got.Content)
}
func TestSUDPBridgeForwardsProxyPing(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.Ping{})
var count int64
err := bridgeSUDPProxyToVisitor(
msg.NewReadWriter(&in, wire.ProtocolV1),
msg.NewReadWriter(&out, wire.ProtocolV2),
&count,
nil,
)
require.NoError(t, err)
require.Zero(t, count)
rawMsg, err := msg.NewReadWriter(&out, wire.ProtocolV2).ReadMsg()
require.NoError(t, err)
require.IsType(t, &msg.Ping{}, rawMsg)
}
func TestSUDPBridgeDropsVisitorPing(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Ping{})
var count int64
err := bridgeSUDPVisitorToProxy(
msg.NewReadWriter(&in, wire.ProtocolV2),
msg.NewReadWriter(&out, wire.ProtocolV1),
&count,
nil,
)
require.NoError(t, err)
require.Zero(t, count)
require.Empty(t, out.Bytes())
}
func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Pong{})
var count int64
err := bridgeSUDPVisitorToProxy(
msg.NewReadWriter(&in, wire.ProtocolV2),
msg.NewReadWriter(&out, wire.ProtocolV1),
&count,
nil,
)
require.ErrorContains(t, err, "unexpected SUDP visitor message *msg.Pong")
require.Zero(t, count)
require.Empty(t, out.Bytes())
}
func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) {
require.False(t, isMixedWireProtocol("", wire.ProtocolV1))
require.False(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV2))
require.True(t, isMixedWireProtocol("", wire.ProtocolV2))
require.True(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV1))
}
func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol string, m msg.Message) {
t.Helper()
require.NoError(t, msg.NewReadWriter(buf, wireProtocol).WriteMsg(m))
}

View File

@@ -108,7 +108,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
pxy.checkCloseCh = make(chan int)
// read message from workConn, if it returns any error, notify proxy to start a new workConn
workConnReaderFn := func(conn net.Conn) {
workConnReaderFn := func(payloadConn *msg.Conn) {
for {
var (
rawMsg msg.Message
@@ -116,10 +116,10 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
)
xl.Tracef("loop waiting message from udp workConn")
// client will send heartbeat in workConn for keeping alive
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
_ = payloadConn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
xl.Warnf("read from workConn for udp error: %v", errRet)
_ = conn.Close()
_ = payloadConn.Close()
// notify proxy to start a new work connection
// ignore error here, it means the proxy is closed
_ = errors.PanicToError(func() {
@@ -127,7 +127,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
})
return
}
if err := conn.SetReadDeadline(time.Time{}); err != nil {
if err := payloadConn.SetReadDeadline(time.Time{}); err != nil {
xl.Warnf("set read deadline error: %v", err)
}
switch m := rawMsg.(type) {
@@ -144,7 +144,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
int64(len(m.Content)),
)
}); errRet != nil {
conn.Close()
_ = payloadConn.Close()
xl.Infof("reader goroutine for udp work connection closed")
return
}
@@ -153,7 +153,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
}
// send message to workConn
workConnSenderFn := func(conn net.Conn, ctx context.Context) {
workConnSenderFn := func(payloadConn *msg.Conn, ctx context.Context) {
var errRet error
for {
select {
@@ -162,9 +162,9 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
xl.Infof("sender goroutine for udp work connection closed")
return
}
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
xl.Infof("sender goroutine for udp work connection closed: %v", errRet)
conn.Close()
_ = payloadConn.Close()
return
}
xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
@@ -223,9 +223,11 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
}
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
// Plain UDP payload follows the negotiated wire protocol for message framing.
payloadConn := msg.NewConn(pxy.workConn, msg.NewReadWriter(pxy.workConn, pxy.wireProtocol))
ctx, cancel := context.WithCancel(context.Background())
go workConnReaderFn(pxy.workConn)
go workConnSenderFn(pxy.workConn, ctx)
go workConnReaderFn(payloadConn)
go workConnSenderFn(payloadConn, ctx)
_, ok := <-pxy.checkCloseCh
cancel()
if !ok {

View File

@@ -16,6 +16,7 @@ package proxy
import (
"fmt"
"net"
"reflect"
"sync"
@@ -73,10 +74,7 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
if errRet != nil {
continue
}
m := &msg.NatHoleSid{
Sid: sid,
}
errRet = msg.WriteMsg(workConn, m)
errRet = writeNatHoleSid(workConn, pxy.wireProtocol, sid)
if errRet != nil {
xl.Warnf("write nat hole sid package error, %v", errRet)
}
@@ -87,6 +85,13 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
return
}
func writeNatHoleSid(workConn net.Conn, wireProtocol string, sid string) error {
workMsgConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, wireProtocol))
return workMsgConn.WriteMsg(&msg.NatHoleSid{
Sid: sid,
})
}
func (pxy *XTCPProxy) Close() {
pxy.closeOnce.Do(func() {
pxy.BaseProxy.Close()

93
server/proxy/xtcp_test.go Normal file
View File

@@ -0,0 +1,93 @@
// 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 proxy
import (
"bufio"
"encoding/binary"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestWriteNatHoleSidUsesWireV2MessageFrame(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setPipeDeadline(t, client, server)
errCh := make(chan error, 1)
go func() {
errCh <- writeNatHoleSid(server, wire.ProtocolV2, "sid-v2")
}()
frame, err := wire.NewConn(client).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.GreaterOrEqual(t, len(frame.Payload), 2)
require.Equal(t, msg.V2TypeNatHoleSid, binary.BigEndian.Uint16(frame.Payload[:2]))
var out msg.NatHoleSid
require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &out))
require.Equal(t, "sid-v2", out.Sid)
require.NoError(t, <-errCh)
}
func TestWriteNatHoleSidUsesLegacyCodecForWireV1AndDefault(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
}{
{name: "default", wireProtocol: ""},
{name: "v1", wireProtocol: wire.ProtocolV1},
} {
t.Run(tc.name, func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setPipeDeadline(t, client, server)
errCh := make(chan error, 1)
go func() {
errCh <- writeNatHoleSid(server, tc.wireProtocol, "sid-legacy")
}()
reader := bufio.NewReader(client)
typeByte, err := reader.ReadByte()
require.NoError(t, err)
require.Equal(t, msg.TypeNatHoleSid, typeByte)
require.NoError(t, reader.UnreadByte())
var out msg.NatHoleSid
require.NoError(t, msg.ReadMsgInto(reader, &out))
require.Equal(t, "sid-legacy", out.Sid)
require.NoError(t, <-errCh)
})
}
}
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
t.Helper()
deadline := time.Now().Add(time.Second)
for _, conn := range conns {
require.NoError(t, conn.SetDeadline(deadline))
}
}

View File

@@ -508,7 +508,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
conn.Close()
}
case *msg.NewVisitorConn:
if err = svr.RegisterVisitorConn(conn, m); err != nil {
if err = svr.RegisterVisitorConn(conn, m, acceptedConn.wireProtocol); err != nil {
xl.Warnf("register visitor conn error: %v", err)
_ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
ProxyName: m.ProxyName,
@@ -777,6 +777,7 @@ func (svr *Service) RegisterControl(
LoginMsg: loginMsg,
ServerCfg: svr.cfg,
ClientRegistry: svr.clientRegistry,
WireProtocol: wireProtocol,
})
if err != nil {
xl.Warnf("create new controller error: %v", err)
@@ -832,7 +833,7 @@ func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn
return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
}
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn) error {
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
visitorUser := ""
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
// If runID is required, it is not compatible with versions prior to v0.50.0.
@@ -844,5 +845,5 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
visitorUser = ctl.sessionCtx.LoginMsg.User
}
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
}

View File

@@ -65,6 +65,7 @@ func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.
func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,
useEncryption bool, useCompression bool, visitorUser string,
wireProtocol string,
) (err error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
@@ -90,7 +91,11 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
if useCompression {
rwc = libio.WithCompression(rwc)
}
err = l.l.PutConn(netpkg.WrapReadWriteCloserToConn(rwc, conn))
visitorConn := netpkg.WrapReadWriteCloserToConn(rwc, conn)
err = l.l.PutConn(&wireProtocolConn{
Conn: visitorConn,
wireProtocol: wireProtocol,
})
} else {
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
return
@@ -98,6 +103,15 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
return
}
type wireProtocolConn struct {
net.Conn
wireProtocol string
}
func (c *wireProtocolConn) WireProtocol() string {
return c.wireProtocol
}
func (vm *Manager) CloseListener(name string) {
vm.mu.Lock()
defer vm.mu.Unlock()

View File

@@ -0,0 +1,61 @@
// 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 visitor
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/util/util"
)
func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
vm := NewManager()
listener, err := vm.Listen("sudp", "secret", []string{"*"})
require.NoError(t, err)
defer listener.Close()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
now := time.Now().Unix()
errCh := make(chan error, 1)
go func() {
errCh <- vm.NewConn(
"sudp",
server,
now,
util.GetAuthKey("secret", now),
false,
false,
"user",
wire.ProtocolV2,
)
}()
acceptedConn, err := listener.Accept()
require.NoError(t, err)
defer acceptedConn.Close()
getter, ok := acceptedConn.(interface{ WireProtocol() string })
require.True(t, ok)
require.Equal(t, wire.ProtocolV2, getter.WireProtocol())
require.NoError(t, <-errCh)
}

View File

@@ -7,6 +7,8 @@ import (
"io"
"net/http"
"os"
"strconv"
"strings"
"testing"
"time"
@@ -158,7 +160,7 @@ webServer.port = %d
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
ginkgo.It("baseline frps rejects current frpc forced to v2", func() {
ginkgo.It("baseline frps handles current frpc forced to v2 according to baseline support", func() {
portName := port.GenName("CompatBaselineFRPSForcedV2")
clientConf := tcpClientConfig("tcp", portName, `
transport.wireProtocol = "v2"
@@ -170,6 +172,21 @@ transport.wireProtocol = "v2"
consts.DefaultServerConfig,
[]string{clientConf},
)
// frp v0.69.0 added control connection wireProtocol v2 support, so
// baseline frps v0.69.0 and newer should accept a current frpc forced
// to v2. Older known baselines still must reject the unsupported protocol.
// For custom baselines, the version is unknown and the binary may be either
// side of the support boundary, so this versioned expectation is skipped.
supportsV2, knownVersion := baselineSupportsControlWireProtocolV2(compatCtx.BaselineVersion)
if !knownVersion {
ginkgo.Skip(fmt.Sprintf("baseline version %q is not semver; skip versioned forced-v2 expectation", compatCtx.BaselineVersion))
}
if supportsV2 {
framework.NewRequestExpect(f).PortName(portName).Ensure()
return
}
expectProcessExit(clientProcesses[0], 5*time.Second)
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
})
@@ -199,6 +216,39 @@ func expectProcessExit(p *process.Process, timeout time.Duration) {
}
}
func baselineSupportsControlWireProtocolV2(version string) (supports bool, known bool) {
version = strings.TrimPrefix(version, "v")
parts := strings.Split(version, ".")
if len(parts) != 3 {
return false, false
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return false, false
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return false, false
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return false, false
}
return compareSemanticVersion(major, minor, patch, 0, 69, 0) >= 0, true
}
func compareSemanticVersion(major, minor, patch int, baseMajor, baseMinor, basePatch int) int {
if major != baseMajor {
return major - baseMajor
}
if minor != baseMinor {
return minor - baseMinor
}
return patch - basePatch
}
type wireClientInfo struct {
ClientID string `json:"clientID"`
WireProtocol string `json:"wireProtocol"`

View File

@@ -98,6 +98,73 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
framework.NewRequestExpect(f).PortName(bindPortName).Ensure()
})
for _, tc := range []struct {
name string
proxyWireConfig string
visitorWireConfig string
extraProxyConfig string
extraVisitorConfig string
}{
{
name: "default sudp visitor",
},
{
name: "v2 sudp visitor",
proxyWireConfig: `transport.wireProtocol = "v2"`,
visitorWireConfig: `transport.wireProtocol = "v2"`,
},
{
name: "mixed sudp proxy v1 visitor v2",
proxyWireConfig: `transport.wireProtocol = "v1"`,
visitorWireConfig: `transport.wireProtocol = "v2"`,
extraProxyConfig: `
transport.useEncryption = true
transport.useCompression = true
`,
extraVisitorConfig: `
transport.useEncryption = true
transport.useCompression = true
`,
},
{
name: "mixed sudp proxy v2 visitor v1",
proxyWireConfig: `transport.wireProtocol = "v2"`,
visitorWireConfig: `transport.wireProtocol = "v1"`,
},
} {
ginkgo.It(tc.name, func() {
serverConf := consts.DefaultServerConfig
bindPortName := port.GenName("WireSUDP")
clientServerConf := consts.DefaultClientConfig + fmt.Sprintf(`
user = "user1"
%s
[[proxies]]
name = "sudp"
type = "sudp"
secretKey = "abc"
localPort = {{ .%s }}
%s
`, tc.proxyWireConfig, framework.UDPEchoServerPort, tc.extraProxyConfig)
clientVisitorConf := consts.DefaultClientConfig + fmt.Sprintf(`
user = "user1"
%s
[[visitors]]
name = "sudp-visitor"
type = "sudp"
serverName = "sudp"
secretKey = "abc"
bindPort = {{ .%s }}
%s
`, tc.visitorWireConfig, bindPortName, tc.extraVisitorConfig)
f.RunProcesses(serverConf, []string{clientServerConf, clientVisitorConf})
framework.NewRequestExpect(f).Protocol("udp").PortName(bindPortName).Ensure()
})
}
ginkgo.It("reports client wire protocol", func() {
webPort := f.AllocPort()
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`

View File

@@ -1,18 +1,24 @@
package plugin
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"github.com/onsi/ginkgo/v2"
pp "github.com/pires/go-proxyproto"
"github.com/fatedier/frp/pkg/transport"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
"github.com/fatedier/frp/test/e2e/mock/server/streamserver"
"github.com/fatedier/frp/test/e2e/pkg/cert"
"github.com/fatedier/frp/test/e2e/pkg/port"
"github.com/fatedier/frp/test/e2e/pkg/request"
@@ -450,4 +456,85 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
ExpectResp([]byte("test")).
Ensure()
})
ginkgo.It("tls2raw with proxy protocol v2", func() {
generator := &cert.SelfSignedCertGenerator{}
artifacts, err := generator.Generate("example.com")
framework.ExpectNoError(err)
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
serverConf := consts.DefaultServerConfig
vhostHTTPSPort := f.AllocPort()
serverConf += fmt.Sprintf(`
vhostHTTPSPort = %d
`, vhostHTTPSPort)
localPort := f.AllocPort()
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
[[proxies]]
name = "tls2raw-proxy-protocol-test"
type = "https"
customDomains = ["example.com"]
transport.proxyProtocolVersion = "v2"
[proxies.plugin]
type = "tls2raw"
localAddr = "127.0.0.1:%d"
crtPath = "%s"
keyPath = "%s"
`, localPort, crtPath, keyPath)
f.RunProcesses(serverConf, []string{clientConf})
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
streamserver.WithCustomHandler(func(c net.Conn) {
defer c.Close()
writeResp := func(body string) {
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
}
rd := bufio.NewReader(c)
ppHeader, err := pp.Read(rd)
if err != nil {
log.Errorf("read proxy protocol error: %v", err)
writeResp("missing proxy protocol")
return
}
if ppHeader.Version != 2 {
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
writeResp("unexpected proxy protocol version")
return
}
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
if !ok || srcAddr.IP.String() != "127.0.0.1" {
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
writeResp("unexpected proxy protocol source address")
return
}
req, err := http.ReadRequest(rd)
if err != nil {
log.Errorf("read http request after proxy protocol error: %v", err)
writeResp("missing http request")
return
}
_, _ = io.Copy(io.Discard, req.Body)
_ = req.Body.Close()
writeResp("test")
}))
f.RunServer("", localServer)
framework.NewRequestExpect(f).
Port(vhostHTTPSPort).
RequestModify(func(r *request.Request) {
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
ServerName: "example.com",
InsecureSkipVerify: true,
})
}).
ExpectResp([]byte("test")).
Ensure()
})
})

View File

@@ -15,7 +15,6 @@ declare module 'vue' {
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRow: typeof import('element-plus/es')['ElRow']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag']

View File

@@ -1,10 +1,30 @@
import { http } from './http'
import type { ClientInfoData } from '../types/client'
import { buildQueryString, http } from './http'
import type { V2Page } from './http'
import type { ClientInfoData, ClientListV2Params } from '../types/client'
export const getClients = () => {
return http.get<ClientInfoData[]>('../api/clients')
}
export const getClientsV2 = (params: ClientListV2Params = {}) => {
return http.getV2<V2Page<ClientInfoData>>(
`../api/v2/clients${buildQueryString({
page: params.page,
pageSize: params.pageSize,
status:
params.status && params.status !== 'all' ? params.status : undefined,
q: params.q || undefined,
user: params.user,
clientID: params.clientID || undefined,
runID: params.runID || undefined,
})}`,
)
}
export const getClient = (key: string) => {
return http.get<ClientInfoData>(`../api/clients/${key}`)
}
export const getClientV2 = (key: string) => {
return http.getV2<ClientInfoData>(`../api/v2/clients/${encodeURIComponent(key)}`)
}

View File

@@ -11,6 +11,21 @@ class HTTPError extends Error {
}
}
export interface V2Envelope<T> {
code: number
msg: string
data: T
}
export interface V2Page<T> {
total: number
page: number
pageSize: number
items: T[]
}
type QueryParamValue = string | number | boolean | null | undefined
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
@@ -34,9 +49,62 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
return response.json()
}
async function requestV2<T>(
url: string,
options: RequestInit = {},
): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
}
const response = await fetch(url, { ...defaultOptions, ...options })
const envelope = (await response.json().catch(() => null)) as
| V2Envelope<T>
| null
if (!response.ok) {
throw new HTTPError(
response.status,
response.statusText,
envelope?.msg || `HTTP ${response.status}`,
)
}
if (!envelope || typeof envelope.code !== 'number') {
throw new Error('Invalid API v2 response')
}
if (envelope.code >= 400) {
throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
}
return envelope.data
}
export const buildQueryString = (
params: Record<string, QueryParamValue>,
): string => {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) continue
query.append(key, String(value))
}
const text = query.toString()
return text ? `?${text}` : ''
}
export const http = {
get: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'GET' }),
getV2: <T>(url: string, options?: RequestInit) =>
requestV2<T>(url, { ...options, method: 'GET' }),
postV2: <T>(url: string, body?: any, options?: RequestInit) =>
requestV2<T>(url, {
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(body),
}),
post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, {
...options,

View File

@@ -1,26 +1,80 @@
import { http } from './http'
import { buildQueryString, http } from './http'
import type { V2Page } from './http'
import type {
GetProxyResponse,
ProxyListV2Params,
ProxyStatsInfo,
ProxyV2Info,
TrafficResponse,
} from '../types/proxy'
export interface SystemPruneResponse {
type: 'offline_proxies'
cleared: number
total: number
}
export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
}
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
const page = await http.getV2<V2Page<ProxyV2Info>>(
`../api/v2/proxies${buildQueryString({
page: params.page,
pageSize: params.pageSize,
status:
params.status && params.status !== 'all' ? params.status : undefined,
q: params.q || undefined,
type: params.type || undefined,
user: params.user,
clientID: params.clientID || undefined,
})}`,
)
return {
...page,
items: page.items.map(toLegacyProxyStats),
}
}
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
name: proxy.name,
type: proxy.type,
conf: proxy.spec,
user: proxy.user,
clientID: proxy.clientID,
todayTrafficIn: proxy.status.todayTrafficIn,
todayTrafficOut: proxy.status.todayTrafficOut,
curConns: proxy.status.curConns,
lastStartTime: proxy.status.lastStartTime,
lastCloseTime: proxy.status.lastCloseTime,
status: proxy.status.state || proxy.status.phase || '',
})
export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
}
export const getProxyByNameV2 = async (name: string) => {
const proxy = await http.getV2<ProxyV2Info>(
`../api/v2/proxies/${encodeURIComponent(name)}`,
)
return toLegacyProxyStats(proxy)
}
export const getProxyByName = (name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
}
export const getProxyTraffic = (name: string) => {
return http.get<TrafficResponse>(`../api/traffic/${name}`)
return http.getV2<TrafficResponse>(
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
)
}
export const clearOfflineProxies = () => {
return http.delete('../api/proxies?status=offline')
return http.postV2<SystemPruneResponse>(
'../api/v2/system/prune?type=offline_proxies',
)
}

View File

@@ -2,5 +2,5 @@ import { http } from './http'
import type { ServerInfo } from '../types/server'
export const getServerInfo = () => {
return http.get<ServerInfo>('../api/serverinfo')
return http.getV2<ServerInfo>('../api/v2/system/info')
}

View File

@@ -54,6 +54,7 @@ import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { formatFileSize } from '../utils/format'
import { getProxyTraffic } from '../api/proxy'
import type { TrafficResponse } from '../types/proxy'
const props = defineProps<{
proxyName: string
@@ -71,41 +72,24 @@ const chartData = ref<
>([])
const maxVal = ref(0)
const processData = (trafficIn: number[], trafficOut: number[]) => {
// Ensure we have arrays and reverse them (server returns newest first)
const inArr = [...(trafficIn || [])].reverse()
const outArr = [...(trafficOut || [])].reverse()
const formatDateLabel = (date: string) => {
const parts = date.split('-')
if (parts.length !== 3) return date
return `${Number(parts[1])}-${Number(parts[2])}`
}
// Pad with zeros if less than 7 days
while (inArr.length < 7) inArr.unshift(0)
while (outArr.length < 7) outArr.unshift(0)
// Slice to last 7 entries just in case
const finalIn = inArr.slice(-7)
const finalOut = outArr.slice(-7)
// Calculate dates (last 7 days ending today)
const dates: string[] = []
const d = new Date()
d.setDate(d.getDate() - 6)
for (let i = 0; i < 7; i++) {
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
d.setDate(d.getDate() + 1)
}
// Find max value for scaling
const maxIn = Math.max(...finalIn)
const maxOut = Math.max(...finalOut)
const processData = (history: TrafficResponse['history'] = []) => {
const points = history || []
const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
// Build chart data
chartData.value = dates.map((date, i) => ({
date,
in: finalIn[i],
out: finalOut[i],
inPercent: (finalIn[i] / maxVal.value) * 100,
outPercent: (finalOut[i] / maxVal.value) * 100,
chartData.value = points.map((item) => ({
date: formatDateLabel(item.date),
in: item.trafficIn,
out: item.trafficOut,
inPercent: (item.trafficIn / maxVal.value) * 100,
outPercent: (item.trafficOut / maxVal.value) * 100,
}))
}
@@ -113,7 +97,7 @@ const fetchData = () => {
loading.value = true
getProxyTraffic(props.proxyName)
.then((json) => {
processData(json.trafficIn, json.trafficOut)
processData(json.history)
})
.catch((err) => {
ElMessage({

View File

@@ -12,4 +12,21 @@ export interface ClientInfoData {
lastConnectedAt: number
disconnectedAt?: number
online: boolean
status?: ClientStatus
}
export interface ClientStatus {
phase: 'online' | 'offline'
curConns: number
proxyCount: number
}
export interface ClientListV2Params {
page?: number
pageSize?: number
status?: 'all' | 'online' | 'offline'
q?: string
user?: string
clientID?: string
runID?: string
}

View File

@@ -1,5 +1,6 @@
export interface ProxyStatsInfo {
name: string
type?: string
conf: any
user: string
clientID: string
@@ -15,8 +16,44 @@ export interface GetProxyResponse {
proxies: ProxyStatsInfo[]
}
export interface ProxyListV2Params {
page?: number
pageSize?: number
status?: 'all' | 'online' | 'offline'
q?: string
type?: string
user?: string
clientID?: string
}
export interface ProxyV2Info {
name: string
type: string
user: string
clientID: string
spec: any
status: ProxyV2Status
}
export interface ProxyV2Status {
state?: string
phase?: string
todayTrafficIn: number
todayTrafficOut: number
curConns: number
lastStartTime: string
lastCloseTime: string
}
export interface TrafficResponse {
name: string
trafficIn: number[]
trafficOut: number[]
unit: 'bytes'
granularity: 'day'
history: TrafficPoint[]
}
export interface TrafficPoint {
date: string
trafficIn: number
trafficOut: number
}

View File

@@ -1,5 +1,10 @@
export interface ServerInfo {
version: string
config: ServerInfoConfig
status: ServerInfoStatus
}
export interface ServerInfoConfig {
bindPort: number
vhostHTTPPort: number
vhostHTTPSPort: number
@@ -12,8 +17,9 @@ export interface ServerInfo {
heartbeatTimeout: number
allowPortsStr: string
tlsForce: boolean
}
// Stats
export interface ServerInfoStatus {
totalTrafficIn: number
totalTrafficOut: number
curConns: number

View File

@@ -1,5 +1,5 @@
import { formatDistanceToNow } from './format'
import type { ClientInfoData } from '../types/client'
import type { ClientInfoData, ClientStatus } from '../types/client'
export class Client {
key: string
@@ -15,6 +15,7 @@ export class Client {
lastConnectedAt: Date
disconnectedAt?: Date
online: boolean
status: ClientStatus
constructor(data: ClientInfoData) {
this.key = data.key
@@ -37,6 +38,11 @@ export class Client {
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
}
this.online = data.online
this.status = data.status || {
phase: this.online ? 'online' : 'offline',
curConns: 0,
proxyCount: 0,
}
}
get displayName(): string {

View File

@@ -55,7 +55,7 @@
<div class="info-section">
<div class="info-item">
<span class="info-label">Connections</span>
<span class="info-value">{{ totalConnections }}</span>
<span class="info-value">{{ client.status.curConns }}</span>
</div>
<div class="info-item">
<span class="info-label">Run ID</span>
@@ -85,7 +85,7 @@
<div class="proxies-header">
<div class="proxies-title">
<h2>Proxies</h2>
<span class="proxies-count">{{ filteredProxies.length }}</span>
<span class="proxies-count">{{ total }}</span>
</div>
<el-input
v-model="proxySearch"
@@ -100,21 +100,32 @@
<el-icon class="is-loading"><Loading /></el-icon>
<span>Loading...</span>
</div>
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
<div v-else-if="proxies.length > 0" class="proxies-list">
<ProxyCard
v-for="proxy in filteredProxies"
:key="proxy.name"
v-for="proxy in proxies"
:key="`${proxy.type}:${proxy.name}`"
:proxy="proxy"
show-type
/>
</div>
<div v-else-if="clientProxies.length > 0" class="empty-state">
<div v-else-if="proxySearch.trim()" class="empty-state">
<p>No proxies match "{{ proxySearch }}"</p>
</div>
<div v-else class="empty-state">
<p>No proxies found</p>
</div>
</div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div>
</template>
@@ -130,13 +141,13 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElPagination } from 'element-plus'
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client'
import { getClient } from '../api/client'
import { getProxiesByType } from '../api/proxy'
import { getClientV2 } from '../api/client'
import { getProxiesV2 } from '../api/proxy'
import {
BaseProxy,
TCPProxy,
@@ -149,6 +160,8 @@ import {
} from '../utils/proxy'
import { getServerInfo } from '../api/server'
import ProxyCard from '../components/ProxyCard.vue'
import type { ProxyStatsInfo } from '../types/proxy'
import type { ServerInfo } from '../types/server'
const route = useRoute()
const router = useRouter()
@@ -163,119 +176,191 @@ const goBack = () => {
}
}
const proxiesLoading = ref(false)
const allProxies = ref<BaseProxy[]>([])
const proxies = ref<BaseProxy[]>([])
const proxySearch = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let requestSeq = 0
let searchDebounceTimer: number | null = null
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
let serverInfoPromise: Promise<ServerInfo> | null = null
const clientProxies = computed(() => {
if (!client.value) return []
return allProxies.value.filter(
(p) =>
p.clientID === client.value!.clientID && p.user === client.value!.user,
)
})
const filteredProxies = computed(() => {
if (!proxySearch.value) return clientProxies.value
const search = proxySearch.value.toLowerCase()
return clientProxies.value.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search),
)
})
const totalConnections = computed(() => {
return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)
})
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
const fetchServerInfo = (): Promise<ServerInfo> => {
if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => {
serverInfoPromise = null
throw err
})
}
return serverInfoPromise
}
const fetchClient = async () => {
const fetchClient = async (): Promise<boolean> => {
const key = route.params.key as string
if (!key) {
loading.value = false
return
return false
}
try {
const data = await getClient(key)
const data = await getClientV2(key)
client.value = new Client(data)
return true
} catch (error: any) {
ElMessage.error('Failed to fetch client: ' + error.message)
return false
} finally {
loading.value = false
}
}
const fetchProxies = async () => {
proxiesLoading.value = true
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
const proxies: BaseProxy[] = []
try {
const convertProxy = async (
proxy: ProxyStatsInfo,
): Promise<BaseProxy | null> => {
const type = proxy.type || ''
if (type === 'tcp') {
return new TCPProxy(proxy)
}
if (type === 'udp') {
return new UDPProxy(proxy)
}
if (type === 'http') {
const info = await fetchServerInfo()
for (const type of proxyTypes) {
try {
const json = await getProxiesByType(type)
if (!json.proxies) continue
if (type === 'tcp') {
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
} else if (type === 'udp') {
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
} else if (type === 'http' && info?.vhostHTTPPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
),
)
} else if (type === 'https' && info?.vhostHTTPSPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
),
)
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new TCPMuxProxy(
p,
info.tcpmuxHTTPConnectPort,
info.subdomainHost,
),
),
)
} else if (type === 'stcp') {
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
} else if (type === 'sudp') {
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
}
} catch {
// Ignore
}
if (info && info.config.vhostHTTPPort) {
return new HTTPProxy(
proxy,
info.config.vhostHTTPPort,
info.config.subdomainHost,
)
}
allProxies.value = proxies
} catch {
// Ignore
return null
}
if (type === 'https') {
const info = await fetchServerInfo()
if (info && info.config.vhostHTTPSPort) {
return new HTTPSProxy(
proxy,
info.config.vhostHTTPSPort,
info.config.subdomainHost,
)
}
return null
}
if (type === 'tcpmux') {
const info = await fetchServerInfo()
if (info && info.config.tcpmuxHTTPConnectPort) {
return new TCPMuxProxy(
proxy,
info.config.tcpmuxHTTPConnectPort,
info.config.subdomainHost,
)
}
return null
}
if (type === 'stcp') {
return new STCPProxy(proxy)
}
if (type === 'sudp') {
return new SUDPProxy(proxy)
}
const bp = new BaseProxy(proxy)
bp.type = type
return bp
}
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
const converted = await Promise.all(items.map((item) => convertProxy(item)))
return converted.filter((item): item is BaseProxy => item !== null)
}
const fetchProxies = async () => {
if (!client.value) return
const seq = ++requestSeq
proxiesLoading.value = true
try {
const q = proxySearch.value.trim()
const data = await getProxiesV2({
page: page.value,
pageSize: pageSize.value,
q: q || undefined,
clientID: client.value.clientID,
user: client.value.user,
})
if (seq !== requestSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchProxies()
return
}
const converted = await convertProxies(data.items)
if (seq !== requestSeq) return
proxies.value = converted
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) {
if (seq !== requestSeq) return
ElMessage.error('Failed to fetch proxies: ' + error.message)
} finally {
proxiesLoading.value = false
if (seq === requestSeq) {
proxiesLoading.value = false
}
}
}
onMounted(() => {
fetchClient()
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const invalidateProxyRequests = () => {
requestSeq++
proxiesLoading.value = false
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchProxies()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchProxies()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
watch(proxySearch, () => {
clearSearchDebounce()
invalidateProxyRequests()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchProxies()
}, 300)
})
onUnmounted(() => {
clearSearchDebounce()
})
onMounted(async () => {
const ok = await fetchClient()
if (!ok || !client.value) return
fetchProxies()
})
</script>
@@ -483,6 +568,12 @@ html.dark .status-badge.online {
padding: 16px;
}
.pagination-section {
display: flex;
justify-content: center;
padding: 0 20px 20px;
}
.proxies-list {
display: flex;
flex-direction: column;

View File

@@ -16,7 +16,9 @@
>
<span class="status-dot" :class="tab.value"></span>
<span class="tab-label">{{ tab.label }}</span>
<span class="tab-count">{{ tab.count }}</span>
<span v-if="tab.count !== null" class="tab-count">{{
tab.count
}}</span>
</button>
</div>
</div>
@@ -33,9 +35,9 @@
</div>
<div v-loading="loading" class="clients-content">
<div v-if="filteredClients.length > 0" class="clients-list">
<div v-if="clients.length > 0" class="clients-list">
<ClientCard
v-for="client in filteredClients"
v-for="client in clients"
:key="client.key"
:client="client"
/>
@@ -44,82 +46,123 @@
<el-empty description="No clients found" />
</div>
</div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client'
import ClientCard from '../components/ClientCard.vue'
import { getClients } from '../api/client'
import { getClientsV2 } from '../api/client'
const clients = ref<Client[]>([])
const loading = ref(false)
const searchText = ref('')
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let refreshTimer: number | null = null
const stats = computed(() => {
const total = clients.value.length
const online = clients.value.filter((c) => c.online).length
const offline = total - online
return { total, online, offline }
})
let searchDebounceTimer: number | null = null
let requestSeq = 0
const statusTabs = computed(() => [
{ value: 'all' as const, label: 'All', count: stats.value.total },
{ value: 'online' as const, label: 'Online', count: stats.value.online },
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
{
value: 'all' as const,
label: 'All',
count: statusFilter.value === 'all' ? total.value : null,
},
{
value: 'online' as const,
label: 'Online',
count: statusFilter.value === 'online' ? total.value : null,
},
{
value: 'offline' as const,
label: 'Offline',
count: statusFilter.value === 'offline' ? total.value : null,
},
])
const filteredClients = computed(() => {
let result = clients.value
// Filter by status
if (statusFilter.value === 'online') {
result = result.filter((c) => c.online)
} else if (statusFilter.value === 'offline') {
result = result.filter((c) => !c.online)
}
// Filter by search text
if (searchText.value) {
result = result.filter((c) => c.matchesFilter(searchText.value))
}
// Sort: online first, then by display name
result.sort((a, b) => {
if (a.online !== b.online) {
return a.online ? -1 : 1
}
return a.displayName.localeCompare(b.displayName)
})
return result
})
const fetchData = async () => {
loading.value = true
const fetchData = async (silent = false) => {
const seq = ++requestSeq
if (!silent) loading.value = true
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
const data = await getClientsV2({
page: page.value,
pageSize: pageSize.value,
status: statusFilter.value,
q: searchText.value.trim(),
})
if (seq !== requestSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchData(silent)
return
}
clients.value = data.items.map((item) => new Client(item))
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) {
if (seq !== requestSeq) return
ElMessage({
showClose: true,
message: 'Failed to fetch clients: ' + error.message,
type: 'error',
})
} finally {
loading.value = false
if (seq === requestSeq) {
loading.value = false
}
}
}
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const startAutoRefresh = () => {
refreshTimer = window.setInterval(() => {
fetchData()
fetchData(true)
}, 5000)
}
@@ -130,6 +173,19 @@ const stopAutoRefresh = () => {
}
}
watch(statusFilter, () => {
resetPageAndFetch()
})
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
onMounted(() => {
fetchData()
startAutoRefresh()
@@ -137,6 +193,7 @@ onMounted(() => {
onUnmounted(() => {
stopAutoRefresh()
clearSearchDebounce()
})
</script>
@@ -274,6 +331,11 @@ onUnmounted(() => {
padding: 60px 0;
}
.pagination-section {
display: flex;
justify-content: flex-end;
}
/* Dark mode adjustments */
html.dark .status-tab {
background: var(--el-bg-color-overlay);
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
.status-tab {
flex-shrink: 0;
}
.pagination-section {
justify-content: center;
}
}
</style>

View File

@@ -8,7 +8,7 @@
</div>
<div class="actions-section">
<ActionButton variant="outline" size="small" @click="fetchData">
<ActionButton variant="outline" size="small" @click="refreshData">
Refresh
</ActionButton>
@@ -27,36 +27,6 @@
clearable
class="main-search"
/>
<PopoverMenu
:model-value="selectedClientKey"
:width="220"
placement="bottom-end"
selectable
filterable
filter-placeholder="Search clients..."
:display-value="selectedClientLabel"
clearable
class="client-filter"
@update:model-value="onClientFilterChange($event as string)"
>
<template #default="{ filterText }">
<PopoverMenuItem value="">All Clients</PopoverMenuItem>
<PopoverMenuItem
v-if="clientIDFilter && !selectedClientInList"
:value="selectedClientKey"
>
{{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)
</PopoverMenuItem>
<PopoverMenuItem
v-for="client in filteredClientOptions(filterText)"
:key="client.key"
:value="client.key"
>
{{ client.label }}
</PopoverMenuItem>
</template>
</PopoverMenu>
</div>
<div class="type-tabs">
@@ -74,9 +44,9 @@
</div>
<div v-loading="loading" class="proxies-content">
<div v-if="filteredProxies.length > 0" class="proxies-list">
<div v-if="proxies.length > 0" class="proxies-list">
<ProxyCard
v-for="proxy in filteredProxies"
v-for="proxy in proxies"
:key="`${proxy.type}:${proxy.name}`"
:proxy="proxy"
:show-type="activeType === 'all'"
@@ -87,6 +57,18 @@
</div>
</div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
<ConfirmDialog
v-model="showClearDialog"
title="Clear Offline"
@@ -99,9 +81,9 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
@@ -116,15 +98,13 @@ import {
SUDPProxy,
} from '../utils/proxy'
import ProxyCard from '../components/ProxyCard.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import {
getProxiesByType,
getProxiesV2,
clearOfflineProxies as apiClearOfflineProxies,
} from '../api/proxy'
import { getServerInfo } from '../api/server'
import { getClients } from '../api/client'
import { Client } from '../utils/client'
import type { ProxyStatsInfo } from '../types/proxy'
import type { ServerInfo } from '../types/server'
const route = useRoute()
const router = useRouter()
@@ -141,244 +121,163 @@ const proxyTypes = [
{ label: 'SUDP', value: 'sudp' },
]
const activeType = ref((route.params.type as string) || 'tcp')
const activeType = ref((route.params.type as string) || 'all')
const proxies = ref<BaseProxy[]>([])
const clients = ref<Client[]>([])
const loading = ref(false)
const searchText = ref('')
const showClearDialog = ref(false)
const clientIDFilter = ref((route.query.clientID as string) || '')
const userFilter = ref((route.query.user as string) || '')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let requestSeq = 0
let searchDebounceTimer: number | null = null
const clientOptions = computed(() => {
return clients.value
.map((c) => ({
key: c.key,
clientID: c.clientID,
user: c.user,
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
}))
.sort((a, b) => a.label.localeCompare(b.label))
})
// Server info cache - cache the Promise itself so concurrent first calls
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
let serverInfoPromise: Promise<ServerInfo> | null = null
// Compute selected client key for el-select v-model
const selectedClientKey = computed(() => {
if (!clientIDFilter.value) return ''
const client = clientOptions.value.find(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
// Return a synthetic key even if not found, so the select shows the filter is active
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
})
const selectedClientLabel = computed(() => {
if (!clientIDFilter.value) return 'All Clients'
const client = clientOptions.value.find(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`
})
const filteredClientOptions = (filterText: string) => {
if (!filterText) return clientOptions.value
const search = filterText.toLowerCase()
return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))
}
// Check if the filtered client exists in the client list
const selectedClientInList = computed(() => {
if (!clientIDFilter.value) return true
return clientOptions.value.some(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
})
const filteredProxies = computed(() => {
let result = proxies.value
// Filter by clientID and user if specified
if (clientIDFilter.value) {
result = result.filter(
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
)
}
// Filter by search text across multiple fields
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter((p) => {
const fields: unknown[] = [
p.name,
p.type,
p.clientID,
p.user,
p.addr,
p.port,
p.customDomains,
p.subdomain,
]
return fields.some((v) => matchesSearch(v, search))
const fetchServerInfo = (): Promise<ServerInfo> => {
if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => {
// Allow retry after failure
serverInfoPromise = null
throw err
})
}
return result
})
// Normalize a field of unknown shape (string / number / array / null) to a
// lowercase string for case-insensitive substring matching. Arrays are joined
// so e.g. customDomains: ["A.com","B.com"] is searchable as one blob.
const matchesSearch = (value: unknown, needle: string): boolean => {
if (value === null || value === undefined) return false
let str: string
if (Array.isArray(value)) {
str = value
.filter((v) => v !== null && v !== undefined)
.map((v) => String(v))
.join(' ')
} else if (typeof value === 'number') {
if (value === 0) return false
str = String(value)
} else {
str = String(value)
}
if (!str) return false
return str.toLowerCase().includes(needle)
return serverInfoPromise
}
const onClientFilterChange = (key: string) => {
if (key) {
const client = clientOptions.value.find((c) => c.key === key)
if (client) {
router.replace({
query: { ...route.query, clientID: client.clientID, user: client.user },
})
}
} else {
const query = { ...route.query }
delete query.clientID
delete query.user
router.replace({ query })
}
}
const fetchClients = async () => {
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
} catch {
// Ignore errors when fetching clients
}
}
// Server info cache
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
}
const convertProxies = async (
type: string,
json: any,
): Promise<BaseProxy[]> => {
const convertProxy = async (
proxy: ProxyStatsInfo,
): Promise<BaseProxy | null> => {
const type = proxy.type || activeType.value
if (type === 'tcp') {
return json.proxies.map((p: any) => new TCPProxy(p))
return new TCPProxy(proxy)
}
if (type === 'udp') {
return json.proxies.map((p: any) => new UDPProxy(p))
return new UDPProxy(proxy)
}
if (type === 'http') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) {
return json.proxies.map(
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
if (info && info.config.vhostHTTPPort) {
return new HTTPProxy(
proxy,
info.config.vhostHTTPPort,
info.config.subdomainHost,
)
}
return []
return null
}
if (type === 'https') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) {
return json.proxies.map(
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
if (info && info.config.vhostHTTPSPort) {
return new HTTPSProxy(
proxy,
info.config.vhostHTTPSPort,
info.config.subdomainHost,
)
}
return []
return null
}
if (type === 'tcpmux') {
const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) {
return json.proxies.map(
(p: any) =>
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
if (info && info.config.tcpmuxHTTPConnectPort) {
return new TCPMuxProxy(
proxy,
info.config.tcpmuxHTTPConnectPort,
info.config.subdomainHost,
)
}
return []
return null
}
if (type === 'stcp') {
return json.proxies.map((p: any) => new STCPProxy(p))
return new STCPProxy(proxy)
}
if (type === 'sudp') {
return json.proxies.map((p: any) => new SUDPProxy(p))
return new SUDPProxy(proxy)
}
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
return json.proxies.map((p: any) => {
const bp = new BaseProxy(p)
bp.type = type
return bp
})
const bp = new BaseProxy(proxy)
bp.type = type
return bp
}
const allProxyTypes = [
'tcp',
'udp',
'http',
'https',
'tcpmux',
'stcp',
'xtcp',
'sudp',
]
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
const converted = await Promise.all(items.map((item) => convertProxy(item)))
return converted.filter((item): item is BaseProxy => item !== null)
}
const fetchData = async () => {
loading.value = true
proxies.value = []
const fetchData = async (silent = false) => {
const seq = ++requestSeq
if (!silent) loading.value = true
try {
const type = activeType.value
const q = searchText.value.trim()
const data = await getProxiesV2({
page: page.value,
pageSize: pageSize.value,
type: activeType.value === 'all' ? undefined : activeType.value,
q: q || undefined,
})
if (seq !== requestSeq) return
if (type === 'all') {
const results = await Promise.all(
allProxyTypes.map(async (t) => {
const json = await getProxiesByType(t)
return convertProxies(t, json)
}),
)
proxies.value = results.flat()
} else {
const json = await getProxiesByType(type)
proxies.value = await convertProxies(type, json)
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchData(silent)
return
}
const converted = await convertProxies(data.items)
if (seq !== requestSeq) return
proxies.value = converted
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) {
if (seq !== requestSeq) return
ElMessage({
showClose: true,
message: 'Failed to fetch proxies: ' + error.message,
type: 'error',
})
} finally {
loading.value = false
if (seq === requestSeq) {
loading.value = false
}
}
}
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const refreshData = () => {
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const handleClearConfirm = async () => {
showClearDialog.value = false
await clearOfflineProxies()
@@ -400,25 +299,45 @@ const clearOfflineProxies = async () => {
}
}
const sanitizeClientQuery = () => {
const hasClientQuery =
Object.prototype.hasOwnProperty.call(route.query, 'clientID') ||
Object.prototype.hasOwnProperty.call(route.query, 'user')
if (!hasClientQuery) return
const query = { ...route.query }
delete query.clientID
delete query.user
router.replace({ query })
}
// Watch for type changes
watch(activeType, (newType) => {
clearSearchDebounce()
page.value = 1
// Update route but preserve query params
router.replace({ params: { type: newType }, query: route.query })
fetchData()
})
// Watch for route query changes (client filter)
watch(
() => [route.query.clientID, route.query.user],
([newClientID, newUser]) => {
clientIDFilter.value = (newClientID as string) || ''
userFilter.value = (newUser as string) || ''
},
)
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
watch(() => route.query, sanitizeClientQuery)
onUnmounted(() => {
clearSearchDebounce()
})
// Initial fetch
sanitizeClientQuery()
fetchData()
fetchClients()
</script>
<style scoped>
@@ -485,16 +404,11 @@ fetchClients()
flex: 1;
}
.main-search :deep(.el-input__wrapper),
.client-filter :deep(.el-input__wrapper) {
.main-search :deep(.el-input__wrapper) {
height: 32px;
border-radius: 8px;
}
.client-filter {
width: 240px;
}
.type-tabs {
display: flex;
gap: 8px;
@@ -539,13 +453,18 @@ fetchClients()
padding: 60px 0;
}
.pagination-section {
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.search-row {
flex-direction: column;
}
.client-filter {
width: 100%;
.pagination-section {
justify-content: center;
}
}
</style>

View File

@@ -241,7 +241,7 @@ import {
Tickets,
Location,
} from '@element-plus/icons-vue'
import { getProxyByName } from '../api/proxy'
import { getProxyByNameV2 } from '../api/proxy'
import { getServerInfo } from '../api/server'
import {
BaseProxy,
@@ -254,6 +254,7 @@ import {
SUDPProxy,
} from '../utils/proxy'
import Traffic from '../components/Traffic.vue'
import type { ServerInfo } from '../types/server'
const route = useRoute()
const router = useRouter()
@@ -275,12 +276,7 @@ const goBack = () => {
}
}
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
let serverInfo: ServerInfo | null = null
const clientLink = computed(() => {
if (!proxy.value) return ''
@@ -365,27 +361,32 @@ const fetchProxy = async () => {
}
try {
const data = await getProxyByName(name)
const data = await getProxyByNameV2(name)
const info = await fetchServerInfo()
const type = data.conf?.type || ''
const config = info.config
const type = data.type || data.conf?.type || ''
if (type === 'tcp') {
proxy.value = new TCPProxy(data)
} else if (type === 'udp') {
proxy.value = new UDPProxy(data)
} else if (type === 'http' && info?.vhostHTTPPort) {
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
} else if (type === 'https' && info?.vhostHTTPSPort) {
} else if (type === 'http' && config.vhostHTTPPort) {
proxy.value = new HTTPProxy(
data,
config.vhostHTTPPort,
config.subdomainHost,
)
} else if (type === 'https' && config.vhostHTTPSPort) {
proxy.value = new HTTPSProxy(
data,
info.vhostHTTPSPort,
info.subdomainHost,
config.vhostHTTPSPort,
config.subdomainHost,
)
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
} else if (type === 'tcpmux' && config.tcpmuxHTTPConnectPort) {
proxy.value = new TCPMuxProxy(
data,
info.tcpmuxHTTPConnectPort,
info.subdomainHost,
config.tcpmuxHTTPConnectPort,
config.subdomainHost,
)
} else if (type === 'stcp') {
proxy.value = new STCPProxy(data)

View File

@@ -4,7 +4,7 @@
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Clients"
:value="data.clientCounts"
:value="data.status.clientCounts"
type="clients"
subtitle="Connected clients"
to="/clients"
@@ -13,7 +13,7 @@
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Proxies"
:value="data.proxyCounts"
:value="proxyCounts"
type="proxies"
subtitle="Active proxies"
to="/proxies/tcp"
@@ -22,7 +22,7 @@
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Connections"
:value="data.curConns"
:value="data.status.curConns"
type="connections"
subtitle="Current connections"
/>
@@ -54,7 +54,7 @@
<div class="traffic-info">
<div class="label">Inbound</div>
<div class="value">
{{ formatFileSize(data.totalTrafficIn) }}
{{ formatFileSize(data.status.totalTrafficIn) }}
</div>
</div>
</div>
@@ -66,7 +66,7 @@
<div class="traffic-info">
<div class="label">Outbound</div>
<div class="value">
{{ formatFileSize(data.totalTrafficOut) }}
{{ formatFileSize(data.status.totalTrafficOut) }}
</div>
</div>
</div>
@@ -83,7 +83,7 @@
</template>
<div class="proxy-types-grid">
<div
v-for="(count, type) in data.proxyTypeCounts"
v-for="(count, type) in data.status.proxyTypeCount"
:key="type"
class="proxy-type-item"
v-show="count > 0"
@@ -109,51 +109,51 @@
<div class="config-grid">
<div class="config-item">
<span class="config-label">Bind Port</span>
<span class="config-value">{{ data.bindPort }}</span>
<span class="config-value">{{ data.config.bindPort }}</span>
</div>
<div class="config-item" v-if="data.kcpBindPort != 0">
<div class="config-item" v-if="data.config.kcpBindPort != 0">
<span class="config-label">KCP Port</span>
<span class="config-value">{{ data.kcpBindPort }}</span>
<span class="config-value">{{ data.config.kcpBindPort }}</span>
</div>
<div class="config-item" v-if="data.quicBindPort != 0">
<div class="config-item" v-if="data.config.quicBindPort != 0">
<span class="config-label">QUIC Port</span>
<span class="config-value">{{ data.quicBindPort }}</span>
<span class="config-value">{{ data.config.quicBindPort }}</span>
</div>
<div class="config-item" v-if="data.vhostHTTPPort != 0">
<div class="config-item" v-if="data.config.vhostHTTPPort != 0">
<span class="config-label">HTTP Port</span>
<span class="config-value">{{ data.vhostHTTPPort }}</span>
<span class="config-value">{{ data.config.vhostHTTPPort }}</span>
</div>
<div class="config-item" v-if="data.vhostHTTPSPort != 0">
<div class="config-item" v-if="data.config.vhostHTTPSPort != 0">
<span class="config-label">HTTPS Port</span>
<span class="config-value">{{ data.vhostHTTPSPort }}</span>
<span class="config-value">{{ data.config.vhostHTTPSPort }}</span>
</div>
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0">
<div class="config-item" v-if="data.config.tcpmuxHTTPConnectPort != 0">
<span class="config-label">TCPMux Port</span>
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span>
<span class="config-value">{{ data.config.tcpmuxHTTPConnectPort }}</span>
</div>
<div class="config-item" v-if="data.subdomainHost != ''">
<div class="config-item" v-if="data.config.subdomainHost != ''">
<span class="config-label">Subdomain Host</span>
<span class="config-value">{{ data.subdomainHost }}</span>
<span class="config-value">{{ data.config.subdomainHost }}</span>
</div>
<div class="config-item">
<span class="config-label">Max Pool Count</span>
<span class="config-value">{{ data.maxPoolCount }}</span>
<span class="config-value">{{ data.config.maxPoolCount }}</span>
</div>
<div class="config-item">
<span class="config-label">Max Ports/Client</span>
<span class="config-value">{{ data.maxPortsPerClient }}</span>
<span class="config-value">{{ maxPortsPerClientLabel }}</span>
</div>
<div class="config-item" v-if="data.allowPortsStr != ''">
<div class="config-item" v-if="data.config.allowPortsStr != ''">
<span class="config-label">Allow Ports</span>
<span class="config-value">{{ data.allowPortsStr }}</span>
<span class="config-value">{{ data.config.allowPortsStr }}</span>
</div>
<div class="config-item" v-if="data.tlsForce">
<div class="config-item" v-if="data.config.tlsForce">
<span class="config-label">TLS Force</span>
<el-tag size="small" type="warning">Enabled</el-tag>
</div>
<div class="config-item">
<span class="config-label">Heartbeat Timeout</span>
<span class="config-value">{{ data.heartbeatTimeout }}s</span>
<span class="config-value">{{ data.config.heartbeatTimeout }}s</span>
</div>
</div>
</el-card>
@@ -167,69 +167,59 @@ import { formatFileSize } from '../utils/format'
import { Download, Upload } from '@element-plus/icons-vue'
import StatCard from '../components/StatCard.vue'
import { getServerInfo } from '../api/server'
import type { ServerInfo } from '../types/server'
const data = ref({
const data = ref<ServerInfo>({
version: '',
bindPort: 0,
kcpBindPort: 0,
quicBindPort: 0,
vhostHTTPPort: 0,
vhostHTTPSPort: 0,
tcpmuxHTTPConnectPort: 0,
subdomainHost: '',
maxPoolCount: 0,
maxPortsPerClient: '',
allowPortsStr: '',
tlsForce: false,
heartbeatTimeout: 0,
clientCounts: 0,
curConns: 0,
proxyCounts: 0,
totalTrafficIn: 0,
totalTrafficOut: 0,
proxyTypeCounts: {} as Record<string, number>,
config: {
bindPort: 0,
kcpBindPort: 0,
quicBindPort: 0,
vhostHTTPPort: 0,
vhostHTTPSPort: 0,
tcpmuxHTTPConnectPort: 0,
subdomainHost: '',
maxPoolCount: 0,
maxPortsPerClient: 0,
allowPortsStr: '',
tlsForce: false,
heartbeatTimeout: 0,
},
status: {
clientCounts: 0,
curConns: 0,
totalTrafficIn: 0,
totalTrafficOut: 0,
proxyTypeCount: {},
},
})
const hasActiveProxies = computed(() => {
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)
return Object.values(data.value.status.proxyTypeCount).some((c) => c > 0)
})
const proxyCounts = computed(() => {
return Object.values(data.value.status.proxyTypeCount).reduce(
(sum, count) => sum + (count || 0),
0,
)
})
const maxPortsPerClientLabel = computed(() => {
const value = data.value.config.maxPortsPerClient
return value === 0 ? 'no limit' : String(value)
})
const formatTrafficTotal = () => {
const total = data.value.totalTrafficIn + data.value.totalTrafficOut
const total =
data.value.status.totalTrafficIn + data.value.status.totalTrafficOut
return formatFileSize(total)
}
const fetchData = async () => {
try {
const json = await getServerInfo()
data.value.version = json.version
data.value.bindPort = json.bindPort
data.value.kcpBindPort = json.kcpBindPort
data.value.quicBindPort = json.quicBindPort
data.value.vhostHTTPPort = json.vhostHTTPPort
data.value.vhostHTTPSPort = json.vhostHTTPSPort
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
data.value.subdomainHost = json.subdomainHost
data.value.maxPoolCount = json.maxPoolCount
data.value.maxPortsPerClient = String(json.maxPortsPerClient)
if (data.value.maxPortsPerClient == '0') {
data.value.maxPortsPerClient = 'no limit'
}
data.value.allowPortsStr = json.allowPortsStr
data.value.tlsForce = json.tlsForce
data.value.heartbeatTimeout = json.heartbeatTimeout
data.value.clientCounts = json.clientCounts
data.value.curConns = json.curConns
data.value.totalTrafficIn = json.totalTrafficIn
data.value.totalTrafficOut = json.totalTrafficOut
data.value.proxyTypeCounts = json.proxyTypeCount || {}
data.value.proxyCounts = 0
if (json.proxyTypeCount != null) {
Object.values(json.proxyTypeCount).forEach((count: any) => {
data.value.proxyCounts += count || 0
})
}
data.value = json
} catch {
ElMessage({
showClose: true,

349
web/package-lock.json generated
View File

@@ -147,14 +147,13 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
@@ -164,14 +163,13 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -181,14 +179,13 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -198,14 +195,13 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -215,14 +211,13 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -232,14 +227,13 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -249,14 +243,13 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -266,14 +259,13 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -283,14 +275,13 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -300,14 +291,13 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -317,14 +307,13 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -334,14 +323,13 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -351,14 +339,13 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -368,14 +355,13 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -385,14 +371,13 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -402,14 +387,13 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -419,14 +403,13 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -436,14 +419,13 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -453,14 +435,13 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -470,14 +451,13 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -487,14 +467,13 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -504,14 +483,13 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
@@ -521,14 +499,13 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
@@ -538,14 +515,13 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -555,14 +531,13 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -572,14 +547,13 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -954,76 +928,6 @@
"node": ">= 8"
}
},
"node_modules/@nuxt/kit": {
"version": "3.21.2",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
"integrity": "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
"defu": "^6.1.4",
"destr": "^2.0.5",
"errx": "^0.1.0",
"exsolve": "^1.0.8",
"ignore": "^7.0.5",
"jiti": "^2.6.1",
"klona": "^2.0.6",
"knitwork": "^1.3.0",
"mlly": "^1.8.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"pkg-types": "^2.3.0",
"rc9": "^3.0.0",
"scule": "^1.3.0",
"semver": "^7.7.4",
"tinyglobby": "^0.2.15",
"ufo": "^1.6.3",
"unctx": "^2.5.0",
"untyped": "^2.0.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/@nuxt/kit/node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@nuxt/kit/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@nuxt/kit/node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
@@ -2567,9 +2471,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"dependencies": {
"balanced-match": "^4.0.2"
@@ -3450,12 +3354,11 @@
}
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
@@ -3463,32 +3366,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escape-string-regexp": {
@@ -4859,11 +4762,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"license": "MIT",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"dependencies": {
"argparse": "^2.0.1"
},
@@ -6276,11 +6188,10 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -7460,12 +7371,12 @@
}
},
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true,
"dependencies": {
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",