mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 17:29:16 +08:00
Compare commits
30 Commits
v0.69.0
...
7b6e01f04f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6e01f04f | ||
|
|
5396947c7c | ||
|
|
fe61093bc2 | ||
|
|
54aeb2a7b0 | ||
|
|
84be1938e4 | ||
|
|
becee40715 | ||
|
|
17e788d43b | ||
|
|
68509f5d44 | ||
|
|
5cd722b177 | ||
|
|
5876beceac | ||
|
|
7fe152e3aa | ||
|
|
7c343fc6e7 | ||
|
|
a3b3b35b69 | ||
|
|
ae1c0504ec | ||
|
|
393a533744 | ||
|
|
035889c360 | ||
|
|
940bde5c46 | ||
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec | ||
|
|
9bde0b07de | ||
|
|
c6c545289c | ||
|
|
503afe78b7 | ||
|
|
8dd26c6961 | ||
|
|
9ea1d86f03 | ||
|
|
ac3e82db4e | ||
|
|
0773938d70 | ||
|
|
9bacce22a2 | ||
|
|
7f8d68b666 |
21
README.md
21
README.md
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
[](https://circleci.com/gh/fatedier/frp)
|
[](https://circleci.com/gh/fatedier/frp)
|
||||||
[](https://github.com/fatedier/frp/releases)
|
[](https://github.com/fatedier/frp/releases)
|
||||||
[](https://goreportcard.com/report/github.com/fatedier/frp)
|
|
||||||
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||||
|
|
||||||
[README](README.md) | [中文文档](README_zh.md)
|
[README](README.md) | [中文文档](README_zh.md)
|
||||||
@@ -13,6 +12,16 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--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">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
@@ -30,16 +39,6 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## What is frp?
|
## What is frp?
|
||||||
|
|||||||
20
README_zh.md
20
README_zh.md
@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--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">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
@@ -32,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## 为什么使用 frp ?
|
## 为什么使用 frp ?
|
||||||
|
|||||||
26
Release.md
26
Release.md
@@ -1,21 +1,9 @@
|
|||||||
## 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
|
## Features
|
||||||
|
|
||||||
* Added `transport.wireProtocol` for frpc to select the internal message protocol used between frpc and frps. Supported values are `v1` and `v2`.
|
* 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.
|
||||||
* 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.
|
## Fixes
|
||||||
|
|
||||||
|
* WebSocket and WSS tunnel payloads are now sent as binary frames, avoiding disconnects through RFC-compliant intermediaries that validate text frames as UTF-8.
|
||||||
|
* The `tls2raw` client plugin now writes the proxy protocol header to the local raw connection when proxy protocol is enabled.
|
||||||
|
* frpc now rejects duplicate proxy and visitor names in config files instead of silently overwriting earlier entries.
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||||
|
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
|
||||||
readCh := make(chan *msg.UDPPacket, 1024)
|
readCh := make(chan *msg.UDPPacket, 1024)
|
||||||
sendCh := make(chan msg.Message, 1024)
|
sendCh := make(chan msg.Message, 1024)
|
||||||
isClose := false
|
isClose := false
|
||||||
@@ -109,7 +110,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
|
||||||
defer closeFn()
|
defer closeFn()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -122,7 +123,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var udpMsg msg.UDPPacket
|
var udpMsg msg.UDPPacket
|
||||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
|
||||||
xl.Warnf("read from workConn for sudp error: %v", errRet)
|
xl.Warnf("read from workConn for sudp error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
|
||||||
defer func() {
|
defer func() {
|
||||||
closeFn()
|
closeFn()
|
||||||
xl.Infof("writer goroutine for sudp work connection closed")
|
xl.Infof("writer goroutine for sudp work connection closed")
|
||||||
@@ -148,12 +149,12 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
switch m := rawMsg.(type) {
|
switch m := rawMsg.(type) {
|
||||||
case *msg.UDPPacket:
|
case *msg.UDPPacket:
|
||||||
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
|
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
|
||||||
m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())
|
m.LocalAddr.String(), m.RemoteAddr.String(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
|
||||||
case *msg.Ping:
|
case *msg.Ping:
|
||||||
xl.Tracef("frpc send ping message to frpc visitor")
|
xl.Tracef("frpc send ping message to frpc visitor")
|
||||||
}
|
}
|
||||||
|
|
||||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
|
||||||
xl.Errorf("sudp work write error: %v", errRet)
|
xl.Errorf("sudp work write error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -184,8 +185,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnSenderFn(workConn, sendCh)
|
go workConnSenderFn(payloadConn, sendCh)
|
||||||
go workConnReaderFn(workConn, readCh)
|
go workConnReaderFn(payloadConn, readCh)
|
||||||
go heartbeatFn(sendCh)
|
go heartbeatFn(sendCh)
|
||||||
|
|
||||||
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
||||||
|
|||||||
@@ -99,15 +99,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
|
|
||||||
pxy.mu.Lock()
|
pxy.mu.Lock()
|
||||||
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
|
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.readCh = make(chan *msg.UDPPacket, 1024)
|
||||||
pxy.sendCh = make(chan msg.Message, 1024)
|
pxy.sendCh = make(chan msg.Message, 1024)
|
||||||
pxy.closed = false
|
pxy.closed = false
|
||||||
pxy.mu.Unlock()
|
pxy.mu.Unlock()
|
||||||
|
|
||||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
|
||||||
for {
|
for {
|
||||||
var udpMsg msg.UDPPacket
|
var udpMsg msg.UDPPacket
|
||||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
|
||||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,7 +122,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
workConnSenderFn := func(rw msg.ReadWriter, sendCh chan msg.Message) {
|
||||||
defer func() {
|
defer func() {
|
||||||
xl.Infof("writer goroutine for udp work connection closed")
|
xl.Infof("writer goroutine for udp work connection closed")
|
||||||
}()
|
}()
|
||||||
@@ -132,7 +134,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
case *msg.Ping:
|
case *msg.Ping:
|
||||||
xl.Tracef("send ping message to udp workConn")
|
xl.Tracef("send ping message to udp workConn")
|
||||||
}
|
}
|
||||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
if errRet = rw.WriteMsg(rawMsg); errRet != nil {
|
||||||
xl.Errorf("udp work write error: %v", errRet)
|
xl.Errorf("udp work write error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -151,8 +153,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnSenderFn(pxy.workConn, pxy.sendCh)
|
go workConnSenderFn(payloadRW, pxy.sendCh)
|
||||||
go workConnReaderFn(pxy.workConn, pxy.readCh)
|
go workConnReaderFn(payloadRW, pxy.readCh)
|
||||||
go heartbeatFn(pxy.sendCh)
|
go heartbeatFn(pxy.sendCh)
|
||||||
|
|
||||||
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
|
|||||||
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
var natHoleSidMsg msg.NatHoleSid
|
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
|
||||||
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Errorf("xtcp read from workConn error: %v", err)
|
xl.Errorf("xtcp read from workConn error: %v", err)
|
||||||
return
|
return
|
||||||
@@ -131,6 +130,15 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
|
|||||||
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
|
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) {
|
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
listenConn.Close()
|
listenConn.Close()
|
||||||
|
|||||||
66
client/proxy/xtcp_test.go
Normal file
66
client/proxy/xtcp_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadNatHoleSidUsesSelectedWireProtocol(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
wireProtocol string
|
||||||
|
}{
|
||||||
|
{name: "v2", wireProtocol: wire.ProtocolV2},
|
||||||
|
{name: "v1", wireProtocol: wire.ProtocolV1},
|
||||||
|
{name: "default", wireProtocol: ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
setPipeDeadline(t, client, server)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
writer := msg.NewConn(server, msg.NewReadWriter(server, tc.wireProtocol))
|
||||||
|
errCh <- writer.WriteMsg(&msg.NatHoleSid{Sid: "sid"})
|
||||||
|
}()
|
||||||
|
|
||||||
|
out, err := readNatHoleSid(client, tc.wireProtocol)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "sid", out.Sid)
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for _, conn := range conns {
|
||||||
|
require.NoError(t, conn.SetDeadline(deadline))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,15 +113,16 @@ func (sv *SUDPVisitor) dispatcher() {
|
|||||||
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
xl := xlog.FromContextSafe(sv.ctx)
|
||||||
xl.Debugf("starting sudp proxy worker")
|
xl.Debugf("starting sudp proxy worker")
|
||||||
|
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
|
||||||
|
|
||||||
wg := &sync.WaitGroup{}
|
wg := &sync.WaitGroup{}
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
closeCh := make(chan struct{})
|
closeCh := make(chan struct{})
|
||||||
|
|
||||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||||
workConnReaderFn := func(conn net.Conn) {
|
workConnReaderFn := func(payloadConn *msg.Conn) {
|
||||||
defer func() {
|
defer func() {
|
||||||
conn.Close()
|
payloadConn.Close()
|
||||||
close(closeCh)
|
close(closeCh)
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
@@ -133,13 +134,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
|
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||||
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = conn.SetReadDeadline(time.Time{})
|
_ = payloadConn.SetReadDeadline(time.Time{})
|
||||||
switch m := rawMsg.(type) {
|
switch m := rawMsg.(type) {
|
||||||
case *msg.Ping:
|
case *msg.Ping:
|
||||||
xl.Debugf("frpc visitor get ping message from frpc")
|
xl.Debugf("frpc visitor get ping message from frpc")
|
||||||
@@ -157,15 +158,15 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||||
workConnSenderFn := func(conn net.Conn) {
|
workConnSenderFn := func(payloadConn *msg.Conn) {
|
||||||
defer func() {
|
defer func() {
|
||||||
conn.Close()
|
payloadConn.Close()
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var errRet error
|
var errRet error
|
||||||
if firstPacket != nil {
|
if firstPacket != nil {
|
||||||
if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
|
if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
|
||||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -180,7 +181,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
|
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
|
||||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -191,8 +192,8 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnReaderFn(workConn)
|
go workConnReaderFn(payloadConn)
|
||||||
go workConnSenderFn(workConn)
|
go workConnSenderFn(payloadConn)
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
xl.Infof("sudp worker is closed")
|
xl.Infof("sudp worker is closed")
|
||||||
|
|||||||
@@ -394,6 +394,10 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +421,31 @@ func LoadClientConfig(path string, strict bool) (
|
|||||||
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
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 {
|
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
||||||
proxyCfgs := proxies
|
proxyCfgs := proxies
|
||||||
for _, c := range proxyCfgs {
|
for _, c := range proxyCfgs {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -462,6 +464,111 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
|
|||||||
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
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
|
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
||||||
func TestYAMLEdgeCases(t *testing.T) {
|
func TestYAMLEdgeCases(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|||||||
@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
total = len(m.info.ProxyStatistics)
|
total = len(m.info.ProxyStatistics)
|
||||||
for name, data := range m.info.ProxyStatistics {
|
for name, data := range m.info.ProxyStatistics {
|
||||||
if !data.LastCloseTime.IsZero() &&
|
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
||||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
|
||||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
|
||||||
delete(m.info.ProxyStatistics, name)
|
delete(m.info.ProxyStatistics, name)
|
||||||
count++
|
count++
|
||||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||||
@@ -106,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
|||||||
return count, total
|
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) {
|
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
||||||
return m.clearUselessInfo(0)
|
return m.clearUselessInfo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
|
||||||
|
return m.clearUselessInfo(0)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) NewClient() {
|
func (m *serverMetrics) NewClient() {
|
||||||
m.info.ClientCounts.Inc(1)
|
m.info.ClientCounts.Inc(1)
|
||||||
}
|
}
|
||||||
@@ -231,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
|
|||||||
}
|
}
|
||||||
if !proxyStats.LastStartTime.IsZero() {
|
if !proxyStats.LastStartTime.IsZero() {
|
||||||
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||||
|
ps.LastStartAt = proxyStats.LastStartTime.Unix()
|
||||||
}
|
}
|
||||||
if !proxyStats.LastCloseTime.IsZero() {
|
if !proxyStats.LastCloseTime.IsZero() {
|
||||||
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||||
|
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
|
||||||
}
|
}
|
||||||
return ps
|
return ps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
|||||||
clk.SetTime(closedAt)
|
clk.SetTime(closedAt)
|
||||||
metrics.CloseProxy("proxy", "tcp")
|
metrics.CloseProxy("proxy", "tcp")
|
||||||
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
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) {
|
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||||
@@ -43,6 +49,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
|||||||
require.Empty(metrics.info.ProxyStatistics)
|
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) {
|
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ type ProxyStats struct {
|
|||||||
TodayTrafficOut int64
|
TodayTrafficOut int64
|
||||||
LastStartTime string
|
LastStartTime string
|
||||||
LastCloseTime string
|
LastCloseTime string
|
||||||
|
LastStartAt int64
|
||||||
|
LastCloseAt int64
|
||||||
CurConns int64
|
CurConns int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +87,5 @@ type Collector interface {
|
|||||||
GetProxyByName(proxyName string) *ProxyStats
|
GetProxyByName(proxyName string) *ProxyStats
|
||||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||||
ClearOfflineProxies() (int, int)
|
ClearOfflineProxies() (int, int)
|
||||||
|
PruneOfflineProxies() (int, int)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,28 @@ func TestV2ReadWriterRoundTrip(t *testing.T) {
|
|||||||
func TestNewReadWriter(t *testing.T) {
|
func TestNewReadWriter(t *testing.T) {
|
||||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
|
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
|
||||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV1))
|
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))
|
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) {
|
func TestV2MessageTypeIDsAreStable(t *testing.T) {
|
||||||
require.Equal(t, uint16(1), V2TypeLogin)
|
require.Equal(t, uint16(1), V2TypeLogin)
|
||||||
require.Equal(t, uint16(2), V2TypeLoginResp)
|
require.Equal(t, uint16(2), V2TypeLoginResp)
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
|||||||
return
|
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)
|
libio.Join(tlsConn, rawConn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ type GeneralResponse struct {
|
|||||||
Msg string
|
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.
|
// APIHandler is a handler function that returns a response object or an error.
|
||||||
type APIHandler func(ctx *Context) (any, error)
|
type APIHandler func(ctx *Context) (any, error)
|
||||||
|
|
||||||
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
|
||||||
|
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := NewContext(w, r)
|
||||||
|
res, err := handler(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
|
||||||
|
code := http.StatusInternalServerError
|
||||||
|
if e, ok := err.(*Error); ok {
|
||||||
|
code = e.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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
|
return ctx, conn, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
|||||||
|
|
||||||
muxer := http.NewServeMux()
|
muxer := http.NewServeMux()
|
||||||
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
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{})
|
notifyCh := make(chan struct{})
|
||||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||||
close(notifyCh)
|
close(notifyCh)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "0.69.0"
|
var version = "0.70.0"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
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
|
// view
|
||||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||||
subRouter.PathPrefix("/static/").Handler(
|
subRouter.PathPrefix("/static/").Handler(
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ type SessionContext struct {
|
|||||||
ServerCfg *v1.ServerConfig
|
ServerCfg *v1.ServerConfig
|
||||||
// client registry
|
// client registry
|
||||||
ClientRegistry *registry.ClientRegistry
|
ClientRegistry *registry.ClientRegistry
|
||||||
|
// negotiated wire protocol for this client session
|
||||||
|
WireProtocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Control struct {
|
type Control struct {
|
||||||
@@ -452,6 +454,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
|
|||||||
Configurer: pxyConf,
|
Configurer: pxyConf,
|
||||||
ServerCfg: ctl.sessionCtx.ServerCfg,
|
ServerCfg: ctl.sessionCtx.ServerCfg,
|
||||||
EncryptionKey: ctl.sessionCtx.EncryptionKey,
|
EncryptionKey: ctl.sessionCtx.EncryptionKey,
|
||||||
|
WireProtocol: ctl.sessionCtx.WireProtocol,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return remoteAddr, err
|
return remoteAddr, err
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ func NewController(
|
|||||||
|
|
||||||
// /api/serverinfo
|
// /api/serverinfo
|
||||||
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||||
|
return c.buildServerInfoResp(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||||
serverStats := mem.StatsCollector.GetServer()
|
serverStats := mem.StatsCollector.GetServer()
|
||||||
svrResp := model.ServerInfoResp{
|
return model.ServerInfoResp{
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
BindPort: c.serverCfg.BindPort,
|
BindPort: c.serverCfg.BindPort,
|
||||||
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||||
@@ -80,8 +84,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
|||||||
ClientCounts: serverStats.ClientCounts,
|
ClientCounts: serverStats.ClientCounts,
|
||||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||||
}
|
}
|
||||||
|
|
||||||
return svrResp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// /api/clients
|
// /api/clients
|
||||||
|
|||||||
647
server/http/controller_v2.go
Normal file
647
server/http/controller_v2.go
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
// 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"
|
||||||
|
"maps"
|
||||||
|
"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.Spec.Type, b.Spec.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.Spec.Type,
|
||||||
|
item.User,
|
||||||
|
item.ClientID,
|
||||||
|
item.Status.State,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item.Spec.Type {
|
||||||
|
case string(v1.ProxyTypeTCP):
|
||||||
|
if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
|
||||||
|
values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
|
||||||
|
}
|
||||||
|
case string(v1.ProxyTypeUDP):
|
||||||
|
if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
|
||||||
|
values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
|
||||||
|
}
|
||||||
|
case string(v1.ProxyTypeHTTP):
|
||||||
|
if item.Spec.HTTP != nil {
|
||||||
|
values = append(values, item.Spec.HTTP.CustomDomains...)
|
||||||
|
values = append(values, item.Spec.HTTP.Subdomain)
|
||||||
|
}
|
||||||
|
case string(v1.ProxyTypeHTTPS):
|
||||||
|
if item.Spec.HTTPS != nil {
|
||||||
|
values = append(values, item.Spec.HTTPS.CustomDomains...)
|
||||||
|
values = append(values, item.Spec.HTTPS.Subdomain)
|
||||||
|
}
|
||||||
|
case string(v1.ProxyTypeTCPMUX):
|
||||||
|
if item.Spec.TCPMux != nil {
|
||||||
|
values = append(values, item.Spec.TCPMux.CustomDomains...)
|
||||||
|
values = append(values, item.Spec.TCPMux.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 cfg v1.ProxyConfigurer
|
||||||
|
if c.pxyManager != nil {
|
||||||
|
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||||
|
state = "online"
|
||||||
|
cfg = pxy.GetConfigurer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.V2ProxyResp{
|
||||||
|
Name: ps.Name,
|
||||||
|
User: ps.User,
|
||||||
|
ClientID: ps.ClientID,
|
||||||
|
Spec: buildV2ProxySpec(ps.Type, cfg),
|
||||||
|
Status: model.V2ProxyStatusResp{
|
||||||
|
State: state,
|
||||||
|
TodayTrafficIn: ps.TodayTrafficIn,
|
||||||
|
TodayTrafficOut: ps.TodayTrafficOut,
|
||||||
|
CurConns: ps.CurConns,
|
||||||
|
LastStartAt: ps.LastStartAt,
|
||||||
|
LastCloseAt: ps.LastCloseAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
|
||||||
|
spec := model.V2ProxySpec{Type: proxyType}
|
||||||
|
|
||||||
|
switch proxyType {
|
||||||
|
case string(v1.ProxyTypeTCP):
|
||||||
|
block := &model.V2TCPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.RemotePort = &c.RemotePort
|
||||||
|
}
|
||||||
|
spec.TCP = block
|
||||||
|
case string(v1.ProxyTypeUDP):
|
||||||
|
block := &model.V2UDPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.RemotePort = &c.RemotePort
|
||||||
|
}
|
||||||
|
spec.UDP = block
|
||||||
|
case string(v1.ProxyTypeHTTP):
|
||||||
|
block := &model.V2HTTPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||||
|
block.Subdomain = c.SubDomain
|
||||||
|
block.Locations = slices.Clone(c.Locations)
|
||||||
|
block.HostHeaderRewrite = c.HostHeaderRewrite
|
||||||
|
}
|
||||||
|
spec.HTTP = block
|
||||||
|
case string(v1.ProxyTypeHTTPS):
|
||||||
|
block := &model.V2HTTPSProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||||
|
block.Subdomain = c.SubDomain
|
||||||
|
}
|
||||||
|
spec.HTTPS = block
|
||||||
|
case string(v1.ProxyTypeTCPMUX):
|
||||||
|
block := &model.V2TCPMuxProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||||
|
block.Subdomain = c.SubDomain
|
||||||
|
block.Multiplexer = c.Multiplexer
|
||||||
|
block.RouteByHTTPUser = c.RouteByHTTPUser
|
||||||
|
}
|
||||||
|
spec.TCPMux = block
|
||||||
|
case string(v1.ProxyTypeSTCP):
|
||||||
|
block := &model.V2STCPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
}
|
||||||
|
spec.STCP = block
|
||||||
|
case string(v1.ProxyTypeSUDP):
|
||||||
|
block := &model.V2SUDPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
}
|
||||||
|
spec.SUDP = block
|
||||||
|
case string(v1.ProxyTypeXTCP):
|
||||||
|
block := &model.V2XTCPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
}
|
||||||
|
spec.XTCP = block
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
|
||||||
|
return model.V2ProxyBaseSpec{
|
||||||
|
Annotations: maps.Clone(base.Annotations),
|
||||||
|
Metadatas: maps.Clone(base.Metadatas),
|
||||||
|
Transport: &model.V2ProxyTransportSpec{
|
||||||
|
UseEncryption: base.Transport.UseEncryption,
|
||||||
|
UseCompression: base.Transport.UseCompression,
|
||||||
|
BandwidthLimit: base.Transport.BandwidthLimit.String(),
|
||||||
|
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
|
||||||
|
},
|
||||||
|
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
|
||||||
|
Group: base.LoadBalancer.Group,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
393
server/http/controller_v2_proxy_spec_test.go
Normal file
393
server/http/controller_v2_proxy_spec_test.go
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
// 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"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configtypes "github.com/fatedier/frp/pkg/config/types"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
|
"github.com/fatedier/frp/server/http/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
proxyType string
|
||||||
|
cfg v1.ProxyConfigurer
|
||||||
|
blockKeys []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
proxyType: "tcp",
|
||||||
|
cfg: &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
|
||||||
|
RemotePort: 6000,
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "udp",
|
||||||
|
cfg: &v1.UDPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||||
|
RemotePort: 7000,
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "http",
|
||||||
|
cfg: &v1.HTTPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
|
||||||
|
Locations: []string{"/api"},
|
||||||
|
HTTPUser: "secret-http-user",
|
||||||
|
HTTPPassword: "secret-http-password",
|
||||||
|
HostHeaderRewrite: "backend.example.com",
|
||||||
|
RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
|
||||||
|
ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
|
||||||
|
RouteByHTTPUser: "secret-http-route-user",
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "https",
|
||||||
|
cfg: &v1.HTTPSProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "tcpmux",
|
||||||
|
cfg: &v1.TCPMuxProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
|
||||||
|
HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
|
||||||
|
HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
|
||||||
|
RouteByHTTPUser: "displayed-mux-user",
|
||||||
|
Multiplexer: "httpconnect",
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "stcp",
|
||||||
|
cfg: &v1.STCPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
|
||||||
|
Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
|
||||||
|
AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "sudp",
|
||||||
|
cfg: &v1.SUDPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
|
||||||
|
Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
|
||||||
|
AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "xtcp",
|
||||||
|
cfg: &v1.XTCPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
|
||||||
|
Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
|
||||||
|
AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.proxyType, func(t *testing.T) {
|
||||||
|
spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
|
||||||
|
raw := mustMarshalJSON(t, spec)
|
||||||
|
|
||||||
|
var specObject map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &specObject); err != nil {
|
||||||
|
t.Fatalf("unmarshal spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, specObject, tt.proxyType, "type")
|
||||||
|
|
||||||
|
var gotType string
|
||||||
|
if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
|
||||||
|
t.Fatalf("unmarshal spec type failed: %v", err)
|
||||||
|
}
|
||||||
|
if gotType != tt.proxyType {
|
||||||
|
t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var block map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal active block failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, block, tt.blockKeys...)
|
||||||
|
assertV2ProxyCommonSpec(t, block)
|
||||||
|
assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
|
||||||
|
assertNoV2ProxySensitiveFields(t, block)
|
||||||
|
|
||||||
|
content := string(raw)
|
||||||
|
for _, secret := range []string{
|
||||||
|
"secret-proxy-name",
|
||||||
|
"secret-group-key",
|
||||||
|
"secret-local-host",
|
||||||
|
"secret-plugin-user",
|
||||||
|
"secret-plugin-password",
|
||||||
|
"secret-health-path",
|
||||||
|
"secret-http-user",
|
||||||
|
"secret-http-password",
|
||||||
|
"secret-request-header",
|
||||||
|
"secret-response-header",
|
||||||
|
"secret-http-route-user",
|
||||||
|
"secret-mux-http-user",
|
||||||
|
"secret-mux-http-password",
|
||||||
|
"secret-stcp-key",
|
||||||
|
"secret-stcp-user",
|
||||||
|
"secret-sudp-key",
|
||||||
|
"secret-sudp-user",
|
||||||
|
"secret-xtcp-key",
|
||||||
|
"secret-xtcp-user",
|
||||||
|
} {
|
||||||
|
if strings.Contains(content, secret) {
|
||||||
|
t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
switch proxyType {
|
||||||
|
case "tcp":
|
||||||
|
var block model.V2TCPProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal tcp block failed: %v", err)
|
||||||
|
}
|
||||||
|
if block.RemotePort == nil || *block.RemotePort != 6000 {
|
||||||
|
t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
|
||||||
|
}
|
||||||
|
case "udp":
|
||||||
|
var block model.V2UDPProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal udp block failed: %v", err)
|
||||||
|
}
|
||||||
|
if block.RemotePort == nil || *block.RemotePort != 7000 {
|
||||||
|
t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
|
||||||
|
}
|
||||||
|
case "http":
|
||||||
|
var block model.V2HTTPProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal http block failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
|
||||||
|
block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
|
||||||
|
block.HostHeaderRewrite != "backend.example.com" {
|
||||||
|
t.Fatalf("http fields mismatch: %#v", block)
|
||||||
|
}
|
||||||
|
case "https":
|
||||||
|
var block model.V2HTTPSProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal https block failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
|
||||||
|
t.Fatalf("https fields mismatch: %#v", block)
|
||||||
|
}
|
||||||
|
case "tcpmux":
|
||||||
|
var block model.V2TCPMuxProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal tcpmux block failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
|
||||||
|
block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
|
||||||
|
t.Fatalf("tcpmux fields mismatch: %#v", block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
|
||||||
|
for _, proxyType := range apiV2ProxyTypes {
|
||||||
|
t.Run(proxyType, func(t *testing.T) {
|
||||||
|
resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
|
||||||
|
Name: "offline-" + proxyType,
|
||||||
|
Type: proxyType,
|
||||||
|
})
|
||||||
|
if resp.Status.State != "offline" {
|
||||||
|
t.Fatalf("offline phase mismatch: %#v", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var specObject map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
|
||||||
|
t.Fatalf("unmarshal offline spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, specObject, proxyType, "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, specObject[proxyType])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
|
||||||
|
spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||||
|
RemotePort: 7000,
|
||||||
|
})
|
||||||
|
|
||||||
|
var specObject map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
|
||||||
|
t.Fatalf("unmarshal mismatched spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, specObject, "tcp", "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, specObject["tcp"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create bandwidth limit failed: %v", err)
|
||||||
|
}
|
||||||
|
enabled := false
|
||||||
|
return v1.ProxyBaseConfig{
|
||||||
|
Name: "secret-proxy-name",
|
||||||
|
Type: proxyType,
|
||||||
|
Enabled: &enabled,
|
||||||
|
Annotations: map[string]string{"annotation-key": "annotation-value"},
|
||||||
|
Metadatas: map[string]string{"metadata-key": "metadata-value"},
|
||||||
|
Transport: v1.ProxyTransport{
|
||||||
|
UseEncryption: true,
|
||||||
|
UseCompression: true,
|
||||||
|
BandwidthLimit: bandwidthLimit,
|
||||||
|
BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
|
||||||
|
ProxyProtocolVersion: "v2",
|
||||||
|
},
|
||||||
|
LoadBalancer: v1.LoadBalancerConfig{
|
||||||
|
Group: "public-group",
|
||||||
|
GroupKey: "secret-group-key",
|
||||||
|
},
|
||||||
|
HealthCheck: v1.HealthCheckConfig{
|
||||||
|
Type: "http",
|
||||||
|
Path: "secret-health-path",
|
||||||
|
},
|
||||||
|
ProxyBackend: v1.ProxyBackend{
|
||||||
|
LocalIP: "secret-local-host",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Plugin: v1.TypedClientPluginOptions{
|
||||||
|
Type: v1.PluginHTTPProxy,
|
||||||
|
ClientPluginOptions: &v1.HTTPProxyPluginOptions{
|
||||||
|
Type: v1.PluginHTTPProxy,
|
||||||
|
HTTPUser: "secret-plugin-user",
|
||||||
|
HTTPPassword: "secret-plugin-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var annotations map[string]string
|
||||||
|
if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
|
||||||
|
t.Fatalf("unmarshal annotations failed: %v", err)
|
||||||
|
}
|
||||||
|
if annotations["annotation-key"] != "annotation-value" {
|
||||||
|
t.Fatalf("annotations mismatch: %#v", annotations)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadatas map[string]string
|
||||||
|
if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
|
||||||
|
t.Fatalf("unmarshal metadatas failed: %v", err)
|
||||||
|
}
|
||||||
|
if metadatas["metadata-key"] != "metadata-value" {
|
||||||
|
t.Fatalf("metadatas mismatch: %#v", metadatas)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRawJSONKeysFromMessage(t, block["transport"],
|
||||||
|
"bandwidthLimit",
|
||||||
|
"bandwidthLimitMode",
|
||||||
|
"useCompression",
|
||||||
|
"useEncryption",
|
||||||
|
)
|
||||||
|
var transport model.V2ProxyTransportSpec
|
||||||
|
if err := json.Unmarshal(block["transport"], &transport); err != nil {
|
||||||
|
t.Fatalf("unmarshal transport failed: %v", err)
|
||||||
|
}
|
||||||
|
if !transport.UseEncryption || !transport.UseCompression ||
|
||||||
|
transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
|
||||||
|
t.Fatalf("transport mismatch: %#v", transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
|
||||||
|
var loadBalancer model.V2ProxyLoadBalancerSpec
|
||||||
|
if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
|
||||||
|
t.Fatalf("unmarshal load balancer failed: %v", err)
|
||||||
|
}
|
||||||
|
if loadBalancer.Group != "public-group" {
|
||||||
|
t.Fatalf("load balancer mismatch: %#v", loadBalancer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
forbidden := map[string]struct{}{
|
||||||
|
"allowUsers": {},
|
||||||
|
"enabled": {},
|
||||||
|
"groupKey": {},
|
||||||
|
"healthCheck": {},
|
||||||
|
"httpPassword": {},
|
||||||
|
"httpUser": {},
|
||||||
|
"localIP": {},
|
||||||
|
"localPort": {},
|
||||||
|
"name": {},
|
||||||
|
"natTraversal": {},
|
||||||
|
"plugin": {},
|
||||||
|
"proxyProtocolVersion": {},
|
||||||
|
"requestHeaders": {},
|
||||||
|
"responseHeaders": {},
|
||||||
|
"secretKey": {},
|
||||||
|
"type": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var walk func(any)
|
||||||
|
walk = func(current any) {
|
||||||
|
switch current := current.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, nested := range current {
|
||||||
|
if _, ok := forbidden[key]; ok {
|
||||||
|
t.Fatalf("sensitive field %q leaked in active block", key)
|
||||||
|
}
|
||||||
|
walk(nested)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, nested := range current {
|
||||||
|
walk(nested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal active block failed: %v", err)
|
||||||
|
}
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode active block failed: %v", err)
|
||||||
|
}
|
||||||
|
walk(decoded)
|
||||||
|
}
|
||||||
908
server/http/controller_v2_test.go
Normal file
908
server/http/controller_v2_test.go
Normal file
@@ -0,0 +1,908 @@
|
|||||||
|
// 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.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||||
|
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||||
|
}
|
||||||
|
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
|
||||||
|
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
|
||||||
|
var rawListSpec map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
|
||||||
|
t.Fatalf("unmarshal list proxy spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
|
||||||
|
var rawDetailSpec map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
|
||||||
|
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
|
||||||
|
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", Spec: model.V2ProxySpec{
|
||||||
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||||
|
}},
|
||||||
|
q: "6000",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "udp remote port",
|
||||||
|
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "udp",
|
||||||
|
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
|
||||||
|
}},
|
||||||
|
q: "7000",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote port does not match colon form",
|
||||||
|
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||||
|
}},
|
||||||
|
q: ":6000",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "http custom domain",
|
||||||
|
item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "http",
|
||||||
|
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
|
||||||
|
}},
|
||||||
|
q: "app.example.com",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https subdomain",
|
||||||
|
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "https",
|
||||||
|
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||||
|
}},
|
||||||
|
q: "portal",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdomain does not match expanded host",
|
||||||
|
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "https",
|
||||||
|
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||||
|
}},
|
||||||
|
q: "portal.example.com",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcpmux custom domain",
|
||||||
|
item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "tcpmux",
|
||||||
|
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
|
||||||
|
}},
|
||||||
|
q: "mux.example.com",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "offline shell does not match online spec fields",
|
||||||
|
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{},
|
||||||
|
}},
|
||||||
|
q: "6000",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "offline shell does not contribute zero remote port",
|
||||||
|
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{},
|
||||||
|
}},
|
||||||
|
q: "0",
|
||||||
|
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 v2TestIntPtr(value int) *int {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
179
server/http/model/v2.go
Normal file
179
server/http/model/v2.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// 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"`
|
||||||
|
User string `json:"user"`
|
||||||
|
ClientID string `json:"clientID"`
|
||||||
|
Spec V2ProxySpec `json:"spec"`
|
||||||
|
Status V2ProxyStatusResp `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxySpec struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
|
||||||
|
UDP *V2UDPProxySpec `json:"udp,omitempty"`
|
||||||
|
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
|
||||||
|
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
|
||||||
|
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
|
||||||
|
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
|
||||||
|
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
|
||||||
|
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyBaseSpec struct {
|
||||||
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
|
Metadatas map[string]string `json:"metadatas,omitempty"`
|
||||||
|
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
|
||||||
|
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyTransportSpec struct {
|
||||||
|
UseEncryption bool `json:"useEncryption"`
|
||||||
|
UseCompression bool `json:"useCompression"`
|
||||||
|
BandwidthLimit string `json:"bandwidthLimit"`
|
||||||
|
BandwidthLimitMode string `json:"bandwidthLimitMode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyLoadBalancerSpec struct {
|
||||||
|
Group string `json:"group"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2TCPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
RemotePort *int `json:"remotePort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2UDPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
RemotePort *int `json:"remotePort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2HTTPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
CustomDomains []string `json:"customDomains,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
Locations []string `json:"locations,omitempty"`
|
||||||
|
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2HTTPSProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
CustomDomains []string `json:"customDomains,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2TCPMuxProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
CustomDomains []string `json:"customDomains,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
Multiplexer string `json:"multiplexer,omitempty"`
|
||||||
|
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2STCPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2SUDPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2XTCPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +16,7 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -31,6 +32,7 @@ import (
|
|||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
"github.com/fatedier/frp/pkg/util/limit"
|
"github.com/fatedier/frp/pkg/util/limit"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
@@ -92,6 +94,7 @@ type BaseProxy struct {
|
|||||||
userInfo plugin.UserInfo
|
userInfo plugin.UserInfo
|
||||||
loginMsg *msg.Login
|
loginMsg *msg.Login
|
||||||
configurer v1.ProxyConfigurer
|
configurer v1.ProxyConfigurer
|
||||||
|
wireProtocol string
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
xl *xlog.Logger
|
xl *xlog.Logger
|
||||||
@@ -315,13 +318,147 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
|
|||||||
name := pxy.GetName()
|
name := pxy.GetName()
|
||||||
proxyType := cfg.Type
|
proxyType := cfg.Type
|
||||||
metrics.Server.OpenConnection(name, proxyType)
|
metrics.Server.OpenConnection(name, proxyType)
|
||||||
inCount, outCount, _ := libio.Join(local, userConn)
|
inCount, outCount, _ := pxy.joinUserConnection(local, userConn, proxyType, xl)
|
||||||
metrics.Server.CloseConnection(name, proxyType)
|
metrics.Server.CloseConnection(name, proxyType)
|
||||||
metrics.Server.AddTrafficIn(name, proxyType, inCount)
|
metrics.Server.AddTrafficIn(name, proxyType, inCount)
|
||||||
metrics.Server.AddTrafficOut(name, proxyType, outCount)
|
metrics.Server.AddTrafficOut(name, proxyType, outCount)
|
||||||
xl.Debugf("join connections closed")
|
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 {
|
type Options struct {
|
||||||
UserInfo plugin.UserInfo
|
UserInfo plugin.UserInfo
|
||||||
LoginMsg *msg.Login
|
LoginMsg *msg.Login
|
||||||
@@ -331,6 +468,7 @@ type Options struct {
|
|||||||
Configurer v1.ProxyConfigurer
|
Configurer v1.ProxyConfigurer
|
||||||
ServerCfg *v1.ServerConfig
|
ServerCfg *v1.ServerConfig
|
||||||
EncryptionKey []byte
|
EncryptionKey []byte
|
||||||
|
WireProtocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
||||||
@@ -357,6 +495,7 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
|
|||||||
userInfo: options.UserInfo,
|
userInfo: options.UserInfo,
|
||||||
loginMsg: options.LoginMsg,
|
loginMsg: options.LoginMsg,
|
||||||
configurer: configurer,
|
configurer: configurer,
|
||||||
|
wireProtocol: options.WireProtocol,
|
||||||
}
|
}
|
||||||
|
|
||||||
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
|
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
|
||||||
|
|||||||
@@ -15,12 +15,15 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
|
func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
|
||||||
@@ -51,3 +54,56 @@ func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
|
|||||||
require.NoError(t, result.err)
|
require.NoError(t, result.err)
|
||||||
require.Same(t, serverMsgConn, result.conn)
|
require.Same(t, serverMsgConn, result.conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetWorkConnFromPoolStartWorkConnUnchangedForUDPWireV2(t *testing.T) {
|
||||||
|
startMsg := getStartWorkConnFromPool(t, &v1.UDPProxyConfig{
|
||||||
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "udp", Type: string(v1.ProxyTypeUDP)},
|
||||||
|
}, wire.ProtocolV2)
|
||||||
|
|
||||||
|
require.Equal(t, msg.StartWorkConn{ProxyName: "udp"}, startMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWorkConnFromPoolLeavesRawTCPPayloadUnframed(t *testing.T) {
|
||||||
|
startMsg := getStartWorkConnFromPool(t, &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "tcp", Type: string(v1.ProxyTypeTCP)},
|
||||||
|
}, wire.ProtocolV2)
|
||||||
|
|
||||||
|
require.Equal(t, msg.StartWorkConn{ProxyName: "tcp"}, startMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStartWorkConnFromPool(t *testing.T, cfg v1.ProxyConfigurer, wireProtocol string) msg.StartWorkConn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
client, server := net.Pipe()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
client.Close()
|
||||||
|
server.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
serverMsgConn := msg.NewConn(server, msg.NewV2ReadWriter(server))
|
||||||
|
clientMsgConn := msg.NewConn(client, msg.NewV2ReadWriter(client))
|
||||||
|
pxy := &BaseProxy{
|
||||||
|
name: cfg.GetBaseConfig().Name,
|
||||||
|
configurer: cfg,
|
||||||
|
poolCount: 0,
|
||||||
|
ctx: context.Background(),
|
||||||
|
wireProtocol: wireProtocol,
|
||||||
|
getWorkConnFn: func() (*WorkConn, error) {
|
||||||
|
return NewWorkConn(serverMsgConn), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
conn, err := pxy.GetWorkConnFromPool(nil, nil)
|
||||||
|
if conn != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
var startMsg msg.StartWorkConn
|
||||||
|
require.NoError(t, clientMsgConn.ReadMsgInto(&startMsg))
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
return startMsg
|
||||||
|
}
|
||||||
|
|||||||
141
server/proxy/sudp_test.go
Normal file
141
server/proxy/sudp_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.UDPPacket{Content: []byte("proxy-to-visitor")})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPProxyToVisitor(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV1),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV2),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(len("proxy-to-visitor")), count)
|
||||||
|
|
||||||
|
frame, err := wire.NewConn(&out).ReadFrame()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||||
|
require.GreaterOrEqual(t, len(frame.Payload), 2)
|
||||||
|
require.Equal(t, msg.V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||||
|
|
||||||
|
var got msg.UDPPacket
|
||||||
|
require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &got))
|
||||||
|
require.Equal(t, []byte("proxy-to-visitor"), got.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.UDPPacket{Content: []byte("visitor-to-proxy")})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPVisitorToProxy(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV2),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV1),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(len("visitor-to-proxy")), count)
|
||||||
|
|
||||||
|
reader := bufio.NewReader(&out)
|
||||||
|
typeByte, err := reader.ReadByte()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, msg.TypeUDPPacket, typeByte)
|
||||||
|
require.NoError(t, reader.UnreadByte())
|
||||||
|
|
||||||
|
var got msg.UDPPacket
|
||||||
|
require.NoError(t, msg.ReadMsgInto(reader, &got))
|
||||||
|
require.Equal(t, []byte("visitor-to-proxy"), got.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeForwardsProxyPing(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.Ping{})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPProxyToVisitor(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV1),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV2),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, count)
|
||||||
|
|
||||||
|
rawMsg, err := msg.NewReadWriter(&out, wire.ProtocolV2).ReadMsg()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.IsType(t, &msg.Ping{}, rawMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeDropsVisitorPing(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Ping{})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPVisitorToProxy(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV2),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV1),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, count)
|
||||||
|
require.Empty(t, out.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) {
|
||||||
|
var in, out bytes.Buffer
|
||||||
|
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Pong{})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := bridgeSUDPVisitorToProxy(
|
||||||
|
msg.NewReadWriter(&in, wire.ProtocolV2),
|
||||||
|
msg.NewReadWriter(&out, wire.ProtocolV1),
|
||||||
|
&count,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.ErrorContains(t, err, "unexpected SUDP visitor message *msg.Pong")
|
||||||
|
require.Zero(t, count)
|
||||||
|
require.Empty(t, out.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) {
|
||||||
|
require.False(t, isMixedWireProtocol("", wire.ProtocolV1))
|
||||||
|
require.False(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV2))
|
||||||
|
require.True(t, isMixedWireProtocol("", wire.ProtocolV2))
|
||||||
|
require.True(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol string, m msg.Message) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, msg.NewReadWriter(buf, wireProtocol).WriteMsg(m))
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
pxy.checkCloseCh = make(chan int)
|
pxy.checkCloseCh = make(chan int)
|
||||||
|
|
||||||
// read message from workConn, if it returns any error, notify proxy to start a new workConn
|
// read message from workConn, if it returns any error, notify proxy to start a new workConn
|
||||||
workConnReaderFn := func(conn net.Conn) {
|
workConnReaderFn := func(payloadConn *msg.Conn) {
|
||||||
for {
|
for {
|
||||||
var (
|
var (
|
||||||
rawMsg msg.Message
|
rawMsg msg.Message
|
||||||
@@ -116,10 +116,10 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
)
|
)
|
||||||
xl.Tracef("loop waiting message from udp workConn")
|
xl.Tracef("loop waiting message from udp workConn")
|
||||||
// client will send heartbeat in workConn for keeping alive
|
// client will send heartbeat in workConn for keeping alive
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
|
_ = payloadConn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
|
||||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||||
_ = conn.Close()
|
_ = payloadConn.Close()
|
||||||
// notify proxy to start a new work connection
|
// notify proxy to start a new work connection
|
||||||
// ignore error here, it means the proxy is closed
|
// ignore error here, it means the proxy is closed
|
||||||
_ = errors.PanicToError(func() {
|
_ = errors.PanicToError(func() {
|
||||||
@@ -127,7 +127,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := conn.SetReadDeadline(time.Time{}); err != nil {
|
if err := payloadConn.SetReadDeadline(time.Time{}); err != nil {
|
||||||
xl.Warnf("set read deadline error: %v", err)
|
xl.Warnf("set read deadline error: %v", err)
|
||||||
}
|
}
|
||||||
switch m := rawMsg.(type) {
|
switch m := rawMsg.(type) {
|
||||||
@@ -144,7 +144,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
int64(len(m.Content)),
|
int64(len(m.Content)),
|
||||||
)
|
)
|
||||||
}); errRet != nil {
|
}); errRet != nil {
|
||||||
conn.Close()
|
_ = payloadConn.Close()
|
||||||
xl.Infof("reader goroutine for udp work connection closed")
|
xl.Infof("reader goroutine for udp work connection closed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// send message to workConn
|
// send message to workConn
|
||||||
workConnSenderFn := func(conn net.Conn, ctx context.Context) {
|
workConnSenderFn := func(payloadConn *msg.Conn, ctx context.Context) {
|
||||||
var errRet error
|
var errRet error
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -162,9 +162,9 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
xl.Infof("sender goroutine for udp work connection closed")
|
xl.Infof("sender goroutine for udp work connection closed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
|
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
|
||||||
xl.Infof("sender goroutine for udp work connection closed: %v", errRet)
|
xl.Infof("sender goroutine for udp work connection closed: %v", errRet)
|
||||||
conn.Close()
|
_ = payloadConn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
|
xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
|
||||||
@@ -223,9 +223,11 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
go workConnReaderFn(pxy.workConn)
|
go workConnReaderFn(payloadConn)
|
||||||
go workConnSenderFn(pxy.workConn, ctx)
|
go workConnSenderFn(payloadConn, ctx)
|
||||||
_, ok := <-pxy.checkCloseCh
|
_, ok := <-pxy.checkCloseCh
|
||||||
cancel()
|
cancel()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -73,10 +74,7 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
|
|||||||
if errRet != nil {
|
if errRet != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m := &msg.NatHoleSid{
|
errRet = writeNatHoleSid(workConn, pxy.wireProtocol, sid)
|
||||||
Sid: sid,
|
|
||||||
}
|
|
||||||
errRet = msg.WriteMsg(workConn, m)
|
|
||||||
if errRet != nil {
|
if errRet != nil {
|
||||||
xl.Warnf("write nat hole sid package error, %v", errRet)
|
xl.Warnf("write nat hole sid package error, %v", errRet)
|
||||||
}
|
}
|
||||||
@@ -87,6 +85,13 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
|
|||||||
return
|
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() {
|
func (pxy *XTCPProxy) Close() {
|
||||||
pxy.closeOnce.Do(func() {
|
pxy.closeOnce.Do(func() {
|
||||||
pxy.BaseProxy.Close()
|
pxy.BaseProxy.Close()
|
||||||
|
|||||||
93
server/proxy/xtcp_test.go
Normal file
93
server/proxy/xtcp_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteNatHoleSidUsesWireV2MessageFrame(t *testing.T) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
setPipeDeadline(t, client, server)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- writeNatHoleSid(server, wire.ProtocolV2, "sid-v2")
|
||||||
|
}()
|
||||||
|
|
||||||
|
frame, err := wire.NewConn(client).ReadFrame()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||||
|
require.GreaterOrEqual(t, len(frame.Payload), 2)
|
||||||
|
require.Equal(t, msg.V2TypeNatHoleSid, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||||
|
|
||||||
|
var out msg.NatHoleSid
|
||||||
|
require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &out))
|
||||||
|
require.Equal(t, "sid-v2", out.Sid)
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNatHoleSidUsesLegacyCodecForWireV1AndDefault(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
wireProtocol string
|
||||||
|
}{
|
||||||
|
{name: "default", wireProtocol: ""},
|
||||||
|
{name: "v1", wireProtocol: wire.ProtocolV1},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
setPipeDeadline(t, client, server)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- writeNatHoleSid(server, tc.wireProtocol, "sid-legacy")
|
||||||
|
}()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(client)
|
||||||
|
typeByte, err := reader.ReadByte()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, msg.TypeNatHoleSid, typeByte)
|
||||||
|
require.NoError(t, reader.UnreadByte())
|
||||||
|
|
||||||
|
var out msg.NatHoleSid
|
||||||
|
require.NoError(t, msg.ReadMsgInto(reader, &out))
|
||||||
|
require.Equal(t, "sid-legacy", out.Sid)
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for _, conn := range conns {
|
||||||
|
require.NoError(t, conn.SetDeadline(deadline))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -508,7 +508,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
}
|
}
|
||||||
case *msg.NewVisitorConn:
|
case *msg.NewVisitorConn:
|
||||||
if err = svr.RegisterVisitorConn(conn, m); err != nil {
|
if err = svr.RegisterVisitorConn(conn, m, acceptedConn.wireProtocol); err != nil {
|
||||||
xl.Warnf("register visitor conn error: %v", err)
|
xl.Warnf("register visitor conn error: %v", err)
|
||||||
_ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
|
_ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
|
||||||
ProxyName: m.ProxyName,
|
ProxyName: m.ProxyName,
|
||||||
@@ -777,6 +777,7 @@ func (svr *Service) RegisterControl(
|
|||||||
LoginMsg: loginMsg,
|
LoginMsg: loginMsg,
|
||||||
ServerCfg: svr.cfg,
|
ServerCfg: svr.cfg,
|
||||||
ClientRegistry: svr.clientRegistry,
|
ClientRegistry: svr.clientRegistry,
|
||||||
|
WireProtocol: wireProtocol,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("create new controller error: %v", err)
|
xl.Warnf("create new controller error: %v", err)
|
||||||
@@ -832,7 +833,7 @@ func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn
|
|||||||
return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
|
return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn) error {
|
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
|
||||||
visitorUser := ""
|
visitorUser := ""
|
||||||
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
|
// 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.
|
// If runID is required, it is not compatible with versions prior to v0.50.0.
|
||||||
@@ -844,5 +845,5 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
|
|||||||
visitorUser = ctl.sessionCtx.LoginMsg.User
|
visitorUser = ctl.sessionCtx.LoginMsg.User
|
||||||
}
|
}
|
||||||
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
||||||
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
|
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.
|
|||||||
|
|
||||||
func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,
|
func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,
|
||||||
useEncryption bool, useCompression bool, visitorUser string,
|
useEncryption bool, useCompression bool, visitorUser string,
|
||||||
|
wireProtocol string,
|
||||||
) (err error) {
|
) (err error) {
|
||||||
vm.mu.RLock()
|
vm.mu.RLock()
|
||||||
defer vm.mu.RUnlock()
|
defer vm.mu.RUnlock()
|
||||||
@@ -90,7 +91,11 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
|
|||||||
if useCompression {
|
if useCompression {
|
||||||
rwc = libio.WithCompression(rwc)
|
rwc = libio.WithCompression(rwc)
|
||||||
}
|
}
|
||||||
err = l.l.PutConn(netpkg.WrapReadWriteCloserToConn(rwc, conn))
|
visitorConn := netpkg.WrapReadWriteCloserToConn(rwc, conn)
|
||||||
|
err = l.l.PutConn(&wireProtocolConn{
|
||||||
|
Conn: visitorConn,
|
||||||
|
wireProtocol: wireProtocol,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
|
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
|
||||||
return
|
return
|
||||||
@@ -98,6 +103,15 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type wireProtocolConn struct {
|
||||||
|
net.Conn
|
||||||
|
wireProtocol string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wireProtocolConn) WireProtocol() string {
|
||||||
|
return c.wireProtocol
|
||||||
|
}
|
||||||
|
|
||||||
func (vm *Manager) CloseListener(name string) {
|
func (vm *Manager) CloseListener(name string) {
|
||||||
vm.mu.Lock()
|
vm.mu.Lock()
|
||||||
defer vm.mu.Unlock()
|
defer vm.mu.Unlock()
|
||||||
|
|||||||
61
server/visitor/visitor_test.go
Normal file
61
server/visitor/visitor_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package visitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
|
||||||
|
vm := NewManager()
|
||||||
|
listener, err := vm.Listen("sudp", "secret", []string{"*"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- vm.NewConn(
|
||||||
|
"sudp",
|
||||||
|
server,
|
||||||
|
now,
|
||||||
|
util.GetAuthKey("secret", now),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"user",
|
||||||
|
wire.ProtocolV2,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
acceptedConn, err := listener.Accept()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer acceptedConn.Close()
|
||||||
|
|
||||||
|
getter, ok := acceptedConn.(interface{ WireProtocol() string })
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, wire.ProtocolV2, getter.WireProtocol())
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -158,7 +160,7 @@ webServer.port = %d
|
|||||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
ginkgo.It("baseline frps rejects current frpc forced to v2", func() {
|
ginkgo.It("baseline frps handles current frpc forced to v2 according to baseline support", func() {
|
||||||
portName := port.GenName("CompatBaselineFRPSForcedV2")
|
portName := port.GenName("CompatBaselineFRPSForcedV2")
|
||||||
clientConf := tcpClientConfig("tcp", portName, `
|
clientConf := tcpClientConfig("tcp", portName, `
|
||||||
transport.wireProtocol = "v2"
|
transport.wireProtocol = "v2"
|
||||||
@@ -170,6 +172,21 @@ transport.wireProtocol = "v2"
|
|||||||
consts.DefaultServerConfig,
|
consts.DefaultServerConfig,
|
||||||
[]string{clientConf},
|
[]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)
|
expectProcessExit(clientProcesses[0], 5*time.Second)
|
||||||
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
|
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
|
||||||
})
|
})
|
||||||
@@ -199,6 +216,39 @@ func expectProcessExit(p *process.Process, timeout time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func baselineSupportsControlWireProtocolV2(version string) (supports bool, known bool) {
|
||||||
|
version = strings.TrimPrefix(version, "v")
|
||||||
|
parts := strings.Split(version, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
major, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
minor, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
patch, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareSemanticVersion(major, minor, patch, 0, 69, 0) >= 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareSemanticVersion(major, minor, patch int, baseMajor, baseMinor, basePatch int) int {
|
||||||
|
if major != baseMajor {
|
||||||
|
return major - baseMajor
|
||||||
|
}
|
||||||
|
if minor != baseMinor {
|
||||||
|
return minor - baseMinor
|
||||||
|
}
|
||||||
|
return patch - basePatch
|
||||||
|
}
|
||||||
|
|
||||||
type wireClientInfo struct {
|
type wireClientInfo struct {
|
||||||
ClientID string `json:"clientID"`
|
ClientID string `json:"clientID"`
|
||||||
WireProtocol string `json:"wireProtocol"`
|
WireProtocol string `json:"wireProtocol"`
|
||||||
|
|||||||
@@ -98,6 +98,73 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
|
|||||||
framework.NewRequestExpect(f).PortName(bindPortName).Ensure()
|
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() {
|
ginkgo.It("reports client wire protocol", func() {
|
||||||
webPort := f.AllocPort()
|
webPort := f.AllocPort()
|
||||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
"github.com/onsi/ginkgo/v2"
|
||||||
|
pp "github.com/pires/go-proxyproto"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"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"
|
||||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
"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/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/cert"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/port"
|
"github.com/fatedier/frp/test/e2e/pkg/port"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||||
@@ -450,4 +456,85 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
|||||||
ExpectResp([]byte("test")).
|
ExpectResp([]byte("test")).
|
||||||
Ensure()
|
Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ginkgo.It("tls2raw with proxy protocol v2", func() {
|
||||||
|
generator := &cert.SelfSignedCertGenerator{}
|
||||||
|
artifacts, err := generator.Generate("example.com")
|
||||||
|
framework.ExpectNoError(err)
|
||||||
|
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
|
||||||
|
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
|
||||||
|
|
||||||
|
serverConf := consts.DefaultServerConfig
|
||||||
|
vhostHTTPSPort := f.AllocPort()
|
||||||
|
serverConf += fmt.Sprintf(`
|
||||||
|
vhostHTTPSPort = %d
|
||||||
|
`, vhostHTTPSPort)
|
||||||
|
|
||||||
|
localPort := f.AllocPort()
|
||||||
|
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||||
|
[[proxies]]
|
||||||
|
name = "tls2raw-proxy-protocol-test"
|
||||||
|
type = "https"
|
||||||
|
customDomains = ["example.com"]
|
||||||
|
transport.proxyProtocolVersion = "v2"
|
||||||
|
[proxies.plugin]
|
||||||
|
type = "tls2raw"
|
||||||
|
localAddr = "127.0.0.1:%d"
|
||||||
|
crtPath = "%s"
|
||||||
|
keyPath = "%s"
|
||||||
|
`, localPort, crtPath, keyPath)
|
||||||
|
|
||||||
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
|
|
||||||
|
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
|
||||||
|
streamserver.WithCustomHandler(func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
writeResp := func(body string) {
|
||||||
|
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rd := bufio.NewReader(c)
|
||||||
|
ppHeader, err := pp.Read(rd)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("read proxy protocol error: %v", err)
|
||||||
|
writeResp("missing proxy protocol")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ppHeader.Version != 2 {
|
||||||
|
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
|
||||||
|
writeResp("unexpected proxy protocol version")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
|
||||||
|
if !ok || srcAddr.IP.String() != "127.0.0.1" {
|
||||||
|
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
|
||||||
|
writeResp("unexpected proxy protocol source address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.ReadRequest(rd)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("read http request after proxy protocol error: %v", err)
|
||||||
|
writeResp("missing http request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, req.Body)
|
||||||
|
_ = req.Body.Close()
|
||||||
|
|
||||||
|
writeResp("test")
|
||||||
|
}))
|
||||||
|
f.RunServer("", localServer)
|
||||||
|
|
||||||
|
framework.NewRequestExpect(f).
|
||||||
|
Port(vhostHTTPSPort).
|
||||||
|
RequestModify(func(r *request.Request) {
|
||||||
|
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
|
||||||
|
ServerName: "example.com",
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
})
|
||||||
|
}).
|
||||||
|
ExpectResp([]byte("test")).
|
||||||
|
Ensure()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
1
web/frps/components.d.ts
vendored
1
web/frps/components.d.ts
vendored
@@ -15,7 +15,6 @@ declare module 'vue' {
|
|||||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { http } from './http'
|
import { buildQueryString, http } from './http'
|
||||||
import type { ClientInfoData } from '../types/client'
|
import type { V2Page } from './http'
|
||||||
|
import type { ClientInfoData, ClientListV2Params } from '../types/client'
|
||||||
|
|
||||||
export const getClients = () => {
|
export const getClients = () => {
|
||||||
return http.get<ClientInfoData[]>('../api/clients')
|
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) => {
|
export const getClient = (key: string) => {
|
||||||
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getClientV2 = (key: string) => {
|
||||||
|
return http.getV2<ClientInfoData>(`../api/v2/clients/${encodeURIComponent(key)}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ class HTTPError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface V2Envelope<T> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface V2Page<T> {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
items: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryParamValue = string | number | boolean | null | undefined
|
||||||
|
|
||||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
const defaultOptions: RequestInit = {
|
const defaultOptions: RequestInit = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -34,9 +49,62 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
return response.json()
|
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 = {
|
export const http = {
|
||||||
get: <T>(url: string, options?: RequestInit) =>
|
get: <T>(url: string, options?: RequestInit) =>
|
||||||
request<T>(url, { ...options, method: 'GET' }),
|
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) =>
|
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||||
request<T>(url, {
|
request<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -1,26 +1,118 @@
|
|||||||
import { http } from './http'
|
import { buildQueryString, http } from './http'
|
||||||
|
import { formatUnixSeconds } from '../utils/format'
|
||||||
|
import type { V2Page } from './http'
|
||||||
import type {
|
import type {
|
||||||
GetProxyResponse,
|
GetProxyResponse,
|
||||||
|
ProxyListV2Params,
|
||||||
ProxyStatsInfo,
|
ProxyStatsInfo,
|
||||||
|
ProxyV2Info,
|
||||||
|
ProxyV2Spec,
|
||||||
|
ProxyV2SpecBlocks,
|
||||||
|
ProxyV2Type,
|
||||||
TrafficResponse,
|
TrafficResponse,
|
||||||
} from '../types/proxy'
|
} from '../types/proxy'
|
||||||
|
|
||||||
|
export interface SystemPruneResponse {
|
||||||
|
type: 'offline_proxies'
|
||||||
|
cleared: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
export const getProxiesByType = (type: string) => {
|
export const getProxiesByType = (type: string) => {
|
||||||
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
||||||
|
const page = await http.getV2<V2Page<ProxyV2Info>>(
|
||||||
|
`../api/v2/proxies${buildQueryString({
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
status:
|
||||||
|
params.status && params.status !== 'all' ? params.status : undefined,
|
||||||
|
q: params.q || undefined,
|
||||||
|
type: params.type || undefined,
|
||||||
|
user: params.user,
|
||||||
|
clientID: params.clientID || undefined,
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
items: page.items.map(toLegacyProxyStats),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActiveProxySpec = (
|
||||||
|
spec: ProxyV2Spec,
|
||||||
|
): ProxyV2SpecBlocks[ProxyV2Type] => {
|
||||||
|
switch (spec.type) {
|
||||||
|
case 'tcp':
|
||||||
|
return spec.tcp
|
||||||
|
case 'udp':
|
||||||
|
return spec.udp
|
||||||
|
case 'http':
|
||||||
|
return spec.http
|
||||||
|
case 'https':
|
||||||
|
return spec.https
|
||||||
|
case 'tcpmux':
|
||||||
|
return spec.tcpmux
|
||||||
|
case 'stcp':
|
||||||
|
return spec.stcp
|
||||||
|
case 'sudp':
|
||||||
|
return spec.sudp
|
||||||
|
case 'xtcp':
|
||||||
|
return spec.xtcp
|
||||||
|
default:
|
||||||
|
return assertNever(spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertNever = (value: never): never => {
|
||||||
|
throw new Error(`Unsupported proxy spec: ${JSON.stringify(value)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => {
|
||||||
|
const type = proxy.spec.type
|
||||||
|
const activeSpec = getActiveProxySpec(proxy.spec)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: proxy.name,
|
||||||
|
type,
|
||||||
|
conf: proxy.status.phase === 'offline' ? null : activeSpec,
|
||||||
|
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) => {
|
export const getProxy = (type: string, name: string) => {
|
||||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
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) => {
|
export const getProxyByName = (name: string) => {
|
||||||
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
|
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProxyTraffic = (name: string) => {
|
export const getProxyTraffic = (name: string) => {
|
||||||
return http.get<TrafficResponse>(`../api/traffic/${name}`)
|
return http.getV2<TrafficResponse>(
|
||||||
|
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clearOfflineProxies = () => {
|
export const clearOfflineProxies = () => {
|
||||||
return http.delete('../api/proxies?status=offline')
|
return http.postV2<SystemPruneResponse>(
|
||||||
|
'../api/v2/system/prune?type=offline_proxies',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { http } from './http'
|
|||||||
import type { ServerInfo } from '../types/server'
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
export const getServerInfo = () => {
|
export const getServerInfo = () => {
|
||||||
return http.get<ServerInfo>('../api/serverinfo')
|
return http.getV2<ServerInfo>('../api/v2/system/info')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { formatFileSize } from '../utils/format'
|
import { formatFileSize } from '../utils/format'
|
||||||
import { getProxyTraffic } from '../api/proxy'
|
import { getProxyTraffic } from '../api/proxy'
|
||||||
|
import type { TrafficResponse } from '../types/proxy'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
proxyName: string
|
proxyName: string
|
||||||
@@ -71,41 +72,24 @@ const chartData = ref<
|
|||||||
>([])
|
>([])
|
||||||
const maxVal = ref(0)
|
const maxVal = ref(0)
|
||||||
|
|
||||||
const processData = (trafficIn: number[], trafficOut: number[]) => {
|
const formatDateLabel = (date: string) => {
|
||||||
// Ensure we have arrays and reverse them (server returns newest first)
|
const parts = date.split('-')
|
||||||
const inArr = [...(trafficIn || [])].reverse()
|
if (parts.length !== 3) return date
|
||||||
const outArr = [...(trafficOut || [])].reverse()
|
return `${Number(parts[1])}-${Number(parts[2])}`
|
||||||
|
}
|
||||||
|
|
||||||
// Pad with zeros if less than 7 days
|
const processData = (history: TrafficResponse['history'] = []) => {
|
||||||
while (inArr.length < 7) inArr.unshift(0)
|
const points = history || []
|
||||||
while (outArr.length < 7) outArr.unshift(0)
|
const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
|
||||||
|
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
|
||||||
// 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
|
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
|
||||||
|
|
||||||
// Build chart data
|
chartData.value = points.map((item) => ({
|
||||||
chartData.value = dates.map((date, i) => ({
|
date: formatDateLabel(item.date),
|
||||||
date,
|
in: item.trafficIn,
|
||||||
in: finalIn[i],
|
out: item.trafficOut,
|
||||||
out: finalOut[i],
|
inPercent: (item.trafficIn / maxVal.value) * 100,
|
||||||
inPercent: (finalIn[i] / maxVal.value) * 100,
|
outPercent: (item.trafficOut / maxVal.value) * 100,
|
||||||
outPercent: (finalOut[i] / maxVal.value) * 100,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +97,7 @@ const fetchData = () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
getProxyTraffic(props.proxyName)
|
getProxyTraffic(props.proxyName)
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
processData(json.trafficIn, json.trafficOut)
|
processData(json.history)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
|
|||||||
@@ -7,9 +7,25 @@ export interface ClientInfoData {
|
|||||||
wireProtocol?: string
|
wireProtocol?: string
|
||||||
hostname: string
|
hostname: string
|
||||||
clientIP?: string
|
clientIP?: string
|
||||||
metas?: Record<string, string>
|
|
||||||
firstConnectedAt: number
|
firstConnectedAt: number
|
||||||
lastConnectedAt: number
|
lastConnectedAt: number
|
||||||
disconnectedAt?: number
|
disconnectedAt?: number
|
||||||
online: boolean
|
online: boolean
|
||||||
|
status?: ClientStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientStatus {
|
||||||
|
phase: 'online' | 'offline'
|
||||||
|
curConns: number
|
||||||
|
proxyCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientListV2Params {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: 'all' | 'online' | 'offline'
|
||||||
|
q?: string
|
||||||
|
user?: string
|
||||||
|
clientID?: string
|
||||||
|
runID?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface ProxyStatsInfo {
|
export interface ProxyStatsInfo {
|
||||||
name: string
|
name: string
|
||||||
|
type?: string
|
||||||
conf: any
|
conf: any
|
||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
@@ -15,8 +16,114 @@ export interface GetProxyResponse {
|
|||||||
proxies: ProxyStatsInfo[]
|
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
|
||||||
|
user: string
|
||||||
|
clientID: string
|
||||||
|
spec: ProxyV2Spec
|
||||||
|
status: ProxyV2Status
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2BaseSpec {
|
||||||
|
annotations?: Record<string, string>
|
||||||
|
metadatas?: Record<string, string>
|
||||||
|
transport?: {
|
||||||
|
useEncryption: boolean
|
||||||
|
useCompression: boolean
|
||||||
|
bandwidthLimit: string
|
||||||
|
bandwidthLimitMode: string
|
||||||
|
}
|
||||||
|
loadBalancer?: {
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2TCPBlock extends ProxyV2BaseSpec {
|
||||||
|
remotePort?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2UDPBlock extends ProxyV2BaseSpec {
|
||||||
|
remotePort?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2HTTPBlock extends ProxyV2BaseSpec {
|
||||||
|
customDomains?: string[]
|
||||||
|
subdomain?: string
|
||||||
|
locations?: string[]
|
||||||
|
hostHeaderRewrite?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2HTTPSBlock extends ProxyV2BaseSpec {
|
||||||
|
customDomains?: string[]
|
||||||
|
subdomain?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2TCPMuxBlock extends ProxyV2BaseSpec {
|
||||||
|
customDomains?: string[]
|
||||||
|
subdomain?: string
|
||||||
|
multiplexer?: string
|
||||||
|
routeByHTTPUser?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyV2STCPBlock = ProxyV2BaseSpec
|
||||||
|
|
||||||
|
export type ProxyV2SUDPBlock = ProxyV2BaseSpec
|
||||||
|
|
||||||
|
export type ProxyV2XTCPBlock = ProxyV2BaseSpec
|
||||||
|
|
||||||
|
export interface ProxyV2SpecBlocks {
|
||||||
|
tcp: ProxyV2TCPBlock
|
||||||
|
udp: ProxyV2UDPBlock
|
||||||
|
http: ProxyV2HTTPBlock
|
||||||
|
https: ProxyV2HTTPSBlock
|
||||||
|
tcpmux: ProxyV2TCPMuxBlock
|
||||||
|
stcp: ProxyV2STCPBlock
|
||||||
|
sudp: ProxyV2SUDPBlock
|
||||||
|
xtcp: ProxyV2XTCPBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyV2Type = keyof ProxyV2SpecBlocks
|
||||||
|
|
||||||
|
type ProxyV2SpecFor<T extends ProxyV2Type> = {
|
||||||
|
type: T
|
||||||
|
} & {
|
||||||
|
[K in T]: ProxyV2SpecBlocks[K]
|
||||||
|
} & {
|
||||||
|
[K in Exclude<ProxyV2Type, T>]?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyV2Spec = {
|
||||||
|
[T in ProxyV2Type]: ProxyV2SpecFor<T>
|
||||||
|
}[ProxyV2Type]
|
||||||
|
|
||||||
|
export interface ProxyV2Status {
|
||||||
|
phase: 'online' | 'offline'
|
||||||
|
todayTrafficIn: number
|
||||||
|
todayTrafficOut: number
|
||||||
|
curConns: number
|
||||||
|
lastStartAt?: number
|
||||||
|
lastCloseAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrafficResponse {
|
export interface TrafficResponse {
|
||||||
name: string
|
name: string
|
||||||
trafficIn: number[]
|
unit: 'bytes'
|
||||||
trafficOut: number[]
|
granularity: 'day'
|
||||||
|
history: TrafficPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficPoint {
|
||||||
|
date: string
|
||||||
|
trafficIn: number
|
||||||
|
trafficOut: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
version: string
|
version: string
|
||||||
|
config: ServerInfoConfig
|
||||||
|
status: ServerInfoStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerInfoConfig {
|
||||||
bindPort: number
|
bindPort: number
|
||||||
vhostHTTPPort: number
|
vhostHTTPPort: number
|
||||||
vhostHTTPSPort: number
|
vhostHTTPSPort: number
|
||||||
@@ -12,8 +17,9 @@ export interface ServerInfo {
|
|||||||
heartbeatTimeout: number
|
heartbeatTimeout: number
|
||||||
allowPortsStr: string
|
allowPortsStr: string
|
||||||
tlsForce: boolean
|
tlsForce: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// Stats
|
export interface ServerInfoStatus {
|
||||||
totalTrafficIn: number
|
totalTrafficIn: number
|
||||||
totalTrafficOut: number
|
totalTrafficOut: number
|
||||||
curConns: number
|
curConns: number
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { formatDistanceToNow } from './format'
|
import { formatDistanceToNow } from './format'
|
||||||
import type { ClientInfoData } from '../types/client'
|
import type { ClientInfoData, ClientStatus } from '../types/client'
|
||||||
|
|
||||||
export class Client {
|
export class Client {
|
||||||
key: string
|
key: string
|
||||||
@@ -10,11 +10,11 @@ export class Client {
|
|||||||
wireProtocol: string
|
wireProtocol: string
|
||||||
hostname: string
|
hostname: string
|
||||||
ip: string
|
ip: string
|
||||||
metas: Map<string, string>
|
|
||||||
firstConnectedAt: Date
|
firstConnectedAt: Date
|
||||||
lastConnectedAt: Date
|
lastConnectedAt: Date
|
||||||
disconnectedAt?: Date
|
disconnectedAt?: Date
|
||||||
online: boolean
|
online: boolean
|
||||||
|
status: ClientStatus
|
||||||
|
|
||||||
constructor(data: ClientInfoData) {
|
constructor(data: ClientInfoData) {
|
||||||
this.key = data.key
|
this.key = data.key
|
||||||
@@ -25,18 +25,17 @@ export class Client {
|
|||||||
this.wireProtocol = data.wireProtocol || ''
|
this.wireProtocol = data.wireProtocol || ''
|
||||||
this.hostname = data.hostname
|
this.hostname = data.hostname
|
||||||
this.ip = data.clientIP || ''
|
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.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
|
||||||
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
|
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
|
||||||
if (data.disconnectedAt && data.disconnectedAt > 0) {
|
if (data.disconnectedAt && data.disconnectedAt > 0) {
|
||||||
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
|
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
|
||||||
}
|
}
|
||||||
this.online = data.online
|
this.online = data.online
|
||||||
|
this.status = data.status || {
|
||||||
|
phase: this.online ? 'online' : 'offline',
|
||||||
|
curConns: 0,
|
||||||
|
proxyCount: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get displayName(): string {
|
get displayName(): string {
|
||||||
@@ -46,10 +45,6 @@ export class Client {
|
|||||||
return this.runID
|
return this.runID
|
||||||
}
|
}
|
||||||
|
|
||||||
get shortRunId(): string {
|
|
||||||
return this.runID.substring(0, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
get wireProtocolLabel(): string {
|
get wireProtocolLabel(): string {
|
||||||
if (!this.wireProtocol) return ''
|
if (!this.wireProtocol) return ''
|
||||||
return `Protocol ${this.wireProtocol}`
|
return `Protocol ${this.wireProtocol}`
|
||||||
@@ -67,28 +62,4 @@ export class Client {
|
|||||||
if (!this.disconnectedAt) return ''
|
if (!this.disconnectedAt) return ''
|
||||||
return formatDistanceToNow(this.disconnectedAt)
|
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,6 +19,14 @@ export function formatDistanceToNow(date: Date): string {
|
|||||||
return Math.floor(seconds) + ' seconds ago'
|
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 {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
||||||
if (bytes === 0) return '0 B'
|
if (bytes === 0) return '0 B'
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">Connections</span>
|
<span class="info-label">Connections</span>
|
||||||
<span class="info-value">{{ totalConnections }}</span>
|
<span class="info-value">{{ client.status.curConns }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">Run ID</span>
|
<span class="info-label">Run ID</span>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<div class="proxies-header">
|
<div class="proxies-header">
|
||||||
<div class="proxies-title">
|
<div class="proxies-title">
|
||||||
<h2>Proxies</h2>
|
<h2>Proxies</h2>
|
||||||
<span class="proxies-count">{{ filteredProxies.length }}</span>
|
<span class="proxies-count">{{ total }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="proxySearch"
|
v-model="proxySearch"
|
||||||
@@ -100,21 +100,32 @@
|
|||||||
<el-icon class="is-loading"><Loading /></el-icon>
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
<span>Loading...</span>
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
|
<div v-else-if="proxies.length > 0" class="proxies-list">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
v-for="proxy in filteredProxies"
|
v-for="proxy in proxies"
|
||||||
:key="proxy.name"
|
:key="`${proxy.type}:${proxy.name}`"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
show-type
|
show-type
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="clientProxies.length > 0" class="empty-state">
|
<div v-else-if="proxySearch.trim()" class="empty-state">
|
||||||
<p>No proxies match "{{ proxySearch }}"</p>
|
<p>No proxies match "{{ proxySearch }}"</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<p>No proxies found</p>
|
<p>No proxies found</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -130,13 +141,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElPagination } from 'element-plus'
|
||||||
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
||||||
import { Client } from '../utils/client'
|
import { Client } from '../utils/client'
|
||||||
import { getClient } from '../api/client'
|
import { getClientV2 } from '../api/client'
|
||||||
import { getProxiesByType } from '../api/proxy'
|
import { getProxiesV2 } from '../api/proxy'
|
||||||
import {
|
import {
|
||||||
BaseProxy,
|
BaseProxy,
|
||||||
TCPProxy,
|
TCPProxy,
|
||||||
@@ -149,6 +160,8 @@ import {
|
|||||||
} from '../utils/proxy'
|
} from '../utils/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import ProxyCard from '../components/ProxyCard.vue'
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
|
import type { ProxyStatsInfo } from '../types/proxy'
|
||||||
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -163,119 +176,191 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const proxiesLoading = ref(false)
|
const proxiesLoading = ref(false)
|
||||||
const allProxies = ref<BaseProxy[]>([])
|
const proxies = ref<BaseProxy[]>([])
|
||||||
const proxySearch = ref('')
|
const proxySearch = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
let requestSeq = 0
|
||||||
|
let searchDebounceTimer: number | null = null
|
||||||
|
|
||||||
let serverInfo: {
|
let serverInfoPromise: Promise<ServerInfo> | null = null
|
||||||
vhostHTTPPort: number
|
|
||||||
vhostHTTPSPort: number
|
|
||||||
tcpmuxHTTPConnectPort: number
|
|
||||||
subdomainHost: string
|
|
||||||
} | null = null
|
|
||||||
|
|
||||||
const clientProxies = computed(() => {
|
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||||
if (!client.value) return []
|
if (!serverInfoPromise) {
|
||||||
return allProxies.value.filter(
|
serverInfoPromise = getServerInfo().catch((err) => {
|
||||||
(p) =>
|
serverInfoPromise = null
|
||||||
p.clientID === client.value!.clientID && p.user === client.value!.user,
|
throw err
|
||||||
)
|
})
|
||||||
})
|
}
|
||||||
|
return serverInfoPromise
|
||||||
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 () => {
|
const fetchClient = async (): Promise<boolean> => {
|
||||||
const key = route.params.key as string
|
const key = route.params.key as string
|
||||||
if (!key) {
|
if (!key) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await getClient(key)
|
const data = await getClientV2(key)
|
||||||
client.value = new Client(data)
|
client.value = new Client(data)
|
||||||
|
return true
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error('Failed to fetch client: ' + error.message)
|
ElMessage.error('Failed to fetch client: ' + error.message)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchProxies = async () => {
|
const convertProxy = async (
|
||||||
proxiesLoading.value = true
|
proxy: ProxyStatsInfo,
|
||||||
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
|
): Promise<BaseProxy | null> => {
|
||||||
const proxies: BaseProxy[] = []
|
const type = proxy.type || ''
|
||||||
try {
|
if (type === 'tcp') {
|
||||||
|
return new TCPProxy(proxy)
|
||||||
|
}
|
||||||
|
if (type === 'udp') {
|
||||||
|
return new UDPProxy(proxy)
|
||||||
|
}
|
||||||
|
if (type === 'http') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
for (const type of proxyTypes) {
|
if (info && info.config.vhostHTTPPort) {
|
||||||
try {
|
return new HTTPProxy(
|
||||||
const json = await getProxiesByType(type)
|
proxy,
|
||||||
if (!json.proxies) continue
|
info.config.vhostHTTPPort,
|
||||||
if (type === 'tcp') {
|
info.config.subdomainHost,
|
||||||
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
|
)
|
||||||
} else if (type === 'udp') {
|
|
||||||
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
|
|
||||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
|
||||||
proxies.push(
|
|
||||||
...json.proxies.map(
|
|
||||||
(p: any) =>
|
|
||||||
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
|
||||||
proxies.push(
|
|
||||||
...json.proxies.map(
|
|
||||||
(p: any) =>
|
|
||||||
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
|
||||||
proxies.push(
|
|
||||||
...json.proxies.map(
|
|
||||||
(p: any) =>
|
|
||||||
new TCPMuxProxy(
|
|
||||||
p,
|
|
||||||
info.tcpmuxHTTPConnectPort,
|
|
||||||
info.subdomainHost,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if (type === 'stcp') {
|
|
||||||
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
|
|
||||||
} else if (type === 'sudp') {
|
|
||||||
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
allProxies.value = proxies
|
return null
|
||||||
} catch {
|
}
|
||||||
// Ignore
|
if (type === 'https') {
|
||||||
|
const info = await fetchServerInfo()
|
||||||
|
if (info && info.config.vhostHTTPSPort) {
|
||||||
|
return new HTTPSProxy(
|
||||||
|
proxy,
|
||||||
|
info.config.vhostHTTPSPort,
|
||||||
|
info.config.subdomainHost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (type === 'tcpmux') {
|
||||||
|
const info = await fetchServerInfo()
|
||||||
|
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||||
|
return new TCPMuxProxy(
|
||||||
|
proxy,
|
||||||
|
info.config.tcpmuxHTTPConnectPort,
|
||||||
|
info.config.subdomainHost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (type === 'stcp') {
|
||||||
|
return new STCPProxy(proxy)
|
||||||
|
}
|
||||||
|
if (type === 'sudp') {
|
||||||
|
return new SUDPProxy(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bp = new BaseProxy(proxy)
|
||||||
|
bp.type = type
|
||||||
|
return bp
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
||||||
|
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
||||||
|
return converted.filter((item): item is BaseProxy => item !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchProxies = async () => {
|
||||||
|
if (!client.value) return
|
||||||
|
const seq = ++requestSeq
|
||||||
|
proxiesLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const q = proxySearch.value.trim()
|
||||||
|
const data = await getProxiesV2({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
q: q || undefined,
|
||||||
|
clientID: client.value.clientID,
|
||||||
|
user: client.value.user,
|
||||||
|
})
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
|
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
||||||
|
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
||||||
|
page.value = maxPage
|
||||||
|
await fetchProxies()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const converted = await convertProxies(data.items)
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
|
proxies.value = converted
|
||||||
|
total.value = data.total
|
||||||
|
page.value = data.page
|
||||||
|
pageSize.value = data.pageSize
|
||||||
|
} catch (error: any) {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
ElMessage.error('Failed to fetch proxies: ' + error.message)
|
||||||
} finally {
|
} finally {
|
||||||
proxiesLoading.value = false
|
if (seq === requestSeq) {
|
||||||
|
proxiesLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const clearSearchDebounce = () => {
|
||||||
fetchClient()
|
if (searchDebounceTimer !== null) {
|
||||||
|
window.clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidateProxyRequests = () => {
|
||||||
|
requestSeq++
|
||||||
|
proxiesLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPageAndFetch = () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
fetchProxies()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (value: number) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = value
|
||||||
|
fetchProxies()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageSizeChange = (value: number) => {
|
||||||
|
pageSize.value = value
|
||||||
|
resetPageAndFetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(proxySearch, () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
invalidateProxyRequests()
|
||||||
|
page.value = 1
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
searchDebounceTimer = null
|
||||||
|
fetchProxies()
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const ok = await fetchClient()
|
||||||
|
if (!ok || !client.value) return
|
||||||
|
|
||||||
fetchProxies()
|
fetchProxies()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -483,6 +568,12 @@ html.dark .status-badge.online {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.proxies-list {
|
.proxies-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
>
|
>
|
||||||
<span class="status-dot" :class="tab.value"></span>
|
<span class="status-dot" :class="tab.value"></span>
|
||||||
<span class="tab-label">{{ tab.label }}</span>
|
<span class="tab-label">{{ tab.label }}</span>
|
||||||
<span class="tab-count">{{ tab.count }}</span>
|
<span v-if="tab.count !== null" class="tab-count">{{
|
||||||
|
tab.count
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,9 +35,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="clients-content">
|
<div v-loading="loading" class="clients-content">
|
||||||
<div v-if="filteredClients.length > 0" class="clients-list">
|
<div v-if="clients.length > 0" class="clients-list">
|
||||||
<ClientCard
|
<ClientCard
|
||||||
v-for="client in filteredClients"
|
v-for="client in clients"
|
||||||
:key="client.key"
|
:key="client.key"
|
||||||
:client="client"
|
:client="client"
|
||||||
/>
|
/>
|
||||||
@@ -44,82 +46,123 @@
|
|||||||
<el-empty description="No clients found" />
|
<el-empty description="No clients found" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElPagination } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { Client } from '../utils/client'
|
import { Client } from '../utils/client'
|
||||||
import ClientCard from '../components/ClientCard.vue'
|
import ClientCard from '../components/ClientCard.vue'
|
||||||
import { getClients } from '../api/client'
|
import { getClientsV2 } from '../api/client'
|
||||||
|
|
||||||
const clients = ref<Client[]>([])
|
const clients = ref<Client[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
|
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 refreshTimer: number | null = null
|
||||||
|
let searchDebounceTimer: number | null = null
|
||||||
const stats = computed(() => {
|
let requestSeq = 0
|
||||||
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(() => [
|
const statusTabs = computed(() => [
|
||||||
{ value: 'all' as const, label: 'All', count: stats.value.total },
|
{
|
||||||
{ value: 'online' as const, label: 'Online', count: stats.value.online },
|
value: 'all' as const,
|
||||||
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
|
label: 'All',
|
||||||
|
count: statusFilter.value === 'all' ? total.value : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'online' as const,
|
||||||
|
label: 'Online',
|
||||||
|
count: statusFilter.value === 'online' ? total.value : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'offline' as const,
|
||||||
|
label: 'Offline',
|
||||||
|
count: statusFilter.value === 'offline' ? total.value : null,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const filteredClients = computed(() => {
|
const fetchData = async (silent = false) => {
|
||||||
let result = clients.value
|
const seq = ++requestSeq
|
||||||
|
if (!silent) loading.value = true
|
||||||
// Filter by status
|
|
||||||
if (statusFilter.value === 'online') {
|
|
||||||
result = result.filter((c) => c.online)
|
|
||||||
} else if (statusFilter.value === 'offline') {
|
|
||||||
result = result.filter((c) => !c.online)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search text
|
|
||||||
if (searchText.value) {
|
|
||||||
result = result.filter((c) => c.matchesFilter(searchText.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: online first, then by display name
|
|
||||||
result.sort((a, b) => {
|
|
||||||
if (a.online !== b.online) {
|
|
||||||
return a.online ? -1 : 1
|
|
||||||
}
|
|
||||||
return a.displayName.localeCompare(b.displayName)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const json = await getClients()
|
const data = await getClientsV2({
|
||||||
clients.value = json.map((data) => new Client(data))
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
status: statusFilter.value,
|
||||||
|
q: searchText.value.trim(),
|
||||||
|
})
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
|
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
||||||
|
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
||||||
|
page.value = maxPage
|
||||||
|
await fetchData(silent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.value = data.items.map((item) => new Client(item))
|
||||||
|
total.value = data.total
|
||||||
|
page.value = data.page
|
||||||
|
pageSize.value = data.pageSize
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
message: 'Failed to fetch clients: ' + error.message,
|
message: 'Failed to fetch clients: ' + error.message,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === requestSeq) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSearchDebounce = () => {
|
||||||
|
if (searchDebounceTimer !== null) {
|
||||||
|
window.clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPageAndFetch = () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (value: number) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = value
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageSizeChange = (value: number) => {
|
||||||
|
pageSize.value = value
|
||||||
|
resetPageAndFetch()
|
||||||
|
}
|
||||||
|
|
||||||
const startAutoRefresh = () => {
|
const startAutoRefresh = () => {
|
||||||
refreshTimer = window.setInterval(() => {
|
refreshTimer = window.setInterval(() => {
|
||||||
fetchData()
|
fetchData(true)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +173,19 @@ const stopAutoRefresh = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(statusFilter, () => {
|
||||||
|
resetPageAndFetch()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(searchText, () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
searchDebounceTimer = null
|
||||||
|
fetchData()
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
@@ -137,6 +193,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
|
clearSearchDebounce()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -274,6 +331,11 @@ onUnmounted(() => {
|
|||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode adjustments */
|
/* Dark mode adjustments */
|
||||||
html.dark .status-tab {
|
html.dark .status-tab {
|
||||||
background: var(--el-bg-color-overlay);
|
background: var(--el-bg-color-overlay);
|
||||||
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
|
|||||||
.status-tab {
|
.status-tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions-section">
|
<div class="actions-section">
|
||||||
<ActionButton variant="outline" size="small" @click="fetchData">
|
<ActionButton variant="outline" size="small" @click="refreshData">
|
||||||
Refresh
|
Refresh
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
@@ -27,36 +27,6 @@
|
|||||||
clearable
|
clearable
|
||||||
class="main-search"
|
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>
|
||||||
|
|
||||||
<div class="type-tabs">
|
<div class="type-tabs">
|
||||||
@@ -74,9 +44,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="proxies-content">
|
<div v-loading="loading" class="proxies-content">
|
||||||
<div v-if="filteredProxies.length > 0" class="proxies-list">
|
<div v-if="proxies.length > 0" class="proxies-list">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
v-for="proxy in filteredProxies"
|
v-for="proxy in proxies"
|
||||||
:key="`${proxy.type}:${proxy.name}`"
|
:key="`${proxy.type}:${proxy.name}`"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
:show-type="activeType === 'all'"
|
:show-type="activeType === 'all'"
|
||||||
@@ -87,6 +57,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<ConfirmDialog
|
||||||
v-model="showClearDialog"
|
v-model="showClearDialog"
|
||||||
title="Clear Offline"
|
title="Clear Offline"
|
||||||
@@ -99,9 +81,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElPagination } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import ActionButton from '@shared/components/ActionButton.vue'
|
import ActionButton from '@shared/components/ActionButton.vue'
|
||||||
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
||||||
@@ -116,15 +98,13 @@ import {
|
|||||||
SUDPProxy,
|
SUDPProxy,
|
||||||
} from '../utils/proxy'
|
} from '../utils/proxy'
|
||||||
import ProxyCard from '../components/ProxyCard.vue'
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
|
||||||
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
|
||||||
import {
|
import {
|
||||||
getProxiesByType,
|
getProxiesV2,
|
||||||
clearOfflineProxies as apiClearOfflineProxies,
|
clearOfflineProxies as apiClearOfflineProxies,
|
||||||
} from '../api/proxy'
|
} from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import { getClients } from '../api/client'
|
import type { ProxyStatsInfo } from '../types/proxy'
|
||||||
import { Client } from '../utils/client'
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -141,244 +121,163 @@ const proxyTypes = [
|
|||||||
{ label: 'SUDP', value: 'sudp' },
|
{ label: 'SUDP', value: 'sudp' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeType = ref((route.params.type as string) || 'tcp')
|
const activeType = ref((route.params.type as string) || 'all')
|
||||||
const proxies = ref<BaseProxy[]>([])
|
const proxies = ref<BaseProxy[]>([])
|
||||||
const clients = ref<Client[]>([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const showClearDialog = ref(false)
|
const showClearDialog = ref(false)
|
||||||
const clientIDFilter = ref((route.query.clientID as string) || '')
|
const page = ref(1)
|
||||||
const userFilter = ref((route.query.user as string) || '')
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
let requestSeq = 0
|
||||||
|
let searchDebounceTimer: number | null = null
|
||||||
|
|
||||||
const clientOptions = computed(() => {
|
// Server info cache - cache the Promise itself so concurrent first calls
|
||||||
return clients.value
|
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||||
.map((c) => ({
|
let serverInfoPromise: Promise<ServerInfo> | null = null
|
||||||
key: c.key,
|
|
||||||
clientID: c.clientID,
|
|
||||||
user: c.user,
|
|
||||||
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Compute selected client key for el-select v-model
|
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||||
const selectedClientKey = computed(() => {
|
if (!serverInfoPromise) {
|
||||||
if (!clientIDFilter.value) return ''
|
serverInfoPromise = getServerInfo().catch((err) => {
|
||||||
const client = clientOptions.value.find(
|
// Allow retry after failure
|
||||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
serverInfoPromise = null
|
||||||
)
|
throw err
|
||||||
// Return a synthetic key even if not found, so the select shows the filter is active
|
|
||||||
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedClientLabel = computed(() => {
|
|
||||||
if (!clientIDFilter.value) return 'All Clients'
|
|
||||||
const client = clientOptions.value.find(
|
|
||||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
|
||||||
)
|
|
||||||
return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredClientOptions = (filterText: string) => {
|
|
||||||
if (!filterText) return clientOptions.value
|
|
||||||
const search = filterText.toLowerCase()
|
|
||||||
return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the filtered client exists in the client list
|
|
||||||
const selectedClientInList = computed(() => {
|
|
||||||
if (!clientIDFilter.value) return true
|
|
||||||
return clientOptions.value.some(
|
|
||||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredProxies = computed(() => {
|
|
||||||
let result = proxies.value
|
|
||||||
|
|
||||||
// Filter by clientID and user if specified
|
|
||||||
if (clientIDFilter.value) {
|
|
||||||
result = result.filter(
|
|
||||||
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search text across multiple fields
|
|
||||||
if (searchText.value) {
|
|
||||||
const search = searchText.value.toLowerCase()
|
|
||||||
result = result.filter((p) => {
|
|
||||||
const fields: unknown[] = [
|
|
||||||
p.name,
|
|
||||||
p.type,
|
|
||||||
p.clientID,
|
|
||||||
p.user,
|
|
||||||
p.addr,
|
|
||||||
p.port,
|
|
||||||
p.customDomains,
|
|
||||||
p.subdomain,
|
|
||||||
]
|
|
||||||
return fields.some((v) => matchesSearch(v, search))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return serverInfoPromise
|
||||||
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) => {
|
const convertProxy = async (
|
||||||
if (key) {
|
proxy: ProxyStatsInfo,
|
||||||
const client = clientOptions.value.find((c) => c.key === key)
|
): Promise<BaseProxy | null> => {
|
||||||
if (client) {
|
const type = proxy.type || activeType.value
|
||||||
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') {
|
if (type === 'tcp') {
|
||||||
return json.proxies.map((p: any) => new TCPProxy(p))
|
return new TCPProxy(proxy)
|
||||||
}
|
}
|
||||||
if (type === 'udp') {
|
if (type === 'udp') {
|
||||||
return json.proxies.map((p: any) => new UDPProxy(p))
|
return new UDPProxy(proxy)
|
||||||
}
|
}
|
||||||
if (type === 'http') {
|
if (type === 'http') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPPort) {
|
if (info && info.config.vhostHTTPPort) {
|
||||||
return json.proxies.map(
|
return new HTTPProxy(
|
||||||
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
proxy,
|
||||||
|
info.config.vhostHTTPPort,
|
||||||
|
info.config.subdomainHost,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'https') {
|
if (type === 'https') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPSPort) {
|
if (info && info.config.vhostHTTPSPort) {
|
||||||
return json.proxies.map(
|
return new HTTPSProxy(
|
||||||
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
proxy,
|
||||||
|
info.config.vhostHTTPSPort,
|
||||||
|
info.config.subdomainHost,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'tcpmux') {
|
if (type === 'tcpmux') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.tcpmuxHTTPConnectPort) {
|
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||||
return json.proxies.map(
|
return new TCPMuxProxy(
|
||||||
(p: any) =>
|
proxy,
|
||||||
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
|
info.config.tcpmuxHTTPConnectPort,
|
||||||
|
info.config.subdomainHost,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'stcp') {
|
if (type === 'stcp') {
|
||||||
return json.proxies.map((p: any) => new STCPProxy(p))
|
return new STCPProxy(proxy)
|
||||||
}
|
}
|
||||||
if (type === 'sudp') {
|
if (type === 'sudp') {
|
||||||
return json.proxies.map((p: any) => new SUDPProxy(p))
|
return new SUDPProxy(proxy)
|
||||||
}
|
}
|
||||||
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
||||||
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
||||||
return json.proxies.map((p: any) => {
|
const bp = new BaseProxy(proxy)
|
||||||
const bp = new BaseProxy(p)
|
bp.type = type
|
||||||
bp.type = type
|
return bp
|
||||||
return bp
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allProxyTypes = [
|
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
||||||
'tcp',
|
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
||||||
'udp',
|
return converted.filter((item): item is BaseProxy => item !== null)
|
||||||
'http',
|
}
|
||||||
'https',
|
|
||||||
'tcpmux',
|
|
||||||
'stcp',
|
|
||||||
'xtcp',
|
|
||||||
'sudp',
|
|
||||||
]
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async (silent = false) => {
|
||||||
loading.value = true
|
const seq = ++requestSeq
|
||||||
proxies.value = []
|
if (!silent) loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const type = activeType.value
|
const q = searchText.value.trim()
|
||||||
|
const data = await getProxiesV2({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
type: activeType.value === 'all' ? undefined : activeType.value,
|
||||||
|
q: q || undefined,
|
||||||
|
})
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
if (type === 'all') {
|
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
||||||
const results = await Promise.all(
|
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
||||||
allProxyTypes.map(async (t) => {
|
page.value = maxPage
|
||||||
const json = await getProxiesByType(t)
|
await fetchData(silent)
|
||||||
return convertProxies(t, json)
|
return
|
||||||
}),
|
|
||||||
)
|
|
||||||
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) {
|
} catch (error: any) {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
message: 'Failed to fetch proxies: ' + error.message,
|
message: 'Failed to fetch proxies: ' + error.message,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === requestSeq) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSearchDebounce = () => {
|
||||||
|
if (searchDebounceTimer !== null) {
|
||||||
|
window.clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPageAndFetch = () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshData = () => {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (value: number) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = value
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageSizeChange = (value: number) => {
|
||||||
|
pageSize.value = value
|
||||||
|
resetPageAndFetch()
|
||||||
|
}
|
||||||
|
|
||||||
const handleClearConfirm = async () => {
|
const handleClearConfirm = async () => {
|
||||||
showClearDialog.value = false
|
showClearDialog.value = false
|
||||||
await clearOfflineProxies()
|
await clearOfflineProxies()
|
||||||
@@ -400,25 +299,45 @@ const clearOfflineProxies = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeClientQuery = () => {
|
||||||
|
const hasClientQuery =
|
||||||
|
Object.prototype.hasOwnProperty.call(route.query, 'clientID') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(route.query, 'user')
|
||||||
|
if (!hasClientQuery) return
|
||||||
|
|
||||||
|
const query = { ...route.query }
|
||||||
|
delete query.clientID
|
||||||
|
delete query.user
|
||||||
|
router.replace({ query })
|
||||||
|
}
|
||||||
|
|
||||||
// Watch for type changes
|
// Watch for type changes
|
||||||
watch(activeType, (newType) => {
|
watch(activeType, (newType) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
// Update route but preserve query params
|
// Update route but preserve query params
|
||||||
router.replace({ params: { type: newType }, query: route.query })
|
router.replace({ params: { type: newType }, query: route.query })
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for route query changes (client filter)
|
watch(searchText, () => {
|
||||||
watch(
|
clearSearchDebounce()
|
||||||
() => [route.query.clientID, route.query.user],
|
page.value = 1
|
||||||
([newClientID, newUser]) => {
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
clientIDFilter.value = (newClientID as string) || ''
|
searchDebounceTimer = null
|
||||||
userFilter.value = (newUser as string) || ''
|
fetchData()
|
||||||
},
|
}, 300)
|
||||||
)
|
})
|
||||||
|
|
||||||
|
watch(() => route.query, sanitizeClientQuery)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
})
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
|
sanitizeClientQuery()
|
||||||
fetchData()
|
fetchData()
|
||||||
fetchClients()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -485,16 +404,11 @@ fetchClients()
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-search :deep(.el-input__wrapper),
|
.main-search :deep(.el-input__wrapper) {
|
||||||
.client-filter :deep(.el-input__wrapper) {
|
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-filter {
|
|
||||||
width: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tabs {
|
.type-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -539,13 +453,18 @@ fetchClients()
|
|||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.search-row {
|
.search-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-filter {
|
.pagination-section {
|
||||||
width: 100%;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ import {
|
|||||||
Tickets,
|
Tickets,
|
||||||
Location,
|
Location,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { getProxyByName } from '../api/proxy'
|
import { getProxyByNameV2 } from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import {
|
import {
|
||||||
BaseProxy,
|
BaseProxy,
|
||||||
@@ -254,6 +254,7 @@ import {
|
|||||||
SUDPProxy,
|
SUDPProxy,
|
||||||
} from '../utils/proxy'
|
} from '../utils/proxy'
|
||||||
import Traffic from '../components/Traffic.vue'
|
import Traffic from '../components/Traffic.vue'
|
||||||
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -275,12 +276,7 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let serverInfo: {
|
let serverInfo: ServerInfo | null = null
|
||||||
vhostHTTPPort: number
|
|
||||||
vhostHTTPSPort: number
|
|
||||||
tcpmuxHTTPConnectPort: number
|
|
||||||
subdomainHost: string
|
|
||||||
} | null = null
|
|
||||||
|
|
||||||
const clientLink = computed(() => {
|
const clientLink = computed(() => {
|
||||||
if (!proxy.value) return ''
|
if (!proxy.value) return ''
|
||||||
@@ -365,27 +361,32 @@ const fetchProxy = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getProxyByName(name)
|
const data = await getProxyByNameV2(name)
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
const type = data.conf?.type || ''
|
const config = info.config
|
||||||
|
const type = data.type || data.conf?.type || ''
|
||||||
|
|
||||||
if (type === 'tcp') {
|
if (type === 'tcp') {
|
||||||
proxy.value = new TCPProxy(data)
|
proxy.value = new TCPProxy(data)
|
||||||
} else if (type === 'udp') {
|
} else if (type === 'udp') {
|
||||||
proxy.value = new UDPProxy(data)
|
proxy.value = new UDPProxy(data)
|
||||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
} else if (type === 'http' && config.vhostHTTPPort) {
|
||||||
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
|
proxy.value = new HTTPProxy(
|
||||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
data,
|
||||||
|
config.vhostHTTPPort,
|
||||||
|
config.subdomainHost,
|
||||||
|
)
|
||||||
|
} else if (type === 'https' && config.vhostHTTPSPort) {
|
||||||
proxy.value = new HTTPSProxy(
|
proxy.value = new HTTPSProxy(
|
||||||
data,
|
data,
|
||||||
info.vhostHTTPSPort,
|
config.vhostHTTPSPort,
|
||||||
info.subdomainHost,
|
config.subdomainHost,
|
||||||
)
|
)
|
||||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
} else if (type === 'tcpmux' && config.tcpmuxHTTPConnectPort) {
|
||||||
proxy.value = new TCPMuxProxy(
|
proxy.value = new TCPMuxProxy(
|
||||||
data,
|
data,
|
||||||
info.tcpmuxHTTPConnectPort,
|
config.tcpmuxHTTPConnectPort,
|
||||||
info.subdomainHost,
|
config.subdomainHost,
|
||||||
)
|
)
|
||||||
} else if (type === 'stcp') {
|
} else if (type === 'stcp') {
|
||||||
proxy.value = new STCPProxy(data)
|
proxy.value = new STCPProxy(data)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<el-col :xs="24" :sm="12" :lg="6">
|
<el-col :xs="24" :sm="12" :lg="6">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Clients"
|
label="Clients"
|
||||||
:value="data.clientCounts"
|
:value="data.status.clientCounts"
|
||||||
type="clients"
|
type="clients"
|
||||||
subtitle="Connected clients"
|
subtitle="Connected clients"
|
||||||
to="/clients"
|
to="/clients"
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<el-col :xs="24" :sm="12" :lg="6">
|
<el-col :xs="24" :sm="12" :lg="6">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Proxies"
|
label="Proxies"
|
||||||
:value="data.proxyCounts"
|
:value="proxyCounts"
|
||||||
type="proxies"
|
type="proxies"
|
||||||
subtitle="Active proxies"
|
subtitle="Active proxies"
|
||||||
to="/proxies/tcp"
|
to="/proxies/tcp"
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<el-col :xs="24" :sm="12" :lg="6">
|
<el-col :xs="24" :sm="12" :lg="6">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Connections"
|
label="Connections"
|
||||||
:value="data.curConns"
|
:value="data.status.curConns"
|
||||||
type="connections"
|
type="connections"
|
||||||
subtitle="Current connections"
|
subtitle="Current connections"
|
||||||
/>
|
/>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<div class="traffic-info">
|
<div class="traffic-info">
|
||||||
<div class="label">Inbound</div>
|
<div class="label">Inbound</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
{{ formatFileSize(data.totalTrafficIn) }}
|
{{ formatFileSize(data.status.totalTrafficIn) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<div class="traffic-info">
|
<div class="traffic-info">
|
||||||
<div class="label">Outbound</div>
|
<div class="label">Outbound</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
{{ formatFileSize(data.totalTrafficOut) }}
|
{{ formatFileSize(data.status.totalTrafficOut) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="proxy-types-grid">
|
<div class="proxy-types-grid">
|
||||||
<div
|
<div
|
||||||
v-for="(count, type) in data.proxyTypeCounts"
|
v-for="(count, type) in data.status.proxyTypeCount"
|
||||||
:key="type"
|
:key="type"
|
||||||
class="proxy-type-item"
|
class="proxy-type-item"
|
||||||
v-show="count > 0"
|
v-show="count > 0"
|
||||||
@@ -109,51 +109,51 @@
|
|||||||
<div class="config-grid">
|
<div class="config-grid">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="config-label">Bind Port</span>
|
<span class="config-label">Bind Port</span>
|
||||||
<span class="config-value">{{ data.bindPort }}</span>
|
<span class="config-value">{{ data.config.bindPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.kcpBindPort != 0">
|
<div class="config-item" v-if="data.config.kcpBindPort != 0">
|
||||||
<span class="config-label">KCP Port</span>
|
<span class="config-label">KCP Port</span>
|
||||||
<span class="config-value">{{ data.kcpBindPort }}</span>
|
<span class="config-value">{{ data.config.kcpBindPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.quicBindPort != 0">
|
<div class="config-item" v-if="data.config.quicBindPort != 0">
|
||||||
<span class="config-label">QUIC Port</span>
|
<span class="config-label">QUIC Port</span>
|
||||||
<span class="config-value">{{ data.quicBindPort }}</span>
|
<span class="config-value">{{ data.config.quicBindPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.vhostHTTPPort != 0">
|
<div class="config-item" v-if="data.config.vhostHTTPPort != 0">
|
||||||
<span class="config-label">HTTP Port</span>
|
<span class="config-label">HTTP Port</span>
|
||||||
<span class="config-value">{{ data.vhostHTTPPort }}</span>
|
<span class="config-value">{{ data.config.vhostHTTPPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.vhostHTTPSPort != 0">
|
<div class="config-item" v-if="data.config.vhostHTTPSPort != 0">
|
||||||
<span class="config-label">HTTPS Port</span>
|
<span class="config-label">HTTPS Port</span>
|
||||||
<span class="config-value">{{ data.vhostHTTPSPort }}</span>
|
<span class="config-value">{{ data.config.vhostHTTPSPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0">
|
<div class="config-item" v-if="data.config.tcpmuxHTTPConnectPort != 0">
|
||||||
<span class="config-label">TCPMux Port</span>
|
<span class="config-label">TCPMux Port</span>
|
||||||
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span>
|
<span class="config-value">{{ data.config.tcpmuxHTTPConnectPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.subdomainHost != ''">
|
<div class="config-item" v-if="data.config.subdomainHost != ''">
|
||||||
<span class="config-label">Subdomain Host</span>
|
<span class="config-label">Subdomain Host</span>
|
||||||
<span class="config-value">{{ data.subdomainHost }}</span>
|
<span class="config-value">{{ data.config.subdomainHost }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="config-label">Max Pool Count</span>
|
<span class="config-label">Max Pool Count</span>
|
||||||
<span class="config-value">{{ data.maxPoolCount }}</span>
|
<span class="config-value">{{ data.config.maxPoolCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="config-label">Max Ports/Client</span>
|
<span class="config-label">Max Ports/Client</span>
|
||||||
<span class="config-value">{{ data.maxPortsPerClient }}</span>
|
<span class="config-value">{{ maxPortsPerClientLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.allowPortsStr != ''">
|
<div class="config-item" v-if="data.config.allowPortsStr != ''">
|
||||||
<span class="config-label">Allow Ports</span>
|
<span class="config-label">Allow Ports</span>
|
||||||
<span class="config-value">{{ data.allowPortsStr }}</span>
|
<span class="config-value">{{ data.config.allowPortsStr }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.tlsForce">
|
<div class="config-item" v-if="data.config.tlsForce">
|
||||||
<span class="config-label">TLS Force</span>
|
<span class="config-label">TLS Force</span>
|
||||||
<el-tag size="small" type="warning">Enabled</el-tag>
|
<el-tag size="small" type="warning">Enabled</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="config-label">Heartbeat Timeout</span>
|
<span class="config-label">Heartbeat Timeout</span>
|
||||||
<span class="config-value">{{ data.heartbeatTimeout }}s</span>
|
<span class="config-value">{{ data.config.heartbeatTimeout }}s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -167,69 +167,59 @@ import { formatFileSize } from '../utils/format'
|
|||||||
import { Download, Upload } from '@element-plus/icons-vue'
|
import { Download, Upload } from '@element-plus/icons-vue'
|
||||||
import StatCard from '../components/StatCard.vue'
|
import StatCard from '../components/StatCard.vue'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
const data = ref({
|
const data = ref<ServerInfo>({
|
||||||
version: '',
|
version: '',
|
||||||
bindPort: 0,
|
config: {
|
||||||
kcpBindPort: 0,
|
bindPort: 0,
|
||||||
quicBindPort: 0,
|
kcpBindPort: 0,
|
||||||
vhostHTTPPort: 0,
|
quicBindPort: 0,
|
||||||
vhostHTTPSPort: 0,
|
vhostHTTPPort: 0,
|
||||||
tcpmuxHTTPConnectPort: 0,
|
vhostHTTPSPort: 0,
|
||||||
subdomainHost: '',
|
tcpmuxHTTPConnectPort: 0,
|
||||||
maxPoolCount: 0,
|
subdomainHost: '',
|
||||||
maxPortsPerClient: '',
|
maxPoolCount: 0,
|
||||||
allowPortsStr: '',
|
maxPortsPerClient: 0,
|
||||||
tlsForce: false,
|
allowPortsStr: '',
|
||||||
heartbeatTimeout: 0,
|
tlsForce: false,
|
||||||
clientCounts: 0,
|
heartbeatTimeout: 0,
|
||||||
curConns: 0,
|
},
|
||||||
proxyCounts: 0,
|
status: {
|
||||||
totalTrafficIn: 0,
|
clientCounts: 0,
|
||||||
totalTrafficOut: 0,
|
curConns: 0,
|
||||||
proxyTypeCounts: {} as Record<string, number>,
|
totalTrafficIn: 0,
|
||||||
|
totalTrafficOut: 0,
|
||||||
|
proxyTypeCount: {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActiveProxies = computed(() => {
|
const hasActiveProxies = computed(() => {
|
||||||
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)
|
return Object.values(data.value.status.proxyTypeCount).some((c) => c > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const proxyCounts = computed(() => {
|
||||||
|
return Object.values(data.value.status.proxyTypeCount).reduce(
|
||||||
|
(sum, count) => sum + (count || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxPortsPerClientLabel = computed(() => {
|
||||||
|
const value = data.value.config.maxPortsPerClient
|
||||||
|
return value === 0 ? 'no limit' : String(value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatTrafficTotal = () => {
|
const formatTrafficTotal = () => {
|
||||||
const total = data.value.totalTrafficIn + data.value.totalTrafficOut
|
const total =
|
||||||
|
data.value.status.totalTrafficIn + data.value.status.totalTrafficOut
|
||||||
return formatFileSize(total)
|
return formatFileSize(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const json = await getServerInfo()
|
const json = await getServerInfo()
|
||||||
data.value.version = json.version
|
data.value = json
|
||||||
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 {
|
} catch {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
|
|||||||
349
web/package-lock.json
generated
349
web/package-lock.json
generated
@@ -147,14 +147,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
@@ -164,14 +163,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -181,14 +179,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -198,14 +195,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -215,14 +211,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -232,14 +227,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -249,14 +243,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -266,14 +259,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -283,14 +275,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -300,14 +291,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -317,14 +307,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -334,14 +323,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -351,14 +339,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -368,14 +355,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -385,14 +371,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -402,14 +387,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -419,14 +403,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -436,14 +419,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -453,14 +435,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -470,14 +451,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -487,14 +467,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -504,14 +483,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
@@ -521,14 +499,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
@@ -538,14 +515,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -555,14 +531,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -572,14 +547,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -954,76 +928,6 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
@@ -2567,9 +2471,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -3450,12 +3354,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.4",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
@@ -3463,32 +3366,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.27.4",
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
"@esbuild/android-arm": "0.27.4",
|
"@esbuild/android-arm": "0.28.1",
|
||||||
"@esbuild/android-arm64": "0.27.4",
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
"@esbuild/android-x64": "0.27.4",
|
"@esbuild/android-x64": "0.28.1",
|
||||||
"@esbuild/darwin-arm64": "0.27.4",
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
"@esbuild/darwin-x64": "0.27.4",
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
"@esbuild/freebsd-arm64": "0.27.4",
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
"@esbuild/freebsd-x64": "0.27.4",
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
"@esbuild/linux-arm": "0.27.4",
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
"@esbuild/linux-arm64": "0.27.4",
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
"@esbuild/linux-ia32": "0.27.4",
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
"@esbuild/linux-loong64": "0.27.4",
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
"@esbuild/linux-mips64el": "0.27.4",
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
"@esbuild/linux-ppc64": "0.27.4",
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
"@esbuild/linux-riscv64": "0.27.4",
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
"@esbuild/linux-s390x": "0.27.4",
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
"@esbuild/linux-x64": "0.27.4",
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
"@esbuild/netbsd-arm64": "0.27.4",
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
"@esbuild/netbsd-x64": "0.27.4",
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
"@esbuild/openbsd-arm64": "0.27.4",
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
"@esbuild/openbsd-x64": "0.27.4",
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
"@esbuild/openharmony-arm64": "0.27.4",
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
"@esbuild/sunos-x64": "0.27.4",
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
"@esbuild/win32-arm64": "0.27.4",
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
"@esbuild/win32-ia32": "0.27.4",
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
"@esbuild/win32-x64": "0.27.4"
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
@@ -4859,11 +4762,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -6276,11 +6188,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shell-quote": {
|
"node_modules/shell-quote": {
|
||||||
"version": "1.8.3",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
@@ -7460,12 +7371,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.2",
|
"version": "7.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user