mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 17:29:16 +08:00
Compare commits
9 Commits
84be1938e4
...
v0.69.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8c1e5116c | ||
|
|
4ec8de973f | ||
|
|
5bfcea3d0c | ||
|
|
0a1b4ab21f | ||
|
|
5f575b8442 | ||
|
|
a1348cdf00 | ||
|
|
2f5e1f7945 | ||
|
|
22ae8166d3 | ||
|
|
af6bc6369d |
20
README.md
20
README.md
@@ -13,16 +13,6 @@ 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">
|
||||
@@ -40,6 +30,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<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,16 +15,6 @@ 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">
|
||||
@@ -42,6 +32,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<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 ?
|
||||
|
||||
26
Release.md
26
Release.md
@@ -1,9 +1,21 @@
|
||||
## 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
|
||||
|
||||
* Expanded the frps dashboard API v2 migration across Clients, Proxies, Server Overview, Client Detail, and Proxy Detail, covering paginated users/clients/proxies, detail data, proxy traffic history, server system info, offline proxy statistics pruning, server-side pagination, search, and proxy type filtering.
|
||||
|
||||
## 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.
|
||||
* 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.
|
||||
|
||||
@@ -87,7 +87,6 @@ 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
|
||||
@@ -110,7 +109,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||
workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
|
||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
||||
defer closeFn()
|
||||
|
||||
for {
|
||||
@@ -123,7 +122,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
var udpMsg msg.UDPPacket
|
||||
if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
|
||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
||||
xl.Warnf("read from workConn for sudp error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -138,7 +137,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||
workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
|
||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
||||
defer func() {
|
||||
closeFn()
|
||||
xl.Infof("writer goroutine for sudp work connection closed")
|
||||
@@ -149,12 +148,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(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
|
||||
m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())
|
||||
case *msg.Ping:
|
||||
xl.Tracef("frpc send ping message to frpc visitor")
|
||||
}
|
||||
|
||||
if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
|
||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
||||
xl.Errorf("sudp work write error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -185,8 +184,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnSenderFn(payloadConn, sendCh)
|
||||
go workConnReaderFn(payloadConn, readCh)
|
||||
go workConnSenderFn(workConn, sendCh)
|
||||
go workConnReaderFn(workConn, readCh)
|
||||
go heartbeatFn(sendCh)
|
||||
|
||||
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
||||
|
||||
@@ -99,17 +99,15 @@ 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(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
|
||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
||||
for {
|
||||
var udpMsg msg.UDPPacket
|
||||
if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
|
||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -122,7 +120,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
workConnSenderFn := func(rw msg.ReadWriter, sendCh chan msg.Message) {
|
||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
||||
defer func() {
|
||||
xl.Infof("writer goroutine for udp work connection closed")
|
||||
}()
|
||||
@@ -134,7 +132,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
case *msg.Ping:
|
||||
xl.Tracef("send ping message to udp workConn")
|
||||
}
|
||||
if errRet = rw.WriteMsg(rawMsg); errRet != nil {
|
||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
||||
xl.Errorf("udp work write error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -153,8 +151,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnSenderFn(payloadRW, pxy.sendCh)
|
||||
go workConnReaderFn(payloadRW, pxy.readCh)
|
||||
go workConnSenderFn(pxy.workConn, pxy.sendCh)
|
||||
go workConnReaderFn(pxy.workConn, pxy.readCh)
|
||||
go heartbeatFn(pxy.sendCh)
|
||||
|
||||
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
||||
|
||||
@@ -57,7 +57,8 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
|
||||
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
||||
xl := pxy.xl
|
||||
defer conn.Close()
|
||||
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
|
||||
var natHoleSidMsg msg.NatHoleSid
|
||||
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
|
||||
if err != nil {
|
||||
xl.Errorf("xtcp read from workConn error: %v", err)
|
||||
return
|
||||
@@ -130,15 +131,6 @@ 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()
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// 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,16 +113,15 @@ 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(payloadConn *msg.Conn) {
|
||||
workConnReaderFn := func(conn net.Conn) {
|
||||
defer func() {
|
||||
payloadConn.Close()
|
||||
conn.Close()
|
||||
close(closeCh)
|
||||
wg.Done()
|
||||
}()
|
||||
@@ -134,13 +133,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
)
|
||||
|
||||
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
|
||||
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
||||
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
||||
return
|
||||
}
|
||||
|
||||
_ = payloadConn.SetReadDeadline(time.Time{})
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
switch m := rawMsg.(type) {
|
||||
case *msg.Ping:
|
||||
xl.Debugf("frpc visitor get ping message from frpc")
|
||||
@@ -158,15 +157,15 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
}
|
||||
|
||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||
workConnSenderFn := func(payloadConn *msg.Conn) {
|
||||
workConnSenderFn := func(conn net.Conn) {
|
||||
defer func() {
|
||||
payloadConn.Close()
|
||||
conn.Close()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
var errRet error
|
||||
if firstPacket != nil {
|
||||
if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
|
||||
if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
|
||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -181,7 +180,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
return
|
||||
}
|
||||
|
||||
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
|
||||
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
|
||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -192,8 +191,8 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnReaderFn(payloadConn)
|
||||
go workConnSenderFn(payloadConn)
|
||||
go workConnReaderFn(workConn)
|
||||
go workConnSenderFn(workConn)
|
||||
|
||||
wg.Wait()
|
||||
xl.Infof("sudp worker is closed")
|
||||
|
||||
@@ -394,10 +394,6 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -421,31 +417,6 @@ 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,8 +17,6 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -464,111 +462,6 @@ 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)
|
||||
|
||||
@@ -95,7 +95,9 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
defer m.mu.Unlock()
|
||||
total = len(m.info.ProxyStatistics)
|
||||
for name, data := range m.info.ProxyStatistics {
|
||||
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
||||
if !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
||||
delete(m.info.ProxyStatistics, name)
|
||||
count++
|
||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||
@@ -104,20 +106,10 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
return count, total
|
||||
}
|
||||
|
||||
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
|
||||
return !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
|
||||
}
|
||||
|
||||
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
||||
return m.clearUselessInfo(0)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
|
||||
return m.clearUselessInfo(0)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) NewClient() {
|
||||
m.info.ClientCounts.Inc(1)
|
||||
}
|
||||
@@ -239,11 +231,9 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
|
||||
}
|
||||
if !proxyStats.LastStartTime.IsZero() {
|
||||
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||
ps.LastStartAt = proxyStats.LastStartTime.Unix()
|
||||
}
|
||||
if !proxyStats.LastCloseTime.IsZero() {
|
||||
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
@@ -22,12 +22,6 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
||||
clk.SetTime(closedAt)
|
||||
metrics.CloseProxy("proxy", "tcp")
|
||||
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
||||
|
||||
stats := metrics.GetProxyByName("proxy")
|
||||
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
|
||||
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
|
||||
require.Equal(start.Unix(), stats.LastStartAt)
|
||||
require.Equal(closedAt.Unix(), stats.LastCloseAt)
|
||||
}
|
||||
|
||||
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
@@ -49,70 +43,6 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
require.Empty(metrics.info.ProxyStatistics)
|
||||
}
|
||||
|
||||
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||
Name: "offline",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||
Name: "online",
|
||||
LastStartTime: start,
|
||||
}
|
||||
|
||||
cleared, total := metrics.ClearOfflineProxies()
|
||||
|
||||
require.Equal(1, cleared)
|
||||
require.Equal(2, total)
|
||||
require.False(metrics.hasProxyStatistics("offline"))
|
||||
require.True(metrics.hasProxyStatistics("online"))
|
||||
}
|
||||
|
||||
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||
Name: "offline",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||
Name: "online",
|
||||
LastStartTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
|
||||
Name: "restarted",
|
||||
LastStartTime: start.Add(30 * time.Second),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
|
||||
Name: "same-time",
|
||||
LastStartTime: start,
|
||||
LastCloseTime: start,
|
||||
}
|
||||
|
||||
cleared, total := metrics.PruneOfflineProxies()
|
||||
|
||||
require.Equal(1, cleared)
|
||||
require.Equal(4, total)
|
||||
require.False(metrics.hasProxyStatistics("offline"))
|
||||
require.True(metrics.hasProxyStatistics("online"))
|
||||
require.True(metrics.hasProxyStatistics("restarted"))
|
||||
require.True(metrics.hasProxyStatistics("same-time"))
|
||||
|
||||
cleared, total = metrics.PruneOfflineProxies()
|
||||
require.Equal(0, cleared)
|
||||
require.Equal(3, total)
|
||||
}
|
||||
|
||||
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ type ProxyStats struct {
|
||||
TodayTrafficOut int64
|
||||
LastStartTime string
|
||||
LastCloseTime string
|
||||
LastStartAt int64
|
||||
LastCloseAt int64
|
||||
CurConns int64
|
||||
}
|
||||
|
||||
@@ -87,5 +85,4 @@ type Collector interface {
|
||||
GetProxyByName(proxyName string) *ProxyStats
|
||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||
ClearOfflineProxies() (int, int)
|
||||
PruneOfflineProxies() (int, int)
|
||||
}
|
||||
|
||||
@@ -43,28 +43,9 @@ 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,15 +72,6 @@ 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,12 +26,6 @@ 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)
|
||||
|
||||
@@ -70,27 +64,3 @@ 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,11 +45,6 @@ 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,11 +32,6 @@ 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.1"
|
||||
var version = "0.69.0"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
@@ -48,17 +48,6 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
||||
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
||||
|
||||
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
|
||||
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
||||
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
|
||||
v2EncodedPathRouter.UseEncodedPath()
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||
|
||||
// view
|
||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||
subRouter.PathPrefix("/static/").Handler(
|
||||
|
||||
@@ -112,8 +112,6 @@ type SessionContext struct {
|
||||
ServerCfg *v1.ServerConfig
|
||||
// client registry
|
||||
ClientRegistry *registry.ClientRegistry
|
||||
// negotiated wire protocol for this client session
|
||||
WireProtocol string
|
||||
}
|
||||
|
||||
type Control struct {
|
||||
@@ -454,7 +452,6 @@ 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
|
||||
|
||||
@@ -58,12 +58,8 @@ func NewController(
|
||||
|
||||
// /api/serverinfo
|
||||
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||
return c.buildServerInfoResp(), nil
|
||||
}
|
||||
|
||||
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||
serverStats := mem.StatsCollector.GetServer()
|
||||
return model.ServerInfoResp{
|
||||
svrResp := model.ServerInfoResp{
|
||||
Version: version.Full(),
|
||||
BindPort: c.serverCfg.BindPort,
|
||||
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||
@@ -84,6 +80,8 @@ func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||
ClientCounts: serverStats.ClientCounts,
|
||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||
}
|
||||
|
||||
return svrResp, nil
|
||||
}
|
||||
|
||||
// /api/clients
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
"github.com/fatedier/frp/server/http/model"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultV2Page = 1
|
||||
defaultV2PageSize = 50
|
||||
maxV2PageSize = 200
|
||||
|
||||
v2SystemPruneTypeOfflineProxies = "offline_proxies"
|
||||
v2ProxyTrafficDefaultDays = 7
|
||||
v2ProxyTrafficUnit = "bytes"
|
||||
v2ProxyTrafficGranularity = "day"
|
||||
)
|
||||
|
||||
var apiV2ProxyTypes = []string{
|
||||
string(v1.ProxyTypeTCP),
|
||||
string(v1.ProxyTypeUDP),
|
||||
string(v1.ProxyTypeHTTP),
|
||||
string(v1.ProxyTypeHTTPS),
|
||||
string(v1.ProxyTypeTCPMUX),
|
||||
string(v1.ProxyTypeSTCP),
|
||||
string(v1.ProxyTypeXTCP),
|
||||
string(v1.ProxyTypeSUDP),
|
||||
}
|
||||
|
||||
// /api/v2/system/info
|
||||
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
|
||||
info := c.buildServerInfoResp()
|
||||
proxyTypeCounts := info.ProxyTypeCounts
|
||||
if proxyTypeCounts == nil {
|
||||
proxyTypeCounts = map[string]int64{}
|
||||
}
|
||||
|
||||
return model.V2SystemInfoResp{
|
||||
Version: info.Version,
|
||||
Config: model.V2SystemInfoConfigResp{
|
||||
BindPort: info.BindPort,
|
||||
VhostHTTPPort: info.VhostHTTPPort,
|
||||
VhostHTTPSPort: info.VhostHTTPSPort,
|
||||
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
|
||||
KCPBindPort: info.KCPBindPort,
|
||||
QUICBindPort: info.QUICBindPort,
|
||||
SubdomainHost: info.SubdomainHost,
|
||||
MaxPoolCount: info.MaxPoolCount,
|
||||
MaxPortsPerClient: info.MaxPortsPerClient,
|
||||
HeartbeatTimeout: info.HeartBeatTimeout,
|
||||
AllowPortsStr: info.AllowPortsStr,
|
||||
TLSForce: info.TLSForce,
|
||||
},
|
||||
Status: model.V2SystemInfoStatusResp{
|
||||
TotalTrafficIn: info.TotalTrafficIn,
|
||||
TotalTrafficOut: info.TotalTrafficOut,
|
||||
CurConns: info.CurConns,
|
||||
ClientCounts: info.ClientCounts,
|
||||
ProxyTypeCounts: proxyTypeCounts,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/system/prune
|
||||
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
|
||||
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleared, total := mem.StatsCollector.PruneOfflineProxies()
|
||||
return model.V2SystemPruneResp{
|
||||
Type: pruneType,
|
||||
Cleared: cleared,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/users
|
||||
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
||||
page, pageSize, err := parseV2PageParams(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.clientRegistry == nil {
|
||||
return nil, fmt.Errorf("client registry unavailable")
|
||||
}
|
||||
|
||||
userStats := make(map[string]*model.V2UserResp)
|
||||
for _, info := range c.clientRegistry.List() {
|
||||
item := getOrCreateV2User(userStats, info.User)
|
||||
item.ClientCount++
|
||||
}
|
||||
for _, proxyInfo := range c.listV2ProxyStats("") {
|
||||
item := getOrCreateV2User(userStats, proxyInfo.User)
|
||||
item.ProxyCount++
|
||||
}
|
||||
|
||||
q := strings.ToLower(ctx.Query("q"))
|
||||
items := make([]model.V2UserResp, 0, len(userStats))
|
||||
for _, item := range userStats {
|
||||
if q != "" && !strings.Contains(strings.ToLower(item.User), q) {
|
||||
continue
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
slices.SortFunc(items, func(a, b model.V2UserResp) int {
|
||||
return cmp.Compare(a.User, b.User)
|
||||
})
|
||||
|
||||
return buildV2PageResp(items, page, pageSize), nil
|
||||
}
|
||||
|
||||
// /api/v2/clients
|
||||
func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
||||
page, pageSize, err := parseV2PageParams(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.clientRegistry == nil {
|
||||
return nil, fmt.Errorf("client registry unavailable")
|
||||
}
|
||||
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userFilter, filterByUser := queryValue(ctx, "user")
|
||||
clientIDFilter := ctx.Query("clientID")
|
||||
runIDFilter := ctx.Query("runID")
|
||||
q := strings.ToLower(ctx.Query("q"))
|
||||
|
||||
records := c.clientRegistry.List()
|
||||
items := make([]model.ClientInfoResp, 0, len(records))
|
||||
for _, info := range records {
|
||||
if filterByUser && info.User != userFilter {
|
||||
continue
|
||||
}
|
||||
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
|
||||
continue
|
||||
}
|
||||
if runIDFilter != "" && info.RunID != runIDFilter {
|
||||
continue
|
||||
}
|
||||
if !matchV2StatusFilter(info.Online, statusFilter) {
|
||||
continue
|
||||
}
|
||||
resp := buildClientInfoResp(info)
|
||||
if q != "" && !matchV2ClientQuery(resp, q) {
|
||||
continue
|
||||
}
|
||||
items = append(items, resp)
|
||||
}
|
||||
|
||||
slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
|
||||
if v := cmp.Compare(a.User, b.User); v != 0 {
|
||||
return v
|
||||
}
|
||||
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
|
||||
return v
|
||||
}
|
||||
return cmp.Compare(a.Key, b.Key)
|
||||
})
|
||||
|
||||
return buildV2PageResp(items, page, pageSize), nil
|
||||
}
|
||||
|
||||
// /api/v2/clients/{key}
|
||||
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
||||
key, err := decodeV2PathParam(ctx, "key", "client key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.clientRegistry == nil {
|
||||
return nil, fmt.Errorf("client registry unavailable")
|
||||
}
|
||||
|
||||
info, ok := c.clientRegistry.GetByKey(key)
|
||||
if !ok {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
|
||||
}
|
||||
|
||||
resp := buildClientInfoResp(info)
|
||||
status := c.buildV2ClientStatus(info)
|
||||
return model.V2ClientDetailResp{
|
||||
ClientInfoResp: resp,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/proxies
|
||||
func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||
page, pageSize, err := parseV2PageParams(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userFilter, filterByUser := queryValue(ctx, "user")
|
||||
clientIDFilter := ctx.Query("clientID")
|
||||
q := strings.ToLower(ctx.Query("q"))
|
||||
|
||||
stats := c.listV2ProxyStats(proxyType)
|
||||
items := make([]model.V2ProxyResp, 0, len(stats))
|
||||
for _, ps := range stats {
|
||||
resp := c.buildV2ProxyResp(ps)
|
||||
if filterByUser && resp.User != userFilter {
|
||||
continue
|
||||
}
|
||||
if clientIDFilter != "" && resp.ClientID != clientIDFilter {
|
||||
continue
|
||||
}
|
||||
if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) {
|
||||
continue
|
||||
}
|
||||
if q != "" && !matchV2ProxyQuery(resp, q) {
|
||||
continue
|
||||
}
|
||||
items = append(items, resp)
|
||||
}
|
||||
|
||||
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
||||
if v := cmp.Compare(a.Type, b.Type); v != 0 {
|
||||
return v
|
||||
}
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
return buildV2PageResp(items, page, pageSize), nil
|
||||
}
|
||||
|
||||
// /api/v2/proxies/{name}
|
||||
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ps := mem.StatsCollector.GetProxyByName(name)
|
||||
if ps == nil {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||
}
|
||||
return c.buildV2ProxyResp(ps), nil
|
||||
}
|
||||
|
||||
// /api/v2/proxies/{name}/traffic
|
||||
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
|
||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
|
||||
if proxyTrafficInfo == nil {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||
}
|
||||
|
||||
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
|
||||
}
|
||||
|
||||
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
|
||||
raw := ctx.Param(key)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("missing %s", label)
|
||||
}
|
||||
decoded, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
||||
item, ok := items[user]
|
||||
if !ok {
|
||||
item = &model.V2UserResp{User: user}
|
||||
items[user] = item
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func parseV2PageParams(ctx *httppkg.Context) (int, int, error) {
|
||||
page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if pageSize > maxV2PageSize {
|
||||
return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize))
|
||||
}
|
||||
if page > math.MaxInt/pageSize {
|
||||
return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large")
|
||||
}
|
||||
return page, pageSize, nil
|
||||
}
|
||||
|
||||
func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) {
|
||||
if raw == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseV2StatusFilter(raw string) (string, error) {
|
||||
status := strings.ToLower(raw)
|
||||
switch status {
|
||||
case "", "all", "online", "offline":
|
||||
return status, nil
|
||||
default:
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline")
|
||||
}
|
||||
}
|
||||
|
||||
func parseV2ProxyTypeFilter(raw string) (string, error) {
|
||||
proxyType := strings.ToLower(raw)
|
||||
if proxyType == "" {
|
||||
return "", nil
|
||||
}
|
||||
if slices.Contains(apiV2ProxyTypes, proxyType) {
|
||||
return proxyType, nil
|
||||
}
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
||||
}
|
||||
|
||||
func parseV2SystemPruneType(raw string) (string, error) {
|
||||
pruneType := strings.ToLower(raw)
|
||||
switch pruneType {
|
||||
case "":
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
|
||||
case v2SystemPruneTypeOfflineProxies:
|
||||
return pruneType, nil
|
||||
default:
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
|
||||
}
|
||||
}
|
||||
|
||||
func matchV2StatusFilter(online bool, filter string) bool {
|
||||
switch filter {
|
||||
case "", "all":
|
||||
return true
|
||||
case "online":
|
||||
return online
|
||||
case "offline":
|
||||
return !online
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] {
|
||||
total := len(items)
|
||||
return model.V2PageResp[T]{
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Items: paginateV2Items(items, page, pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
func paginateV2Items[T any](items []T, page, pageSize int) []T {
|
||||
start := (page - 1) * pageSize
|
||||
if start >= len(items) {
|
||||
return []T{}
|
||||
}
|
||||
end := min(start+pageSize, len(items))
|
||||
return items[start:end]
|
||||
}
|
||||
|
||||
func queryValue(ctx *httppkg.Context, key string) (string, bool) {
|
||||
values, ok := ctx.Req.URL.Query()[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return "", true
|
||||
}
|
||||
return values[0], true
|
||||
}
|
||||
|
||||
func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
|
||||
return containsV2Query(q,
|
||||
item.Key,
|
||||
item.User,
|
||||
item.ClientID,
|
||||
item.RunID,
|
||||
item.Version,
|
||||
item.WireProtocol,
|
||||
item.Hostname,
|
||||
item.ClientIP,
|
||||
)
|
||||
}
|
||||
|
||||
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
||||
values := []string{
|
||||
item.Name,
|
||||
item.Type,
|
||||
item.User,
|
||||
item.ClientID,
|
||||
item.Status.State,
|
||||
}
|
||||
|
||||
switch spec := item.Spec.(type) {
|
||||
case *model.TCPOutConf:
|
||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||
case *model.UDPOutConf:
|
||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||
case *model.HTTPOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
case *model.HTTPSOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
case *model.TCPMuxOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
}
|
||||
|
||||
return containsV2Query(q, values...)
|
||||
}
|
||||
|
||||
func containsV2Query(q string, values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.Contains(strings.ToLower(value), q) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
||||
if proxyType != "" {
|
||||
return mem.StatsCollector.GetProxiesByType(proxyType)
|
||||
}
|
||||
|
||||
items := make([]*mem.ProxyStats, 0)
|
||||
for _, t := range apiV2ProxyTypes {
|
||||
items = append(items, mem.StatsCollector.GetProxiesByType(t)...)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
|
||||
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
|
||||
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
|
||||
history = append(history, model.V2ProxyTrafficPointResp{
|
||||
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
|
||||
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
|
||||
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
|
||||
})
|
||||
}
|
||||
|
||||
return model.V2ProxyTrafficResp{
|
||||
Name: name,
|
||||
Unit: v2ProxyTrafficUnit,
|
||||
Granularity: v2ProxyTrafficGranularity,
|
||||
History: history,
|
||||
}
|
||||
}
|
||||
|
||||
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
|
||||
if todayFirstIndex >= len(values) {
|
||||
return 0
|
||||
}
|
||||
return values[todayFirstIndex]
|
||||
}
|
||||
|
||||
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
||||
status := model.V2ClientStatusResp{State: "offline"}
|
||||
if info.Online {
|
||||
status.State = "online"
|
||||
}
|
||||
|
||||
user := info.User
|
||||
clientID := info.ClientID()
|
||||
for _, ps := range c.listV2ProxyStats("") {
|
||||
if ps.User != user || ps.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
status.CurConns += ps.CurConns
|
||||
status.ProxyCount++
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
||||
state := "offline"
|
||||
var spec any
|
||||
if c.pxyManager != nil {
|
||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||
state = "online"
|
||||
spec = getConfFromConfigurer(pxy.GetConfigurer())
|
||||
}
|
||||
}
|
||||
|
||||
return model.V2ProxyResp{
|
||||
Name: ps.Name,
|
||||
Type: ps.Type,
|
||||
User: ps.User,
|
||||
ClientID: ps.ClientID,
|
||||
Spec: spec,
|
||||
Status: model.V2ProxyStatusResp{
|
||||
State: state,
|
||||
TodayTrafficIn: ps.TodayTrafficIn,
|
||||
TodayTrafficOut: ps.TodayTrafficOut,
|
||||
CurConns: ps.CurConns,
|
||||
LastStartAt: ps.LastStartAt,
|
||||
LastCloseAt: ps.LastCloseAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,870 +0,0 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/types"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
"github.com/fatedier/frp/server/http/model"
|
||||
serverproxy "github.com/fatedier/frp/server/proxy"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
type v2EnvelopeForTest[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
type fakeStatsCollector struct {
|
||||
server *mem.ServerStats
|
||||
proxies map[string]*mem.ProxyStats
|
||||
traffic map[string]*mem.ProxyTrafficInfo
|
||||
pruneable map[string]bool
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||
if f.server != nil {
|
||||
return f.server
|
||||
}
|
||||
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
|
||||
items := make([]*mem.ProxyStats, 0)
|
||||
for _, ps := range f.proxies {
|
||||
if ps.Type == proxyType {
|
||||
items = append(items, ps)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
|
||||
ps := f.proxies[proxyName]
|
||||
if ps != nil && ps.Type == proxyType {
|
||||
return ps
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
||||
return f.proxies[proxyName]
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
||||
return f.traffic[name]
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||
return 0, len(f.proxies)
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
|
||||
total := len(f.proxies)
|
||||
cleared := 0
|
||||
for name := range f.pruneable {
|
||||
if _, ok := f.proxies[name]; ok {
|
||||
delete(f.proxies, name)
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
f.pruneable = map[string]bool{}
|
||||
return cleared, total
|
||||
}
|
||||
|
||||
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
server: &mem.ServerStats{
|
||||
TotalTrafficIn: 1024,
|
||||
TotalTrafficOut: 2048,
|
||||
CurConns: 3,
|
||||
ClientCounts: 4,
|
||||
ProxyTypeCounts: map[string]int64{
|
||||
"tcp": 2,
|
||||
"http": 1,
|
||||
},
|
||||
},
|
||||
proxies: map[string]*mem.ProxyStats{},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
VhostHTTPSPort: 8443,
|
||||
TCPMuxHTTPConnectPort: 9000,
|
||||
KCPBindPort: 7001,
|
||||
QUICBindPort: 7002,
|
||||
SubDomainHost: "example.com",
|
||||
MaxPortsPerClient: 8,
|
||||
AllowPorts: []types.PortsRange{
|
||||
{Start: 1000, End: 1002},
|
||||
{Single: 2000},
|
||||
},
|
||||
Transport: v1.ServerTransportConfig{
|
||||
MaxPoolCount: 5,
|
||||
HeartbeatTimeout: 90,
|
||||
TLS: v1.TLSServerConfig{
|
||||
Force: true,
|
||||
},
|
||||
},
|
||||
}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/system/info")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
|
||||
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
|
||||
"allowPortsStr",
|
||||
"bindPort",
|
||||
"heartbeatTimeout",
|
||||
"kcpBindPort",
|
||||
"maxPoolCount",
|
||||
"maxPortsPerClient",
|
||||
"quicBindPort",
|
||||
"subdomainHost",
|
||||
"tcpmuxHTTPConnectPort",
|
||||
"tlsForce",
|
||||
"vhostHTTPPort",
|
||||
"vhostHTTPSPort",
|
||||
)
|
||||
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
|
||||
"clientCounts",
|
||||
"curConns",
|
||||
"proxyTypeCount",
|
||||
"totalTrafficIn",
|
||||
"totalTrafficOut",
|
||||
)
|
||||
|
||||
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
|
||||
if systemResp.Data.Version == "" {
|
||||
t.Fatal("version should be set at top level")
|
||||
}
|
||||
if systemResp.Data.Config.BindPort != 7000 ||
|
||||
systemResp.Data.Config.VhostHTTPPort != 8080 ||
|
||||
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
|
||||
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
|
||||
systemResp.Data.Config.KCPBindPort != 7001 ||
|
||||
systemResp.Data.Config.QUICBindPort != 7002 ||
|
||||
systemResp.Data.Config.SubdomainHost != "example.com" ||
|
||||
systemResp.Data.Config.MaxPoolCount != 5 ||
|
||||
systemResp.Data.Config.MaxPortsPerClient != 8 ||
|
||||
systemResp.Data.Config.HeartbeatTimeout != 90 ||
|
||||
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
|
||||
!systemResp.Data.Config.TLSForce {
|
||||
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
|
||||
}
|
||||
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
|
||||
systemResp.Data.Status.TotalTrafficOut != 2048 ||
|
||||
systemResp.Data.Status.CurConns != 3 ||
|
||||
systemResp.Data.Status.ClientCounts != 4 ||
|
||||
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
|
||||
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
|
||||
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
collector := &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
|
||||
"http-offline": {Name: "http-offline", Type: "http"},
|
||||
"udp-offline": {Name: "udp-offline", Type: "udp"},
|
||||
"tcp-online": {Name: "tcp-online", Type: "tcp"},
|
||||
"http-online": {Name: "http-online", Type: "http"},
|
||||
"udp-online": {Name: "udp-online", Type: "udp"},
|
||||
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
|
||||
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
|
||||
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
|
||||
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
|
||||
},
|
||||
pruneable: map[string]bool{
|
||||
"tcp-offline": true,
|
||||
"http-offline": true,
|
||||
"udp-offline": true,
|
||||
},
|
||||
}
|
||||
mem.StatsCollector = collector
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
|
||||
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
|
||||
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
|
||||
}
|
||||
if _, ok := collector.proxies["tcp-offline"]; ok {
|
||||
t.Fatal("pruned proxy statistics should be removed")
|
||||
}
|
||||
if _, ok := collector.proxies["tcp-online"]; !ok {
|
||||
t.Fatal("online proxy statistics should remain")
|
||||
}
|
||||
|
||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
|
||||
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
|
||||
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
|
||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp = decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
|
||||
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||
if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", pageResp)
|
||||
}
|
||||
if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
|
||||
t.Fatalf("page data mismatch: %#v", pageResp.Data)
|
||||
}
|
||||
if got := pageResp.Data.Items[0].User; got != "" {
|
||||
t.Fatalf("first sorted user mismatch, want empty got %q", got)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
|
||||
emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||
if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
|
||||
t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
|
||||
aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||
if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
|
||||
t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/clients?status=offline")
|
||||
offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||
if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
|
||||
t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/clients?page=0")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
||||
t.Fatalf("error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/clients?pageSize=201")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
|
||||
resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/clients/alice.client-a")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
|
||||
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
|
||||
}
|
||||
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
|
||||
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"tcp-url": {
|
||||
Name: "tcp-url",
|
||||
Type: "tcp",
|
||||
User: "url",
|
||||
ClientID: "client/a?b#c",
|
||||
CurConns: 7,
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
|
||||
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
encodedKey := url.PathEscape("url.client/a?b#c")
|
||||
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
|
||||
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
|
||||
}
|
||||
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
|
||||
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies?type=invalid")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
||||
t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
|
||||
proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
|
||||
if proxyResp.Data.Total != 1 {
|
||||
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
||||
}
|
||||
proxyItem := proxyResp.Data.Items[0]
|
||||
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
||||
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
|
||||
"curConns",
|
||||
"lastCloseAt",
|
||||
"lastStartAt",
|
||||
"phase",
|
||||
"todayTrafficIn",
|
||||
"todayTrafficOut",
|
||||
)
|
||||
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)
|
||||
}
|
||||
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
|
||||
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
||||
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
||||
if userResp.Data.Total != 3 {
|
||||
t.Fatalf("user total mismatch: %#v", userResp.Data)
|
||||
}
|
||||
expectedProxyCounts := map[string]int{
|
||||
"": 1,
|
||||
"alice": 2,
|
||||
"bob": 1,
|
||||
}
|
||||
for _, item := range userResp.Data.Items {
|
||||
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
|
||||
t.Fatalf("user counts mismatch: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"ssh": {Name: "ssh", Type: "tcp"},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
"ssh": {
|
||||
Name: "ssh",
|
||||
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
|
||||
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
|
||||
|
||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
|
||||
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
|
||||
}
|
||||
if len(trafficResp.Data.History) != 7 {
|
||||
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
|
||||
}
|
||||
|
||||
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
|
||||
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
|
||||
var prevDate time.Time
|
||||
for i, point := range trafficResp.Data.History {
|
||||
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
|
||||
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
|
||||
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
|
||||
}
|
||||
parsedDate, err := time.Parse(time.DateOnly, point.Date)
|
||||
if err != nil {
|
||||
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
|
||||
}
|
||||
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
|
||||
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
|
||||
}
|
||||
prevDate = parsedDate
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
|
||||
t.Fatalf("not found envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
|
||||
name := "folder/ssh?x#y"
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
name: {Name: name, Type: "tcp", User: "encoded"},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
name: {
|
||||
Name: name,
|
||||
TrafficIn: []int64{1},
|
||||
TrafficOut: []int64{2},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||
router := newV2TestRouter(controller)
|
||||
encodedName := url.PathEscape(name)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
|
||||
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||
if trafficResp.Data.Name != name {
|
||||
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
|
||||
}
|
||||
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
|
||||
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
|
||||
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item model.V2ProxyResp
|
||||
q string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "tcp remote port",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||
RemotePort: 6000,
|
||||
}},
|
||||
q: "6000",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "udp remote port",
|
||||
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
|
||||
RemotePort: 7000,
|
||||
}},
|
||||
q: "7000",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "remote port does not match colon form",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||
RemotePort: 6000,
|
||||
}},
|
||||
q: ":6000",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "http custom domain",
|
||||
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
|
||||
}},
|
||||
q: "app.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "https subdomain",
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||
}},
|
||||
q: "portal",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subdomain does not match expanded host",
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||
}},
|
||||
q: "portal.example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "tcpmux custom domain",
|
||||
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
|
||||
}},
|
||||
q: "mux.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "nil spec does not match spec fields",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
||||
q: "6000",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
|
||||
t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/serverinfo")
|
||||
var serverInfo model.ServerInfoResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
|
||||
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if serverInfo.Version == "" {
|
||||
t.Fatal("legacy serverinfo version should be set")
|
||||
}
|
||||
var serverInfoRaw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
|
||||
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
|
||||
}
|
||||
if _, ok := serverInfoRaw["data"]; ok {
|
||||
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
|
||||
}
|
||||
if _, ok := serverInfoRaw["config"]; ok {
|
||||
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/clients")
|
||||
var clients []model.ClientInfoResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
|
||||
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if len(clients) != 3 {
|
||||
t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/proxy/tcp")
|
||||
var proxies model.GetProxyInfoResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
|
||||
t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if len(proxies.Proxies) != 2 {
|
||||
t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
|
||||
}
|
||||
var envelope httppkg.V2Response
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
||||
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/traffic/tcp-alice")
|
||||
var traffic model.GetProxyTrafficResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
|
||||
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if traffic.Name != "tcp-alice" ||
|
||||
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
|
||||
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
|
||||
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
|
||||
}
|
||||
var trafficRaw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
|
||||
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
|
||||
}
|
||||
if _, ok := trafficRaw["data"]; ok {
|
||||
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newV2TestController(t *testing.T) *Controller {
|
||||
t.Helper()
|
||||
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"tcp-empty": {
|
||||
Name: "tcp-empty",
|
||||
Type: "tcp",
|
||||
User: "",
|
||||
ClientID: "legacy-client",
|
||||
TodayTrafficIn: 10,
|
||||
TodayTrafficOut: 20,
|
||||
CurConns: 1,
|
||||
},
|
||||
"tcp-alice": {
|
||||
Name: "tcp-alice",
|
||||
Type: "tcp",
|
||||
User: "alice",
|
||||
ClientID: "client-a",
|
||||
TodayTrafficIn: 30,
|
||||
TodayTrafficOut: 40,
|
||||
CurConns: 2,
|
||||
LastStartTime: "07-08 12:30:00",
|
||||
LastCloseTime: "07-08 12:31:40",
|
||||
LastStartAt: 1783504200,
|
||||
LastCloseAt: 1783504300,
|
||||
},
|
||||
"http-alice": {
|
||||
Name: "http-alice",
|
||||
Type: "http",
|
||||
User: "alice",
|
||||
ClientID: "client-a",
|
||||
CurConns: 3,
|
||||
},
|
||||
"udp-bob": {
|
||||
Name: "udp-bob",
|
||||
Type: "udp",
|
||||
User: "bob",
|
||||
ClientID: "client-b",
|
||||
},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
"tcp-alice": {
|
||||
Name: "tcp-alice",
|
||||
TrafficIn: []int64{7, 6},
|
||||
TrafficOut: []int64{70, 60},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
|
||||
clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
|
||||
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
|
||||
clientRegistry.MarkOfflineByRunID("run-b")
|
||||
|
||||
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
||||
}
|
||||
|
||||
func newV2TestRouter(controller *Controller) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
|
||||
encodedPathRouter := router.NewRoute().Subrouter()
|
||||
encodedPathRouter.UseEncodedPath()
|
||||
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
|
||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
|
||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
|
||||
return router
|
||||
}
|
||||
|
||||
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||
return performRequestWithMethod(handler, http.MethodGet, target)
|
||||
}
|
||||
|
||||
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
return resp
|
||||
}
|
||||
|
||||
func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
||||
t.Helper()
|
||||
|
||||
var out T
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
if len(raw) != len(want) {
|
||||
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
|
||||
}
|
||||
for _, key := range want {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Fatalf("json key %q missing from %v", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
var out map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
|
||||
}
|
||||
assertRawJSONKeys(t, out, want...)
|
||||
}
|
||||
|
||||
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
|
||||
t.Helper()
|
||||
|
||||
out, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json failed: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package model
|
||||
|
||||
type V2PageResp[T any] struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Items []T `json:"items"`
|
||||
}
|
||||
|
||||
type V2SystemInfoResp struct {
|
||||
Version string `json:"version"`
|
||||
Config V2SystemInfoConfigResp `json:"config"`
|
||||
Status V2SystemInfoStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2SystemInfoConfigResp struct {
|
||||
BindPort int `json:"bindPort"`
|
||||
VhostHTTPPort int `json:"vhostHTTPPort"`
|
||||
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
||||
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
||||
KCPBindPort int `json:"kcpBindPort"`
|
||||
QUICBindPort int `json:"quicBindPort"`
|
||||
SubdomainHost string `json:"subdomainHost"`
|
||||
MaxPoolCount int64 `json:"maxPoolCount"`
|
||||
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
||||
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
|
||||
AllowPortsStr string `json:"allowPortsStr"`
|
||||
TLSForce bool `json:"tlsForce"`
|
||||
}
|
||||
|
||||
type V2SystemInfoStatusResp struct {
|
||||
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
||||
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ClientCounts int64 `json:"clientCounts"`
|
||||
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
||||
}
|
||||
|
||||
type V2SystemPruneResp struct {
|
||||
Type string `json:"type"`
|
||||
Cleared int `json:"cleared"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type V2UserResp struct {
|
||||
User string `json:"user"`
|
||||
ClientCount int `json:"clientCount"`
|
||||
ProxyCount int `json:"proxyCount"`
|
||||
}
|
||||
|
||||
type V2ClientDetailResp struct {
|
||||
ClientInfoResp
|
||||
Status V2ClientStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2ClientStatusResp struct {
|
||||
State string `json:"phase"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ProxyCount int64 `json:"proxyCount"`
|
||||
}
|
||||
|
||||
type V2ProxyResp struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
User string `json:"user"`
|
||||
ClientID string `json:"clientID"`
|
||||
Spec any `json:"spec"`
|
||||
Status V2ProxyStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2ProxyStatusResp struct {
|
||||
State string `json:"phase"`
|
||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
LastStartAt int64 `json:"lastStartAt,omitempty"`
|
||||
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficResp struct {
|
||||
Name string `json:"name"`
|
||||
Unit string `json:"unit"`
|
||||
Granularity string `json:"granularity"`
|
||||
History []V2ProxyTrafficPointResp `json:"history"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficPointResp struct {
|
||||
Date string `json:"date"`
|
||||
TrafficIn int64 `json:"trafficIn"`
|
||||
TrafficOut int64 `json:"trafficOut"`
|
||||
}
|
||||
@@ -16,7 +16,6 @@ package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -32,7 +31,6 @@ 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"
|
||||
@@ -94,7 +92,6 @@ type BaseProxy struct {
|
||||
userInfo plugin.UserInfo
|
||||
loginMsg *msg.Login
|
||||
configurer v1.ProxyConfigurer
|
||||
wireProtocol string
|
||||
|
||||
mu sync.RWMutex
|
||||
xl *xlog.Logger
|
||||
@@ -318,147 +315,13 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
|
||||
name := pxy.GetName()
|
||||
proxyType := cfg.Type
|
||||
metrics.Server.OpenConnection(name, proxyType)
|
||||
inCount, outCount, _ := pxy.joinUserConnection(local, userConn, proxyType, xl)
|
||||
inCount, outCount, _ := libio.Join(local, userConn)
|
||||
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
|
||||
@@ -468,7 +331,6 @@ type Options struct {
|
||||
Configurer v1.ProxyConfigurer
|
||||
ServerCfg *v1.ServerConfig
|
||||
EncryptionKey []byte
|
||||
WireProtocol string
|
||||
}
|
||||
|
||||
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
||||
@@ -495,7 +357,6 @@ 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,15 +15,12 @@
|
||||
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) {
|
||||
@@ -54,56 +51,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
// 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(payloadConn *msg.Conn) {
|
||||
workConnReaderFn := func(conn net.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
|
||||
_ = payloadConn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
|
||||
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
|
||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||
_ = payloadConn.Close()
|
||||
_ = conn.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 := payloadConn.SetReadDeadline(time.Time{}); err != nil {
|
||||
if err := conn.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 {
|
||||
_ = payloadConn.Close()
|
||||
conn.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(payloadConn *msg.Conn, ctx context.Context) {
|
||||
workConnSenderFn := func(conn net.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 = payloadConn.WriteMsg(udpMsg); errRet != nil {
|
||||
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
|
||||
xl.Infof("sender goroutine for udp work connection closed: %v", errRet)
|
||||
_ = payloadConn.Close()
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
|
||||
@@ -223,11 +223,9 @@ 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(payloadConn)
|
||||
go workConnSenderFn(payloadConn, ctx)
|
||||
go workConnReaderFn(pxy.workConn)
|
||||
go workConnSenderFn(pxy.workConn, ctx)
|
||||
_, ok := <-pxy.checkCloseCh
|
||||
cancel()
|
||||
if !ok {
|
||||
|
||||
@@ -16,7 +16,6 @@ package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
@@ -74,7 +73,10 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
|
||||
if errRet != nil {
|
||||
continue
|
||||
}
|
||||
errRet = writeNatHoleSid(workConn, pxy.wireProtocol, sid)
|
||||
m := &msg.NatHoleSid{
|
||||
Sid: sid,
|
||||
}
|
||||
errRet = msg.WriteMsg(workConn, m)
|
||||
if errRet != nil {
|
||||
xl.Warnf("write nat hole sid package error, %v", errRet)
|
||||
}
|
||||
@@ -85,13 +87,6 @@ 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()
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// 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, acceptedConn.wireProtocol); err != nil {
|
||||
if err = svr.RegisterVisitorConn(conn, m); err != nil {
|
||||
xl.Warnf("register visitor conn error: %v", err)
|
||||
_ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
|
||||
ProxyName: m.ProxyName,
|
||||
@@ -777,7 +777,6 @@ 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)
|
||||
@@ -833,7 +832,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, wireProtocol string) error {
|
||||
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn) 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.
|
||||
@@ -845,5 +844,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, wireProtocol)
|
||||
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ 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()
|
||||
@@ -91,11 +90,7 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
|
||||
if useCompression {
|
||||
rwc = libio.WithCompression(rwc)
|
||||
}
|
||||
visitorConn := netpkg.WrapReadWriteCloserToConn(rwc, conn)
|
||||
err = l.l.PutConn(&wireProtocolConn{
|
||||
Conn: visitorConn,
|
||||
wireProtocol: wireProtocol,
|
||||
})
|
||||
err = l.l.PutConn(netpkg.WrapReadWriteCloserToConn(rwc, conn))
|
||||
} else {
|
||||
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
|
||||
return
|
||||
@@ -103,15 +98,6 @@ 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()
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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,8 +7,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -160,7 +158,7 @@ webServer.port = %d
|
||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||
})
|
||||
|
||||
ginkgo.It("baseline frps handles current frpc forced to v2 according to baseline support", func() {
|
||||
ginkgo.It("baseline frps rejects current frpc forced to v2", func() {
|
||||
portName := port.GenName("CompatBaselineFRPSForcedV2")
|
||||
clientConf := tcpClientConfig("tcp", portName, `
|
||||
transport.wireProtocol = "v2"
|
||||
@@ -172,21 +170,6 @@ 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()
|
||||
})
|
||||
@@ -216,39 +199,6 @@ 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,73 +98,6 @@ 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,24 +1,18 @@
|
||||
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"
|
||||
@@ -456,85 +450,4 @@ 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,6 +15,7 @@ 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,30 +1,10 @@
|
||||
import { buildQueryString, http } from './http'
|
||||
import type { V2Page } from './http'
|
||||
import type { ClientInfoData, ClientListV2Params } from '../types/client'
|
||||
import { http } from './http'
|
||||
import type { ClientInfoData } 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,21 +11,6 @@ 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',
|
||||
@@ -49,62 +34,9 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function requestV2<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const defaultOptions: RequestInit = {
|
||||
credentials: 'include',
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...defaultOptions, ...options })
|
||||
const envelope = (await response.json().catch(() => null)) as
|
||||
| V2Envelope<T>
|
||||
| null
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HTTPError(
|
||||
response.status,
|
||||
response.statusText,
|
||||
envelope?.msg || `HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!envelope || typeof envelope.code !== 'number') {
|
||||
throw new Error('Invalid API v2 response')
|
||||
}
|
||||
|
||||
if (envelope.code >= 400) {
|
||||
throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
|
||||
}
|
||||
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
export const buildQueryString = (
|
||||
params: Record<string, QueryParamValue>,
|
||||
): string => {
|
||||
const query = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === null || value === undefined) continue
|
||||
query.append(key, String(value))
|
||||
}
|
||||
const text = query.toString()
|
||||
return text ? `?${text}` : ''
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(url: string, options?: RequestInit) =>
|
||||
request<T>(url, { ...options, method: 'GET' }),
|
||||
getV2: <T>(url: string, options?: RequestInit) =>
|
||||
requestV2<T>(url, { ...options, method: 'GET' }),
|
||||
postV2: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
requestV2<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
|
||||
@@ -1,81 +1,26 @@
|
||||
import { buildQueryString, http } from './http'
|
||||
import { formatUnixSeconds } from '../utils/format'
|
||||
import type { V2Page } from './http'
|
||||
import { http } from './http'
|
||||
import type {
|
||||
GetProxyResponse,
|
||||
ProxyListV2Params,
|
||||
ProxyStatsInfo,
|
||||
ProxyV2Info,
|
||||
TrafficResponse,
|
||||
} from '../types/proxy'
|
||||
|
||||
export interface SystemPruneResponse {
|
||||
type: 'offline_proxies'
|
||||
cleared: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export const getProxiesByType = (type: string) => {
|
||||
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||
}
|
||||
|
||||
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
||||
const page = await http.getV2<V2Page<ProxyV2Info>>(
|
||||
`../api/v2/proxies${buildQueryString({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status:
|
||||
params.status && params.status !== 'all' ? params.status : undefined,
|
||||
q: params.q || undefined,
|
||||
type: params.type || undefined,
|
||||
user: params.user,
|
||||
clientID: params.clientID || undefined,
|
||||
})}`,
|
||||
)
|
||||
|
||||
return {
|
||||
...page,
|
||||
items: page.items.map(toLegacyProxyStats),
|
||||
}
|
||||
}
|
||||
|
||||
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
|
||||
name: proxy.name,
|
||||
type: proxy.type,
|
||||
conf: proxy.spec,
|
||||
user: proxy.user,
|
||||
clientID: proxy.clientID,
|
||||
todayTrafficIn: proxy.status.todayTrafficIn,
|
||||
todayTrafficOut: proxy.status.todayTrafficOut,
|
||||
curConns: proxy.status.curConns,
|
||||
lastStartTime: formatUnixSeconds(proxy.status.lastStartAt),
|
||||
lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt),
|
||||
status: proxy.status.phase,
|
||||
})
|
||||
|
||||
export const getProxy = (type: string, name: string) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||
}
|
||||
|
||||
export const getProxyByNameV2 = async (name: string) => {
|
||||
const proxy = await http.getV2<ProxyV2Info>(
|
||||
`../api/v2/proxies/${encodeURIComponent(name)}`,
|
||||
)
|
||||
return toLegacyProxyStats(proxy)
|
||||
}
|
||||
|
||||
export const getProxyByName = (name: string) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
|
||||
}
|
||||
|
||||
export const getProxyTraffic = (name: string) => {
|
||||
return http.getV2<TrafficResponse>(
|
||||
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
|
||||
)
|
||||
return http.get<TrafficResponse>(`../api/traffic/${name}`)
|
||||
}
|
||||
|
||||
export const clearOfflineProxies = () => {
|
||||
return http.postV2<SystemPruneResponse>(
|
||||
'../api/v2/system/prune?type=offline_proxies',
|
||||
)
|
||||
return http.delete('../api/proxies?status=offline')
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ import { http } from './http'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
export const getServerInfo = () => {
|
||||
return http.getV2<ServerInfo>('../api/v2/system/info')
|
||||
return http.get<ServerInfo>('../api/serverinfo')
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatFileSize } from '../utils/format'
|
||||
import { getProxyTraffic } from '../api/proxy'
|
||||
import type { TrafficResponse } from '../types/proxy'
|
||||
|
||||
const props = defineProps<{
|
||||
proxyName: string
|
||||
@@ -72,24 +71,41 @@ const chartData = ref<
|
||||
>([])
|
||||
const maxVal = ref(0)
|
||||
|
||||
const formatDateLabel = (date: string) => {
|
||||
const parts = date.split('-')
|
||||
if (parts.length !== 3) return date
|
||||
return `${Number(parts[1])}-${Number(parts[2])}`
|
||||
}
|
||||
const processData = (trafficIn: number[], trafficOut: number[]) => {
|
||||
// Ensure we have arrays and reverse them (server returns newest first)
|
||||
const inArr = [...(trafficIn || [])].reverse()
|
||||
const outArr = [...(trafficOut || [])].reverse()
|
||||
|
||||
const processData = (history: TrafficResponse['history'] = []) => {
|
||||
const points = history || []
|
||||
const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
|
||||
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
|
||||
// Pad with zeros if less than 7 days
|
||||
while (inArr.length < 7) inArr.unshift(0)
|
||||
while (outArr.length < 7) outArr.unshift(0)
|
||||
|
||||
// Slice to last 7 entries just in case
|
||||
const finalIn = inArr.slice(-7)
|
||||
const finalOut = outArr.slice(-7)
|
||||
|
||||
// Calculate dates (last 7 days ending today)
|
||||
const dates: string[] = []
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 6)
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
|
||||
d.setDate(d.getDate() + 1)
|
||||
}
|
||||
|
||||
// Find max value for scaling
|
||||
const maxIn = Math.max(...finalIn)
|
||||
const maxOut = Math.max(...finalOut)
|
||||
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
|
||||
|
||||
chartData.value = points.map((item) => ({
|
||||
date: formatDateLabel(item.date),
|
||||
in: item.trafficIn,
|
||||
out: item.trafficOut,
|
||||
inPercent: (item.trafficIn / maxVal.value) * 100,
|
||||
outPercent: (item.trafficOut / maxVal.value) * 100,
|
||||
// Build chart data
|
||||
chartData.value = dates.map((date, i) => ({
|
||||
date,
|
||||
in: finalIn[i],
|
||||
out: finalOut[i],
|
||||
inPercent: (finalIn[i] / maxVal.value) * 100,
|
||||
outPercent: (finalOut[i] / maxVal.value) * 100,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -97,7 +113,7 @@ const fetchData = () => {
|
||||
loading.value = true
|
||||
getProxyTraffic(props.proxyName)
|
||||
.then((json) => {
|
||||
processData(json.history)
|
||||
processData(json.trafficIn, json.trafficOut)
|
||||
})
|
||||
.catch((err) => {
|
||||
ElMessage({
|
||||
|
||||
@@ -7,25 +7,9 @@ export interface ClientInfoData {
|
||||
wireProtocol?: string
|
||||
hostname: string
|
||||
clientIP?: string
|
||||
metas?: Record<string, string>
|
||||
firstConnectedAt: number
|
||||
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,6 +1,5 @@
|
||||
export interface ProxyStatsInfo {
|
||||
name: string
|
||||
type?: string
|
||||
conf: any
|
||||
user: string
|
||||
clientID: string
|
||||
@@ -16,43 +15,8 @@ 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: 'online' | 'offline'
|
||||
todayTrafficIn: number
|
||||
todayTrafficOut: number
|
||||
curConns: number
|
||||
lastStartAt?: number
|
||||
lastCloseAt?: number
|
||||
}
|
||||
|
||||
export interface TrafficResponse {
|
||||
name: string
|
||||
unit: 'bytes'
|
||||
granularity: 'day'
|
||||
history: TrafficPoint[]
|
||||
}
|
||||
|
||||
export interface TrafficPoint {
|
||||
date: string
|
||||
trafficIn: number
|
||||
trafficOut: number
|
||||
trafficIn: number[]
|
||||
trafficOut: number[]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
export interface ServerInfo {
|
||||
version: string
|
||||
config: ServerInfoConfig
|
||||
status: ServerInfoStatus
|
||||
}
|
||||
|
||||
export interface ServerInfoConfig {
|
||||
bindPort: number
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
@@ -17,9 +12,8 @@ export interface ServerInfoConfig {
|
||||
heartbeatTimeout: number
|
||||
allowPortsStr: string
|
||||
tlsForce: boolean
|
||||
}
|
||||
|
||||
export interface ServerInfoStatus {
|
||||
// Stats
|
||||
totalTrafficIn: number
|
||||
totalTrafficOut: number
|
||||
curConns: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistanceToNow } from './format'
|
||||
import type { ClientInfoData, ClientStatus } from '../types/client'
|
||||
import type { ClientInfoData } from '../types/client'
|
||||
|
||||
export class Client {
|
||||
key: string
|
||||
@@ -10,11 +10,11 @@ export class Client {
|
||||
wireProtocol: string
|
||||
hostname: string
|
||||
ip: string
|
||||
metas: Map<string, string>
|
||||
firstConnectedAt: Date
|
||||
lastConnectedAt: Date
|
||||
disconnectedAt?: Date
|
||||
online: boolean
|
||||
status: ClientStatus
|
||||
|
||||
constructor(data: ClientInfoData) {
|
||||
this.key = data.key
|
||||
@@ -25,17 +25,18 @@ export class Client {
|
||||
this.wireProtocol = data.wireProtocol || ''
|
||||
this.hostname = data.hostname
|
||||
this.ip = data.clientIP || ''
|
||||
this.metas = new Map<string, string>()
|
||||
if (data.metas) {
|
||||
for (const [key, value] of Object.entries(data.metas)) {
|
||||
this.metas.set(key, value)
|
||||
}
|
||||
}
|
||||
this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
|
||||
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
|
||||
if (data.disconnectedAt && data.disconnectedAt > 0) {
|
||||
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 {
|
||||
@@ -45,6 +46,10 @@ export class Client {
|
||||
return this.runID
|
||||
}
|
||||
|
||||
get shortRunId(): string {
|
||||
return this.runID.substring(0, 8)
|
||||
}
|
||||
|
||||
get wireProtocolLabel(): string {
|
||||
if (!this.wireProtocol) return ''
|
||||
return `Protocol ${this.wireProtocol}`
|
||||
@@ -62,4 +67,28 @@ export class Client {
|
||||
if (!this.disconnectedAt) return ''
|
||||
return formatDistanceToNow(this.disconnectedAt)
|
||||
}
|
||||
|
||||
get statusColor(): string {
|
||||
return this.online ? 'success' : 'danger'
|
||||
}
|
||||
|
||||
get metasArray(): Array<{ key: string; value: string }> {
|
||||
const arr: Array<{ key: string; value: string }> = []
|
||||
this.metas.forEach((value, key) => {
|
||||
arr.push({ key, value })
|
||||
})
|
||||
return arr
|
||||
}
|
||||
|
||||
matchesFilter(searchText: string): boolean {
|
||||
const search = searchText.toLowerCase()
|
||||
return (
|
||||
this.key.toLowerCase().includes(search) ||
|
||||
this.user.toLowerCase().includes(search) ||
|
||||
this.clientID.toLowerCase().includes(search) ||
|
||||
this.runID.toLowerCase().includes(search) ||
|
||||
this.wireProtocol.toLowerCase().includes(search) ||
|
||||
this.hostname.toLowerCase().includes(search)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,6 @@ export function formatDistanceToNow(date: Date): string {
|
||||
return Math.floor(seconds) + ' seconds ago'
|
||||
}
|
||||
|
||||
export function formatUnixSeconds(seconds?: number): string {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return ''
|
||||
|
||||
const date = new Date(seconds * 1000)
|
||||
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="info-section">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Connections</span>
|
||||
<span class="info-value">{{ client.status.curConns }}</span>
|
||||
<span class="info-value">{{ totalConnections }}</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">{{ total }}</span>
|
||||
<span class="proxies-count">{{ filteredProxies.length }}</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="proxySearch"
|
||||
@@ -100,32 +100,21 @@
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
<div v-else-if="proxies.length > 0" class="proxies-list">
|
||||
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
|
||||
<ProxyCard
|
||||
v-for="proxy in proxies"
|
||||
:key="`${proxy.type}:${proxy.name}`"
|
||||
v-for="proxy in filteredProxies"
|
||||
:key="proxy.name"
|
||||
:proxy="proxy"
|
||||
show-type
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="proxySearch.trim()" class="empty-state">
|
||||
<div v-else-if="clientProxies.length > 0" 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>
|
||||
|
||||
@@ -141,13 +130,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElPagination } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
||||
import { Client } from '../utils/client'
|
||||
import { getClientV2 } from '../api/client'
|
||||
import { getProxiesV2 } from '../api/proxy'
|
||||
import { getClient } from '../api/client'
|
||||
import { getProxiesByType } from '../api/proxy'
|
||||
import {
|
||||
BaseProxy,
|
||||
TCPProxy,
|
||||
@@ -160,8 +149,6 @@ import {
|
||||
} from '../utils/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
import type { ProxyStatsInfo } from '../types/proxy'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -176,191 +163,119 @@ const goBack = () => {
|
||||
}
|
||||
}
|
||||
const proxiesLoading = ref(false)
|
||||
const proxies = ref<BaseProxy[]>([])
|
||||
const allProxies = 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 serverInfoPromise: Promise<ServerInfo> | null = null
|
||||
let serverInfo: {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
|
||||
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||
if (!serverInfoPromise) {
|
||||
serverInfoPromise = getServerInfo().catch((err) => {
|
||||
serverInfoPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return serverInfoPromise
|
||||
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 (): Promise<boolean> => {
|
||||
const fetchClient = async () => {
|
||||
const key = route.params.key as string
|
||||
if (!key) {
|
||||
loading.value = false
|
||||
return false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await getClientV2(key)
|
||||
const data = await getClient(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 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()
|
||||
if (info && info.config.vhostHTTPPort) {
|
||||
return new HTTPProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'https') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.vhostHTTPSPort) {
|
||||
return new HTTPSProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPSPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'tcpmux') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||
return new TCPMuxProxy(
|
||||
proxy,
|
||||
info.config.tcpmuxHTTPConnectPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'stcp') {
|
||||
return new STCPProxy(proxy)
|
||||
}
|
||||
if (type === 'sudp') {
|
||||
return new SUDPProxy(proxy)
|
||||
}
|
||||
|
||||
const bp = new BaseProxy(proxy)
|
||||
bp.type = type
|
||||
return bp
|
||||
}
|
||||
|
||||
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
||||
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
||||
return converted.filter((item): item is BaseProxy => item !== null)
|
||||
}
|
||||
|
||||
const fetchProxies = async () => {
|
||||
if (!client.value) return
|
||||
const seq = ++requestSeq
|
||||
proxiesLoading.value = true
|
||||
|
||||
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
|
||||
const proxies: BaseProxy[] = []
|
||||
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 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)))
|
||||
}
|
||||
|
||||
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)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
allProxies.value = proxies
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
proxiesLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
onMounted(() => {
|
||||
fetchClient()
|
||||
fetchProxies()
|
||||
})
|
||||
</script>
|
||||
@@ -568,12 +483,6 @@ 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,9 +16,7 @@
|
||||
>
|
||||
<span class="status-dot" :class="tab.value"></span>
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
<span v-if="tab.count !== null" class="tab-count">{{
|
||||
tab.count
|
||||
}}</span>
|
||||
<span class="tab-count">{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,9 +33,9 @@
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="clients-content">
|
||||
<div v-if="clients.length > 0" class="clients-list">
|
||||
<div v-if="filteredClients.length > 0" class="clients-list">
|
||||
<ClientCard
|
||||
v-for="client in clients"
|
||||
v-for="client in filteredClients"
|
||||
:key="client.key"
|
||||
:client="client"
|
||||
/>
|
||||
@@ -46,123 +44,82 @@
|
||||
<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, watch } from 'vue'
|
||||
import { ElMessage, ElPagination } from 'element-plus'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { Client } from '../utils/client'
|
||||
import ClientCard from '../components/ClientCard.vue'
|
||||
import { getClientsV2 } from '../api/client'
|
||||
import { getClients } 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
|
||||
let searchDebounceTimer: number | null = null
|
||||
let requestSeq = 0
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{ 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 },
|
||||
])
|
||||
|
||||
const fetchData = async (silent = false) => {
|
||||
const seq = ++requestSeq
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const data = await getClientsV2({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
status: statusFilter.value,
|
||||
q: searchText.value.trim(),
|
||||
})
|
||||
if (seq !== requestSeq) return
|
||||
const filteredClients = computed(() => {
|
||||
let result = clients.value
|
||||
|
||||
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
|
||||
// Filter by status
|
||||
if (statusFilter.value === 'online') {
|
||||
result = result.filter((c) => c.online)
|
||||
} else if (statusFilter.value === 'offline') {
|
||||
result = result.filter((c) => !c.online)
|
||||
}
|
||||
|
||||
clients.value = data.items.map((item) => new Client(item))
|
||||
total.value = data.total
|
||||
page.value = data.page
|
||||
pageSize.value = data.pageSize
|
||||
// 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
|
||||
try {
|
||||
const json = await getClients()
|
||||
clients.value = json.map((data) => new Client(data))
|
||||
} catch (error: any) {
|
||||
if (seq !== requestSeq) return
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
message: 'Failed to fetch clients: ' + error.message,
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
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(true)
|
||||
fetchData()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
@@ -173,19 +130,6 @@ const stopAutoRefresh = () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(statusFilter, () => {
|
||||
resetPageAndFetch()
|
||||
})
|
||||
|
||||
watch(searchText, () => {
|
||||
clearSearchDebounce()
|
||||
page.value = 1
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
searchDebounceTimer = null
|
||||
fetchData()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
startAutoRefresh()
|
||||
@@ -193,7 +137,6 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
clearSearchDebounce()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -331,11 +274,6 @@ 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);
|
||||
@@ -360,9 +298,5 @@ 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="refreshData">
|
||||
<ActionButton variant="outline" size="small" @click="fetchData">
|
||||
Refresh
|
||||
</ActionButton>
|
||||
|
||||
@@ -27,6 +27,36 @@
|
||||
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">
|
||||
@@ -44,9 +74,9 @@
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="proxies-content">
|
||||
<div v-if="proxies.length > 0" class="proxies-list">
|
||||
<div v-if="filteredProxies.length > 0" class="proxies-list">
|
||||
<ProxyCard
|
||||
v-for="proxy in proxies"
|
||||
v-for="proxy in filteredProxies"
|
||||
:key="`${proxy.type}:${proxy.name}`"
|
||||
:proxy="proxy"
|
||||
:show-type="activeType === 'all'"
|
||||
@@ -57,18 +87,6 @@
|
||||
</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"
|
||||
@@ -81,9 +99,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElPagination } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import ActionButton from '@shared/components/ActionButton.vue'
|
||||
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
||||
@@ -98,13 +116,15 @@ 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 {
|
||||
getProxiesV2,
|
||||
getProxiesByType,
|
||||
clearOfflineProxies as apiClearOfflineProxies,
|
||||
} from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import type { ProxyStatsInfo } from '../types/proxy'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
import { getClients } from '../api/client'
|
||||
import { Client } from '../utils/client'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -121,161 +141,242 @@ const proxyTypes = [
|
||||
{ label: 'SUDP', value: 'sudp' },
|
||||
]
|
||||
|
||||
const activeType = ref((route.params.type as string) || 'all')
|
||||
const activeType = ref((route.params.type as string) || 'tcp')
|
||||
const proxies = ref<BaseProxy[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const showClearDialog = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
let requestSeq = 0
|
||||
let searchDebounceTimer: number | null = null
|
||||
const clientIDFilter = ref((route.query.clientID as string) || '')
|
||||
const userFilter = ref((route.query.user as string) || '')
|
||||
|
||||
// Server info cache - cache the Promise itself so concurrent first calls
|
||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||
let serverInfoPromise: Promise<ServerInfo> | null = null
|
||||
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))
|
||||
})
|
||||
|
||||
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||
if (!serverInfoPromise) {
|
||||
serverInfoPromise = getServerInfo().catch((err) => {
|
||||
// Allow retry after failure
|
||||
serverInfoPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return serverInfoPromise
|
||||
// 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))
|
||||
}
|
||||
|
||||
const convertProxy = async (
|
||||
proxy: ProxyStatsInfo,
|
||||
): Promise<BaseProxy | null> => {
|
||||
const type = proxy.type || activeType.value
|
||||
// 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: {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
|
||||
const fetchServerInfo = async () => {
|
||||
if (serverInfo) return serverInfo
|
||||
const res = await getServerInfo()
|
||||
serverInfo = res
|
||||
return serverInfo
|
||||
}
|
||||
|
||||
const convertProxies = async (
|
||||
type: string,
|
||||
json: any,
|
||||
): Promise<BaseProxy[]> => {
|
||||
if (type === 'tcp') {
|
||||
return new TCPProxy(proxy)
|
||||
return json.proxies.map((p: any) => new TCPProxy(p))
|
||||
}
|
||||
if (type === 'udp') {
|
||||
return new UDPProxy(proxy)
|
||||
return json.proxies.map((p: any) => new UDPProxy(p))
|
||||
}
|
||||
if (type === 'http') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.vhostHTTPPort) {
|
||||
return new HTTPProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPPort,
|
||||
info.config.subdomainHost,
|
||||
if (info && info.vhostHTTPPort) {
|
||||
return json.proxies.map(
|
||||
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
||||
)
|
||||
}
|
||||
return null
|
||||
return []
|
||||
}
|
||||
if (type === 'https') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.vhostHTTPSPort) {
|
||||
return new HTTPSProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPSPort,
|
||||
info.config.subdomainHost,
|
||||
if (info && info.vhostHTTPSPort) {
|
||||
return json.proxies.map(
|
||||
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
||||
)
|
||||
}
|
||||
return null
|
||||
return []
|
||||
}
|
||||
if (type === 'tcpmux') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||
return new TCPMuxProxy(
|
||||
proxy,
|
||||
info.config.tcpmuxHTTPConnectPort,
|
||||
info.config.subdomainHost,
|
||||
if (info && info.tcpmuxHTTPConnectPort) {
|
||||
return json.proxies.map(
|
||||
(p: any) =>
|
||||
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
|
||||
)
|
||||
}
|
||||
return null
|
||||
return []
|
||||
}
|
||||
if (type === 'stcp') {
|
||||
return new STCPProxy(proxy)
|
||||
return json.proxies.map((p: any) => new STCPProxy(p))
|
||||
}
|
||||
if (type === 'sudp') {
|
||||
return new SUDPProxy(proxy)
|
||||
return json.proxies.map((p: any) => new SUDPProxy(p))
|
||||
}
|
||||
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
||||
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
||||
const bp = new BaseProxy(proxy)
|
||||
return json.proxies.map((p: any) => {
|
||||
const bp = new BaseProxy(p)
|
||||
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 allProxyTypes = [
|
||||
'tcp',
|
||||
'udp',
|
||||
'http',
|
||||
'https',
|
||||
'tcpmux',
|
||||
'stcp',
|
||||
'xtcp',
|
||||
'sudp',
|
||||
]
|
||||
|
||||
const fetchData = async (silent = false) => {
|
||||
const seq = ++requestSeq
|
||||
if (!silent) loading.value = true
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
proxies.value = []
|
||||
|
||||
try {
|
||||
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
|
||||
const type = activeType.value
|
||||
|
||||
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
|
||||
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 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 {
|
||||
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 () => {
|
||||
@@ -299,45 +400,25 @@ 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(searchText, () => {
|
||||
clearSearchDebounce()
|
||||
page.value = 1
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
searchDebounceTimer = null
|
||||
fetchData()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
watch(() => route.query, sanitizeClientQuery)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearSearchDebounce()
|
||||
})
|
||||
// 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) || ''
|
||||
},
|
||||
)
|
||||
|
||||
// Initial fetch
|
||||
sanitizeClientQuery()
|
||||
fetchData()
|
||||
fetchClients()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -404,11 +485,16 @@ fetchData()
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.main-search :deep(.el-input__wrapper) {
|
||||
.main-search :deep(.el-input__wrapper),
|
||||
.client-filter :deep(.el-input__wrapper) {
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.client-filter {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -453,18 +539,13 @@ fetchData()
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
justify-content: center;
|
||||
.client-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -241,7 +241,7 @@ import {
|
||||
Tickets,
|
||||
Location,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getProxyByNameV2 } from '../api/proxy'
|
||||
import { getProxyByName } from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import {
|
||||
BaseProxy,
|
||||
@@ -254,7 +254,6 @@ import {
|
||||
SUDPProxy,
|
||||
} from '../utils/proxy'
|
||||
import Traffic from '../components/Traffic.vue'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -276,7 +275,12 @@ const goBack = () => {
|
||||
}
|
||||
}
|
||||
|
||||
let serverInfo: ServerInfo | null = null
|
||||
let serverInfo: {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
|
||||
const clientLink = computed(() => {
|
||||
if (!proxy.value) return ''
|
||||
@@ -361,32 +365,27 @@ const fetchProxy = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getProxyByNameV2(name)
|
||||
const data = await getProxyByName(name)
|
||||
const info = await fetchServerInfo()
|
||||
const config = info.config
|
||||
const type = data.type || data.conf?.type || ''
|
||||
const type = data.conf?.type || ''
|
||||
|
||||
if (type === 'tcp') {
|
||||
proxy.value = new TCPProxy(data)
|
||||
} else if (type === 'udp') {
|
||||
proxy.value = new UDPProxy(data)
|
||||
} else if (type === 'http' && config.vhostHTTPPort) {
|
||||
proxy.value = new HTTPProxy(
|
||||
data,
|
||||
config.vhostHTTPPort,
|
||||
config.subdomainHost,
|
||||
)
|
||||
} else if (type === 'https' && config.vhostHTTPSPort) {
|
||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
||||
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
|
||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
||||
proxy.value = new HTTPSProxy(
|
||||
data,
|
||||
config.vhostHTTPSPort,
|
||||
config.subdomainHost,
|
||||
info.vhostHTTPSPort,
|
||||
info.subdomainHost,
|
||||
)
|
||||
} else if (type === 'tcpmux' && config.tcpmuxHTTPConnectPort) {
|
||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
||||
proxy.value = new TCPMuxProxy(
|
||||
data,
|
||||
config.tcpmuxHTTPConnectPort,
|
||||
config.subdomainHost,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
)
|
||||
} else if (type === 'stcp') {
|
||||
proxy.value = new STCPProxy(data)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Clients"
|
||||
:value="data.status.clientCounts"
|
||||
:value="data.clientCounts"
|
||||
type="clients"
|
||||
subtitle="Connected clients"
|
||||
to="/clients"
|
||||
@@ -13,7 +13,7 @@
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Proxies"
|
||||
:value="proxyCounts"
|
||||
:value="data.proxyCounts"
|
||||
type="proxies"
|
||||
subtitle="Active proxies"
|
||||
to="/proxies/tcp"
|
||||
@@ -22,7 +22,7 @@
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Connections"
|
||||
:value="data.status.curConns"
|
||||
:value="data.curConns"
|
||||
type="connections"
|
||||
subtitle="Current connections"
|
||||
/>
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="traffic-info">
|
||||
<div class="label">Inbound</div>
|
||||
<div class="value">
|
||||
{{ formatFileSize(data.status.totalTrafficIn) }}
|
||||
{{ formatFileSize(data.totalTrafficIn) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="traffic-info">
|
||||
<div class="label">Outbound</div>
|
||||
<div class="value">
|
||||
{{ formatFileSize(data.status.totalTrafficOut) }}
|
||||
{{ formatFileSize(data.totalTrafficOut) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@
|
||||
</template>
|
||||
<div class="proxy-types-grid">
|
||||
<div
|
||||
v-for="(count, type) in data.status.proxyTypeCount"
|
||||
v-for="(count, type) in data.proxyTypeCounts"
|
||||
:key="type"
|
||||
class="proxy-type-item"
|
||||
v-show="count > 0"
|
||||
@@ -109,51 +109,51 @@
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<span class="config-label">Bind Port</span>
|
||||
<span class="config-value">{{ data.config.bindPort }}</span>
|
||||
<span class="config-value">{{ data.bindPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.kcpBindPort != 0">
|
||||
<div class="config-item" v-if="data.kcpBindPort != 0">
|
||||
<span class="config-label">KCP Port</span>
|
||||
<span class="config-value">{{ data.config.kcpBindPort }}</span>
|
||||
<span class="config-value">{{ data.kcpBindPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.quicBindPort != 0">
|
||||
<div class="config-item" v-if="data.quicBindPort != 0">
|
||||
<span class="config-label">QUIC Port</span>
|
||||
<span class="config-value">{{ data.config.quicBindPort }}</span>
|
||||
<span class="config-value">{{ data.quicBindPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.vhostHTTPPort != 0">
|
||||
<div class="config-item" v-if="data.vhostHTTPPort != 0">
|
||||
<span class="config-label">HTTP Port</span>
|
||||
<span class="config-value">{{ data.config.vhostHTTPPort }}</span>
|
||||
<span class="config-value">{{ data.vhostHTTPPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.vhostHTTPSPort != 0">
|
||||
<div class="config-item" v-if="data.vhostHTTPSPort != 0">
|
||||
<span class="config-label">HTTPS Port</span>
|
||||
<span class="config-value">{{ data.config.vhostHTTPSPort }}</span>
|
||||
<span class="config-value">{{ data.vhostHTTPSPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.tcpmuxHTTPConnectPort != 0">
|
||||
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0">
|
||||
<span class="config-label">TCPMux Port</span>
|
||||
<span class="config-value">{{ data.config.tcpmuxHTTPConnectPort }}</span>
|
||||
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.subdomainHost != ''">
|
||||
<div class="config-item" v-if="data.subdomainHost != ''">
|
||||
<span class="config-label">Subdomain Host</span>
|
||||
<span class="config-value">{{ data.config.subdomainHost }}</span>
|
||||
<span class="config-value">{{ data.subdomainHost }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Max Pool Count</span>
|
||||
<span class="config-value">{{ data.config.maxPoolCount }}</span>
|
||||
<span class="config-value">{{ data.maxPoolCount }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Max Ports/Client</span>
|
||||
<span class="config-value">{{ maxPortsPerClientLabel }}</span>
|
||||
<span class="config-value">{{ data.maxPortsPerClient }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.allowPortsStr != ''">
|
||||
<div class="config-item" v-if="data.allowPortsStr != ''">
|
||||
<span class="config-label">Allow Ports</span>
|
||||
<span class="config-value">{{ data.config.allowPortsStr }}</span>
|
||||
<span class="config-value">{{ data.allowPortsStr }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.config.tlsForce">
|
||||
<div class="config-item" v-if="data.tlsForce">
|
||||
<span class="config-label">TLS Force</span>
|
||||
<el-tag size="small" type="warning">Enabled</el-tag>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Heartbeat Timeout</span>
|
||||
<span class="config-value">{{ data.config.heartbeatTimeout }}s</span>
|
||||
<span class="config-value">{{ data.heartbeatTimeout }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -167,11 +167,9 @@ import { formatFileSize } from '../utils/format'
|
||||
import { Download, Upload } from '@element-plus/icons-vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const data = ref<ServerInfo>({
|
||||
const data = ref({
|
||||
version: '',
|
||||
config: {
|
||||
bindPort: 0,
|
||||
kcpBindPort: 0,
|
||||
quicBindPort: 0,
|
||||
@@ -180,46 +178,58 @@ const data = ref<ServerInfo>({
|
||||
tcpmuxHTTPConnectPort: 0,
|
||||
subdomainHost: '',
|
||||
maxPoolCount: 0,
|
||||
maxPortsPerClient: 0,
|
||||
maxPortsPerClient: '',
|
||||
allowPortsStr: '',
|
||||
tlsForce: false,
|
||||
heartbeatTimeout: 0,
|
||||
},
|
||||
status: {
|
||||
clientCounts: 0,
|
||||
curConns: 0,
|
||||
proxyCounts: 0,
|
||||
totalTrafficIn: 0,
|
||||
totalTrafficOut: 0,
|
||||
proxyTypeCount: {},
|
||||
},
|
||||
proxyTypeCounts: {} as Record<string, number>,
|
||||
})
|
||||
|
||||
const hasActiveProxies = computed(() => {
|
||||
return Object.values(data.value.status.proxyTypeCount).some((c) => c > 0)
|
||||
})
|
||||
|
||||
const proxyCounts = computed(() => {
|
||||
return Object.values(data.value.status.proxyTypeCount).reduce(
|
||||
(sum, count) => sum + (count || 0),
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
const maxPortsPerClientLabel = computed(() => {
|
||||
const value = data.value.config.maxPortsPerClient
|
||||
return value === 0 ? 'no limit' : String(value)
|
||||
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)
|
||||
})
|
||||
|
||||
const formatTrafficTotal = () => {
|
||||
const total =
|
||||
data.value.status.totalTrafficIn + data.value.status.totalTrafficOut
|
||||
const total = data.value.totalTrafficIn + data.value.totalTrafficOut
|
||||
return formatFileSize(total)
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const json = await getServerInfo()
|
||||
data.value = json
|
||||
data.value.version = json.version
|
||||
data.value.bindPort = json.bindPort
|
||||
data.value.kcpBindPort = json.kcpBindPort
|
||||
data.value.quicBindPort = json.quicBindPort
|
||||
data.value.vhostHTTPPort = json.vhostHTTPPort
|
||||
data.value.vhostHTTPSPort = json.vhostHTTPSPort
|
||||
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
|
||||
data.value.subdomainHost = json.subdomainHost
|
||||
data.value.maxPoolCount = json.maxPoolCount
|
||||
data.value.maxPortsPerClient = String(json.maxPortsPerClient)
|
||||
if (data.value.maxPortsPerClient == '0') {
|
||||
data.value.maxPortsPerClient = 'no limit'
|
||||
}
|
||||
data.value.allowPortsStr = json.allowPortsStr
|
||||
data.value.tlsForce = json.tlsForce
|
||||
data.value.heartbeatTimeout = json.heartbeatTimeout
|
||||
data.value.clientCounts = json.clientCounts
|
||||
data.value.curConns = json.curConns
|
||||
data.value.totalTrafficIn = json.totalTrafficIn
|
||||
data.value.totalTrafficOut = json.totalTrafficOut
|
||||
data.value.proxyTypeCounts = json.proxyTypeCount || {}
|
||||
|
||||
data.value.proxyCounts = 0
|
||||
if (json.proxyTypeCount != null) {
|
||||
Object.values(json.proxyTypeCount).forEach((count: any) => {
|
||||
data.value.proxyCounts += count || 0
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
|
||||
349
web/package-lock.json
generated
349
web/package-lock.json
generated
@@ -147,13 +147,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"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==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
@@ -163,13 +164,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -179,13 +181,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -195,13 +198,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -211,13 +215,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -227,13 +232,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -243,13 +249,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -259,13 +266,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"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==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -275,13 +283,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -291,13 +300,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -307,13 +317,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -323,13 +334,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"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==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -339,13 +351,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -355,13 +368,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -371,13 +385,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -387,13 +402,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -403,13 +419,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -419,13 +436,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -435,13 +453,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -451,13 +470,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -467,13 +487,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"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==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -483,13 +504,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
@@ -499,13 +521,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
@@ -515,13 +538,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -531,13 +555,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -547,13 +572,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"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==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -928,6 +954,76 @@
|
||||
"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",
|
||||
@@ -2471,9 +2567,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -3354,11 +3450,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -3366,32 +3463,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
@@ -4762,20 +4859,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -6188,10 +6276,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"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==",
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -7371,12 +7460,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
Reference in New Issue
Block a user