mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 17:29:16 +08:00
Compare commits
18 Commits
v0.69.0
...
7c343fc6e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c343fc6e7 | ||
|
|
a3b3b35b69 | ||
|
|
ae1c0504ec | ||
|
|
393a533744 | ||
|
|
035889c360 | ||
|
|
940bde5c46 | ||
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec | ||
|
|
9bde0b07de | ||
|
|
c6c545289c | ||
|
|
503afe78b7 | ||
|
|
9ea1d86f03 | ||
|
|
ac3e82db4e | ||
|
|
0773938d70 | ||
|
|
9bacce22a2 | ||
|
|
7f8d68b666 |
20
README.md
20
README.md
@@ -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?
|
||||
|
||||
20
README_zh.md
20
README_zh.md
@@ -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 ?
|
||||
|
||||
27
Release.md
27
Release.md
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
66
client/proxy/xtcp_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
package version
|
||||
|
||||
var version = "0.69.0"
|
||||
var version = "0.69.1"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
@@ -48,6 +48,14 @@ 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/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")
|
||||
subRouter.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(
|
||||
|
||||
@@ -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
|
||||
|
||||
438
server/http/controller_v2.go
Normal file
438
server/http/controller_v2.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// 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"
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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/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 := ctx.Param("key")
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("missing client key")
|
||||
}
|
||||
decodedKey, err := url.PathUnescape(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid client key %q: %w", key, err)
|
||||
}
|
||||
key = decodedKey
|
||||
|
||||
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 := ctx.Param("name")
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("missing proxy name")
|
||||
}
|
||||
|
||||
ps := mem.StatsCollector.GetProxyByName(name)
|
||||
if ps == nil {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||
}
|
||||
return c.buildV2ProxyResp(ps), 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 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 (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,
|
||||
},
|
||||
}
|
||||
}
|
||||
429
server/http/controller_v2_test.go
Normal file
429
server/http/controller_v2_test.go
Normal file
@@ -0,0 +1,429 @@
|
||||
// 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"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
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 {
|
||||
proxies map[string]*mem.ProxyStats
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||
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 nil
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||
return 0, len(f.proxies)
|
||||
}
|
||||
|
||||
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 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/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)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
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/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)
|
||||
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).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)
|
||||
return router
|
||||
}
|
||||
|
||||
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, 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
|
||||
}
|
||||
57
server/http/model/v2.go
Normal file
57
server/http/model/v2.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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 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"`
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
141
server/proxy/sudp_test.go
Normal 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))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
93
server/proxy/xtcp_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
61
server/visitor/visitor_test.go
Normal file
61
server/visitor/visitor_test.go
Normal 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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
1
web/frps/components.d.ts
vendored
1
web/frps/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -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,55 @@ 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' }),
|
||||
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { http } from './http'
|
||||
import { buildQueryString, http } from './http'
|
||||
import type { V2Page } from './http'
|
||||
import type {
|
||||
GetProxyResponse,
|
||||
ProxyListV2Params,
|
||||
ProxyStatsInfo,
|
||||
ProxyV2Info,
|
||||
TrafficResponse,
|
||||
} from '../types/proxy'
|
||||
|
||||
@@ -9,6 +12,40 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
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.phase,
|
||||
})
|
||||
|
||||
export const getProxy = (type: string, name: string) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface ProxyStatsInfo {
|
||||
name: string
|
||||
type?: string
|
||||
conf: any
|
||||
user: string
|
||||
clientID: string
|
||||
@@ -15,6 +16,34 @@ 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 {
|
||||
phase: string
|
||||
todayTrafficIn: number
|
||||
todayTrafficOut: number
|
||||
curConns: number
|
||||
lastStartTime: string
|
||||
lastCloseTime: string
|
||||
}
|
||||
|
||||
export interface TrafficResponse {
|
||||
name: string
|
||||
trafficIn: number[]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,7 @@ import {
|
||||
} from '../utils/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
import type { ProxyStatsInfo } from '../types/proxy'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -163,119 +175,190 @@ 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: {
|
||||
type ServerInfoLite = {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | 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 fetchClient = async () => {
|
||||
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
||||
|
||||
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
||||
if (!serverInfoPromise) {
|
||||
serverInfoPromise = getServerInfo().catch((err) => {
|
||||
serverInfoPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return serverInfoPromise
|
||||
}
|
||||
|
||||
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.vhostHTTPPort) {
|
||||
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
||||
}
|
||||
allProxies.value = proxies
|
||||
} catch {
|
||||
// Ignore
|
||||
return null
|
||||
}
|
||||
if (type === 'https') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.vhostHTTPSPort) {
|
||||
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'tcpmux') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.tcpmuxHTTPConnectPort) {
|
||||
return new TCPMuxProxy(
|
||||
proxy,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.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 +566,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,12 @@ 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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -141,244 +120,161 @@ 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))
|
||||
})
|
||||
|
||||
// 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))
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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: {
|
||||
// Server info cache - cache the Promise itself so concurrent first calls
|
||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||
type ServerInfoLite = {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
}
|
||||
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
||||
|
||||
const fetchServerInfo = async () => {
|
||||
if (serverInfo) return serverInfo
|
||||
const res = await getServerInfo()
|
||||
serverInfo = res
|
||||
return serverInfo
|
||||
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
||||
if (!serverInfoPromise) {
|
||||
serverInfoPromise = getServerInfo().catch((err) => {
|
||||
// Allow retry after failure
|
||||
serverInfoPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return serverInfoPromise
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
return new HTTPProxy(proxy, info.vhostHTTPPort, info.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),
|
||||
)
|
||||
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.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),
|
||||
return new TCPMuxProxy(
|
||||
proxy,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.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 +296,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 +401,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 +450,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>
|
||||
|
||||
349
web/package-lock.json
generated
349
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user