forked from Mxmilu666/frp
Compare commits
14 Commits
dev
...
research/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad5ebc0017 | ||
|
|
5cd722b177 | ||
|
|
5876beceac | ||
|
|
7fe152e3aa | ||
|
|
7c343fc6e7 | ||
|
|
a3b3b35b69 | ||
|
|
ae1c0504ec | ||
|
|
393a533744 | ||
|
|
035889c360 | ||
|
|
940bde5c46 | ||
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec |
20
README.md
20
README.md
@@ -13,6 +13,16 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
|
||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -30,16 +40,6 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
|
||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## What is frp?
|
||||
|
||||
20
README_zh.md
20
README_zh.md
@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
|
||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -32,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
|
||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## 为什么使用 frp ?
|
||||
|
||||
11
Release.md
11
Release.md
@@ -1,9 +1,10 @@
|
||||
## Features
|
||||
|
||||
* `transport.wireProtocol = "v2"` now also applies to UDP-based proxy payloads, including ordinary UDP and SUDP, so their payload framing is consistent with the selected wire protocol.
|
||||
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
|
||||
* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
|
||||
* Added dashboard API v2 pagination endpoints for users, clients, and proxies.
|
||||
* The frps dashboard Clients and Proxies pages now use API v2 pagination and server-side search, including proxy type filtering and searchable proxy spec fields such as remote ports, custom domains, and subdomains.
|
||||
|
||||
## Compatibility Notes
|
||||
## Fixes
|
||||
|
||||
* When enabling `transport.wireProtocol = "v2"` for SUDP, upgrade both the proxy and visitor frpc instances first, or keep them on `v1` until both sides are upgraded.
|
||||
* 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.
|
||||
|
||||
@@ -394,6 +394,10 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -417,6 +421,31 @@ func LoadClientConfig(path string, strict bool) (
|
||||
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
||||
}
|
||||
|
||||
// validateNoDuplicateNames rejects proxies or visitors that share a name. They are
|
||||
// keyed by name in the config sources, so a duplicate would otherwise be silently
|
||||
// overwritten and never started, with no error or log.
|
||||
func validateNoDuplicateNames(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
|
||||
proxyNames := make(map[string]struct{}, len(proxies))
|
||||
for _, p := range proxies {
|
||||
name := p.GetBaseConfig().Name
|
||||
if _, ok := proxyNames[name]; ok {
|
||||
return fmt.Errorf("proxy name [%s] is duplicated", name)
|
||||
}
|
||||
proxyNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
visitorNames := make(map[string]struct{}, len(visitors))
|
||||
for _, v := range visitors {
|
||||
name := v.GetBaseConfig().Name
|
||||
if _, ok := visitorNames[name]; ok {
|
||||
return fmt.Errorf("visitor name [%s] is duplicated", name)
|
||||
}
|
||||
visitorNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
||||
proxyCfgs := proxies
|
||||
for _, c := range proxyCfgs {
|
||||
|
||||
@@ -17,6 +17,8 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -462,6 +464,111 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
|
||||
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
||||
}
|
||||
|
||||
func TestLoadClientConfigResult_DuplicateNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "duplicate proxy names",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "dup"
|
||||
type = "tcp"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
|
||||
[[proxies]]
|
||||
name = "dup"
|
||||
type = "tcp"
|
||||
localPort = 3306
|
||||
remotePort = 6001
|
||||
`,
|
||||
errSubstr: "proxy name [dup] is duplicated",
|
||||
},
|
||||
{
|
||||
name: "duplicate visitor names",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[visitors]]
|
||||
name = "dup"
|
||||
type = "stcp"
|
||||
serverName = "a"
|
||||
secretKey = "secret"
|
||||
bindPort = 9001
|
||||
|
||||
[[visitors]]
|
||||
name = "dup"
|
||||
type = "stcp"
|
||||
serverName = "b"
|
||||
secretKey = "secret"
|
||||
bindPort = 9002
|
||||
`,
|
||||
errSubstr: "visitor name [dup] is duplicated",
|
||||
},
|
||||
{
|
||||
name: "unique names",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "p1"
|
||||
type = "tcp"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
|
||||
[[proxies]]
|
||||
name = "p2"
|
||||
type = "tcp"
|
||||
localPort = 3306
|
||||
remotePort = 6001
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "same name across proxy and visitor",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "same"
|
||||
type = "tcp"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
|
||||
[[visitors]]
|
||||
name = "same"
|
||||
type = "stcp"
|
||||
serverName = "a"
|
||||
secretKey = "secret"
|
||||
bindPort = 9001
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
path := filepath.Join(t.TempDir(), "frpc.toml")
|
||||
require.NoError(os.WriteFile(path, []byte(tc.content), 0o600))
|
||||
|
||||
_, err := LoadClientConfigResult(path, false)
|
||||
if tc.errSubstr == "" {
|
||||
require.NoError(err)
|
||||
} else {
|
||||
require.ErrorContains(err, tc.errSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
||||
func TestYAMLEdgeCases(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
defer m.mu.Unlock()
|
||||
total = len(m.info.ProxyStatistics)
|
||||
for name, data := range m.info.ProxyStatistics {
|
||||
if !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
||||
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
||||
delete(m.info.ProxyStatistics, name)
|
||||
count++
|
||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||
@@ -106,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
return count, total
|
||||
}
|
||||
|
||||
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
|
||||
return !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
|
||||
}
|
||||
|
||||
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
||||
return m.clearUselessInfo(0)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
|
||||
return m.clearUselessInfo(0)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) NewClient() {
|
||||
m.info.ClientCounts.Inc(1)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
require.Empty(metrics.info.ProxyStatistics)
|
||||
}
|
||||
|
||||
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||
Name: "offline",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||
Name: "online",
|
||||
LastStartTime: start,
|
||||
}
|
||||
|
||||
cleared, total := metrics.ClearOfflineProxies()
|
||||
|
||||
require.Equal(1, cleared)
|
||||
require.Equal(2, total)
|
||||
require.False(metrics.hasProxyStatistics("offline"))
|
||||
require.True(metrics.hasProxyStatistics("online"))
|
||||
}
|
||||
|
||||
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||
Name: "offline",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||
Name: "online",
|
||||
LastStartTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
|
||||
Name: "restarted",
|
||||
LastStartTime: start.Add(30 * time.Second),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
|
||||
Name: "same-time",
|
||||
LastStartTime: start,
|
||||
LastCloseTime: start,
|
||||
}
|
||||
|
||||
cleared, total := metrics.PruneOfflineProxies()
|
||||
|
||||
require.Equal(1, cleared)
|
||||
require.Equal(4, total)
|
||||
require.False(metrics.hasProxyStatistics("offline"))
|
||||
require.True(metrics.hasProxyStatistics("online"))
|
||||
require.True(metrics.hasProxyStatistics("restarted"))
|
||||
require.True(metrics.hasProxyStatistics("same-time"))
|
||||
|
||||
cleared, total = metrics.PruneOfflineProxies()
|
||||
require.Equal(0, cleared)
|
||||
require.Equal(3, total)
|
||||
}
|
||||
|
||||
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
|
||||
@@ -85,4 +85,5 @@ type Collector interface {
|
||||
GetProxyByName(proxyName string) *ProxyStats
|
||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||
ClearOfflineProxies() (int, int)
|
||||
PruneOfflineProxies() (int, int)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
if connInfo.ProxyProtocolHeader != nil {
|
||||
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
|
||||
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
|
||||
rawConn.Close()
|
||||
tlsConn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
libio.Join(tlsConn, rawConn)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||
// frames and close the connection on invalid bytes.
|
||||
conn.PayloadType = websocket.BinaryFrame
|
||||
return ctx, conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
||||
|
||||
muxer := http.NewServeMux()
|
||||
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||
// frames and close the connection on invalid bytes.
|
||||
c.PayloadType = websocket.BinaryFrame
|
||||
notifyCh := make(chan struct{})
|
||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||
close(notifyCh)
|
||||
|
||||
@@ -49,10 +49,15 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
||||
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")
|
||||
subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
||||
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
|
||||
v2EncodedPathRouter.UseEncodedPath()
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||
|
||||
// view
|
||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||
|
||||
@@ -58,8 +58,12 @@ func NewController(
|
||||
|
||||
// /api/serverinfo
|
||||
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||
return c.buildServerInfoResp(), nil
|
||||
}
|
||||
|
||||
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||
serverStats := mem.StatsCollector.GetServer()
|
||||
svrResp := model.ServerInfoResp{
|
||||
return model.ServerInfoResp{
|
||||
Version: version.Full(),
|
||||
BindPort: c.serverCfg.BindPort,
|
||||
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||
@@ -80,8 +84,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||
ClientCounts: serverStats.ClientCounts,
|
||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||
}
|
||||
|
||||
return svrResp, nil
|
||||
}
|
||||
|
||||
// /api/clients
|
||||
|
||||
@@ -19,20 +19,28 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
"github.com/fatedier/frp/server/http/model"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultV2Page = 1
|
||||
defaultV2PageSize = 50
|
||||
maxV2PageSize = 200
|
||||
|
||||
v2SystemPruneTypeOfflineProxies = "offline_proxies"
|
||||
v2ProxyTrafficDefaultDays = 7
|
||||
v2ProxyTrafficUnit = "bytes"
|
||||
v2ProxyTrafficGranularity = "day"
|
||||
)
|
||||
|
||||
var apiV2ProxyTypes = []string{
|
||||
@@ -46,6 +54,55 @@ var apiV2ProxyTypes = []string{
|
||||
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)
|
||||
@@ -137,7 +194,26 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
||||
|
||||
// /api/v2/clients/{key}
|
||||
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
||||
return c.APIClientDetail(ctx)
|
||||
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
|
||||
@@ -190,9 +266,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||
|
||||
// /api/v2/proxies/{name}
|
||||
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||
name := ctx.Param("name")
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("missing proxy name")
|
||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ps := mem.StatsCollector.GetProxyByName(name)
|
||||
@@ -202,6 +278,33 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||
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 {
|
||||
@@ -261,6 +364,18 @@ func parseV2ProxyTypeFilter(raw string) (string, error) {
|
||||
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":
|
||||
@@ -366,6 +481,49 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
||||
return items
|
||||
}
|
||||
|
||||
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
|
||||
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
|
||||
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
|
||||
history = append(history, model.V2ProxyTrafficPointResp{
|
||||
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
|
||||
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
|
||||
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
|
||||
})
|
||||
}
|
||||
|
||||
return model.V2ProxyTrafficResp{
|
||||
Name: name,
|
||||
Unit: v2ProxyTrafficUnit,
|
||||
Granularity: v2ProxyTrafficGranularity,
|
||||
History: history,
|
||||
}
|
||||
}
|
||||
|
||||
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
|
||||
if todayFirstIndex >= len(values) {
|
||||
return 0
|
||||
}
|
||||
return values[todayFirstIndex]
|
||||
}
|
||||
|
||||
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
||||
status := model.V2ClientStatusResp{State: "offline"}
|
||||
if info.Online {
|
||||
status.State = "online"
|
||||
}
|
||||
|
||||
user := info.User
|
||||
clientID := info.ClientID()
|
||||
for _, ps := range c.listV2ProxyStats("") {
|
||||
if ps.User != user || ps.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
status.CurConns += ps.CurConns
|
||||
status.ProxyCount++
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
||||
state := "offline"
|
||||
var spec any
|
||||
|
||||
@@ -20,10 +20,13 @@ import (
|
||||
"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"
|
||||
@@ -39,10 +42,16 @@ type v2EnvelopeForTest[T any] struct {
|
||||
}
|
||||
|
||||
type fakeStatsCollector struct {
|
||||
proxies map[string]*mem.ProxyStats
|
||||
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{}}
|
||||
}
|
||||
|
||||
@@ -69,13 +78,210 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
||||
return nil
|
||||
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)
|
||||
@@ -146,10 +352,49 @@ func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp)
|
||||
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) {
|
||||
@@ -186,13 +431,152 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||
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 != 1 {
|
||||
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
|
||||
@@ -277,7 +661,26 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/clients")
|
||||
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())
|
||||
@@ -298,6 +701,24 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
||||
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/traffic/tcp-alice")
|
||||
var traffic model.GetProxyTrafficResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
|
||||
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if traffic.Name != "tcp-alice" ||
|
||||
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
|
||||
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
|
||||
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
|
||||
}
|
||||
var trafficRaw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
|
||||
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
|
||||
}
|
||||
if _, ok := trafficRaw["data"]; ok {
|
||||
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newV2TestController(t *testing.T) *Controller {
|
||||
@@ -322,6 +743,14 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
ClientID: "client-a",
|
||||
TodayTrafficIn: 30,
|
||||
TodayTrafficOut: 40,
|
||||
CurConns: 2,
|
||||
},
|
||||
"http-alice": {
|
||||
Name: "http-alice",
|
||||
Type: "http",
|
||||
User: "alice",
|
||||
ClientID: "client-a",
|
||||
CurConns: 3,
|
||||
},
|
||||
"udp-bob": {
|
||||
Name: "udp-bob",
|
||||
@@ -330,6 +759,13 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
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
|
||||
@@ -347,17 +783,28 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
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)
|
||||
router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
||||
encodedPathRouter := router.NewRoute().Subrouter()
|
||||
encodedPathRouter.UseEncodedPath()
|
||||
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
||||
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 {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
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
|
||||
@@ -372,3 +819,36 @@ func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -21,12 +21,58 @@ type V2PageResp[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
}
|
||||
|
||||
type V2SystemInfoResp struct {
|
||||
Version string `json:"version"`
|
||||
Config V2SystemInfoConfigResp `json:"config"`
|
||||
Status V2SystemInfoStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2SystemInfoConfigResp struct {
|
||||
BindPort int `json:"bindPort"`
|
||||
VhostHTTPPort int `json:"vhostHTTPPort"`
|
||||
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
||||
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
||||
KCPBindPort int `json:"kcpBindPort"`
|
||||
QUICBindPort int `json:"quicBindPort"`
|
||||
SubdomainHost string `json:"subdomainHost"`
|
||||
MaxPoolCount int64 `json:"maxPoolCount"`
|
||||
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
||||
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
|
||||
AllowPortsStr string `json:"allowPortsStr"`
|
||||
TLSForce bool `json:"tlsForce"`
|
||||
}
|
||||
|
||||
type V2SystemInfoStatusResp struct {
|
||||
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
||||
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ClientCounts int64 `json:"clientCounts"`
|
||||
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
||||
}
|
||||
|
||||
type V2SystemPruneResp struct {
|
||||
Type string `json:"type"`
|
||||
Cleared int `json:"cleared"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type V2UserResp struct {
|
||||
User string `json:"user"`
|
||||
ClientCount int `json:"clientCount"`
|
||||
ProxyCount int `json:"proxyCount"`
|
||||
}
|
||||
|
||||
type V2ClientDetailResp struct {
|
||||
ClientInfoResp
|
||||
Status V2ClientStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2ClientStatusResp struct {
|
||||
State string `json:"phase"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ProxyCount int64 `json:"proxyCount"`
|
||||
}
|
||||
|
||||
type V2ProxyResp struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
@@ -44,3 +90,16 @@ type V2ProxyStatusResp struct {
|
||||
LastStartTime string `json:"lastStartTime"`
|
||||
LastCloseTime string `json:"lastCloseTime"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficResp struct {
|
||||
Name string `json:"name"`
|
||||
Unit string `json:"unit"`
|
||||
Granularity string `json:"granularity"`
|
||||
History []V2ProxyTrafficPointResp `json:"history"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficPointResp struct {
|
||||
Date string `json:"date"`
|
||||
TrafficIn int64 `json:"trafficIn"`
|
||||
TrafficOut int64 `json:"trafficOut"`
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
pp "github.com/pires/go-proxyproto"
|
||||
|
||||
"github.com/fatedier/frp/pkg/transport"
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
"github.com/fatedier/frp/test/e2e/framework"
|
||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
||||
"github.com/fatedier/frp/test/e2e/mock/server/streamserver"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/port"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||
@@ -450,4 +456,85 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
ExpectResp([]byte("test")).
|
||||
Ensure()
|
||||
})
|
||||
|
||||
ginkgo.It("tls2raw with proxy protocol v2", func() {
|
||||
generator := &cert.SelfSignedCertGenerator{}
|
||||
artifacts, err := generator.Generate("example.com")
|
||||
framework.ExpectNoError(err)
|
||||
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
|
||||
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
|
||||
|
||||
serverConf := consts.DefaultServerConfig
|
||||
vhostHTTPSPort := f.AllocPort()
|
||||
serverConf += fmt.Sprintf(`
|
||||
vhostHTTPSPort = %d
|
||||
`, vhostHTTPSPort)
|
||||
|
||||
localPort := f.AllocPort()
|
||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||
[[proxies]]
|
||||
name = "tls2raw-proxy-protocol-test"
|
||||
type = "https"
|
||||
customDomains = ["example.com"]
|
||||
transport.proxyProtocolVersion = "v2"
|
||||
[proxies.plugin]
|
||||
type = "tls2raw"
|
||||
localAddr = "127.0.0.1:%d"
|
||||
crtPath = "%s"
|
||||
keyPath = "%s"
|
||||
`, localPort, crtPath, keyPath)
|
||||
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
|
||||
streamserver.WithCustomHandler(func(c net.Conn) {
|
||||
defer c.Close()
|
||||
|
||||
writeResp := func(body string) {
|
||||
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
|
||||
}
|
||||
|
||||
rd := bufio.NewReader(c)
|
||||
ppHeader, err := pp.Read(rd)
|
||||
if err != nil {
|
||||
log.Errorf("read proxy protocol error: %v", err)
|
||||
writeResp("missing proxy protocol")
|
||||
return
|
||||
}
|
||||
if ppHeader.Version != 2 {
|
||||
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
|
||||
writeResp("unexpected proxy protocol version")
|
||||
return
|
||||
}
|
||||
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
|
||||
if !ok || srcAddr.IP.String() != "127.0.0.1" {
|
||||
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
|
||||
writeResp("unexpected proxy protocol source address")
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.ReadRequest(rd)
|
||||
if err != nil {
|
||||
log.Errorf("read http request after proxy protocol error: %v", err)
|
||||
writeResp("missing http request")
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
_ = req.Body.Close()
|
||||
|
||||
writeResp("test")
|
||||
}))
|
||||
f.RunServer("", localServer)
|
||||
|
||||
framework.NewRequestExpect(f).
|
||||
Port(vhostHTTPSPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
|
||||
ServerName: "example.com",
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
}).
|
||||
ExpectResp([]byte("test")).
|
||||
Ensure()
|
||||
})
|
||||
})
|
||||
|
||||
1
web/frps/components.d.ts
vendored
1
web/frps/components.d.ts
vendored
@@ -15,7 +15,6 @@ declare module 'vue' {
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
|
||||
@@ -24,3 +24,7 @@ export const getClientsV2 = (params: ClientListV2Params = {}) => {
|
||||
export const getClient = (key: string) => {
|
||||
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
||||
}
|
||||
|
||||
export const getClientV2 = (key: string) => {
|
||||
return http.getV2<ClientInfoData>(`../api/v2/clients/${encodeURIComponent(key)}`)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,13 @@ export const http = {
|
||||
request<T>(url, { ...options, method: 'GET' }),
|
||||
getV2: <T>(url: string, options?: RequestInit) =>
|
||||
requestV2<T>(url, { ...options, method: 'GET' }),
|
||||
postV2: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
requestV2<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
|
||||
@@ -8,6 +8,12 @@ import type {
|
||||
TrafficResponse,
|
||||
} from '../types/proxy'
|
||||
|
||||
export interface SystemPruneResponse {
|
||||
type: 'offline_proxies'
|
||||
cleared: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export const getProxiesByType = (type: string) => {
|
||||
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||
}
|
||||
@@ -32,7 +38,7 @@ export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
|
||||
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
|
||||
name: proxy.name,
|
||||
type: proxy.type,
|
||||
conf: proxy.spec,
|
||||
@@ -43,21 +49,32 @@ const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
|
||||
curConns: proxy.status.curConns,
|
||||
lastStartTime: proxy.status.lastStartTime,
|
||||
lastCloseTime: proxy.status.lastCloseTime,
|
||||
status: proxy.status.phase,
|
||||
status: proxy.status.state || proxy.status.phase || '',
|
||||
})
|
||||
|
||||
export const getProxy = (type: string, name: string) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||
}
|
||||
|
||||
export const getProxyByNameV2 = async (name: string) => {
|
||||
const proxy = await http.getV2<ProxyV2Info>(
|
||||
`../api/v2/proxies/${encodeURIComponent(name)}`,
|
||||
)
|
||||
return toLegacyProxyStats(proxy)
|
||||
}
|
||||
|
||||
export const getProxyByName = (name: string) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
|
||||
}
|
||||
|
||||
export const getProxyTraffic = (name: string) => {
|
||||
return http.get<TrafficResponse>(`../api/traffic/${name}`)
|
||||
return http.getV2<TrafficResponse>(
|
||||
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
|
||||
)
|
||||
}
|
||||
|
||||
export const clearOfflineProxies = () => {
|
||||
return http.delete('../api/proxies?status=offline')
|
||||
return http.postV2<SystemPruneResponse>(
|
||||
'../api/v2/system/prune?type=offline_proxies',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ import { http } from './http'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
export const getServerInfo = () => {
|
||||
return http.get<ServerInfo>('../api/serverinfo')
|
||||
return http.getV2<ServerInfo>('../api/v2/system/info')
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatFileSize } from '../utils/format'
|
||||
import { getProxyTraffic } from '../api/proxy'
|
||||
import type { TrafficResponse } from '../types/proxy'
|
||||
|
||||
const props = defineProps<{
|
||||
proxyName: string
|
||||
@@ -71,41 +72,24 @@ const chartData = ref<
|
||||
>([])
|
||||
const maxVal = ref(0)
|
||||
|
||||
const processData = (trafficIn: number[], trafficOut: number[]) => {
|
||||
// Ensure we have arrays and reverse them (server returns newest first)
|
||||
const inArr = [...(trafficIn || [])].reverse()
|
||||
const outArr = [...(trafficOut || [])].reverse()
|
||||
const formatDateLabel = (date: string) => {
|
||||
const parts = date.split('-')
|
||||
if (parts.length !== 3) return date
|
||||
return `${Number(parts[1])}-${Number(parts[2])}`
|
||||
}
|
||||
|
||||
// Pad with zeros if less than 7 days
|
||||
while (inArr.length < 7) inArr.unshift(0)
|
||||
while (outArr.length < 7) outArr.unshift(0)
|
||||
|
||||
// Slice to last 7 entries just in case
|
||||
const finalIn = inArr.slice(-7)
|
||||
const finalOut = outArr.slice(-7)
|
||||
|
||||
// Calculate dates (last 7 days ending today)
|
||||
const dates: string[] = []
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 6)
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
|
||||
d.setDate(d.getDate() + 1)
|
||||
}
|
||||
|
||||
// Find max value for scaling
|
||||
const maxIn = Math.max(...finalIn)
|
||||
const maxOut = Math.max(...finalOut)
|
||||
const processData = (history: TrafficResponse['history'] = []) => {
|
||||
const points = history || []
|
||||
const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
|
||||
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
|
||||
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
|
||||
|
||||
// Build chart data
|
||||
chartData.value = dates.map((date, i) => ({
|
||||
date,
|
||||
in: finalIn[i],
|
||||
out: finalOut[i],
|
||||
inPercent: (finalIn[i] / maxVal.value) * 100,
|
||||
outPercent: (finalOut[i] / maxVal.value) * 100,
|
||||
chartData.value = points.map((item) => ({
|
||||
date: formatDateLabel(item.date),
|
||||
in: item.trafficIn,
|
||||
out: item.trafficOut,
|
||||
inPercent: (item.trafficIn / maxVal.value) * 100,
|
||||
outPercent: (item.trafficOut / maxVal.value) * 100,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -113,7 +97,7 @@ const fetchData = () => {
|
||||
loading.value = true
|
||||
getProxyTraffic(props.proxyName)
|
||||
.then((json) => {
|
||||
processData(json.trafficIn, json.trafficOut)
|
||||
processData(json.history)
|
||||
})
|
||||
.catch((err) => {
|
||||
ElMessage({
|
||||
|
||||
@@ -12,6 +12,13 @@ export interface ClientInfoData {
|
||||
lastConnectedAt: number
|
||||
disconnectedAt?: number
|
||||
online: boolean
|
||||
status?: ClientStatus
|
||||
}
|
||||
|
||||
export interface ClientStatus {
|
||||
phase: 'online' | 'offline'
|
||||
curConns: number
|
||||
proxyCount: number
|
||||
}
|
||||
|
||||
export interface ClientListV2Params {
|
||||
|
||||
@@ -36,7 +36,8 @@ export interface ProxyV2Info {
|
||||
}
|
||||
|
||||
export interface ProxyV2Status {
|
||||
phase: string
|
||||
state?: string
|
||||
phase?: string
|
||||
todayTrafficIn: number
|
||||
todayTrafficOut: number
|
||||
curConns: number
|
||||
@@ -46,6 +47,13 @@ export interface ProxyV2Status {
|
||||
|
||||
export interface TrafficResponse {
|
||||
name: string
|
||||
trafficIn: number[]
|
||||
trafficOut: number[]
|
||||
unit: 'bytes'
|
||||
granularity: 'day'
|
||||
history: TrafficPoint[]
|
||||
}
|
||||
|
||||
export interface TrafficPoint {
|
||||
date: string
|
||||
trafficIn: number
|
||||
trafficOut: number
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export interface ServerInfo {
|
||||
version: string
|
||||
config: ServerInfoConfig
|
||||
status: ServerInfoStatus
|
||||
}
|
||||
|
||||
export interface ServerInfoConfig {
|
||||
bindPort: number
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
@@ -12,8 +17,9 @@ export interface ServerInfo {
|
||||
heartbeatTimeout: number
|
||||
allowPortsStr: string
|
||||
tlsForce: boolean
|
||||
}
|
||||
|
||||
// Stats
|
||||
export interface ServerInfoStatus {
|
||||
totalTrafficIn: number
|
||||
totalTrafficOut: number
|
||||
curConns: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistanceToNow } from './format'
|
||||
import type { ClientInfoData } from '../types/client'
|
||||
import type { ClientInfoData, ClientStatus } from '../types/client'
|
||||
|
||||
export class Client {
|
||||
key: string
|
||||
@@ -15,6 +15,7 @@ export class Client {
|
||||
lastConnectedAt: Date
|
||||
disconnectedAt?: Date
|
||||
online: boolean
|
||||
status: ClientStatus
|
||||
|
||||
constructor(data: ClientInfoData) {
|
||||
this.key = data.key
|
||||
@@ -37,6 +38,11 @@ export class Client {
|
||||
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
|
||||
}
|
||||
this.online = data.online
|
||||
this.status = data.status || {
|
||||
phase: this.online ? 'online' : 'offline',
|
||||
curConns: 0,
|
||||
proxyCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
get displayName(): string {
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="info-section">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Connections</span>
|
||||
<span class="info-value">{{ totalConnections }}</span>
|
||||
<span class="info-value">{{ client.status.curConns }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Run ID</span>
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="proxies-header">
|
||||
<div class="proxies-title">
|
||||
<h2>Proxies</h2>
|
||||
<span class="proxies-count">{{ filteredProxies.length }}</span>
|
||||
<span class="proxies-count">{{ total }}</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="proxySearch"
|
||||
@@ -100,21 +100,32 @@
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
|
||||
<div v-else-if="proxies.length > 0" class="proxies-list">
|
||||
<ProxyCard
|
||||
v-for="proxy in filteredProxies"
|
||||
:key="proxy.name"
|
||||
v-for="proxy in proxies"
|
||||
:key="`${proxy.type}:${proxy.name}`"
|
||||
:proxy="proxy"
|
||||
show-type
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="clientProxies.length > 0" class="empty-state">
|
||||
<div v-else-if="proxySearch.trim()" class="empty-state">
|
||||
<p>No proxies match "{{ proxySearch }}"</p>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>No proxies found</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="total > 0" class="pagination-section">
|
||||
<ElPagination
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="onPageChange"
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -130,13 +141,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElPagination } from 'element-plus'
|
||||
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
||||
import { Client } from '../utils/client'
|
||||
import { getClient } from '../api/client'
|
||||
import { getProxiesByType } from '../api/proxy'
|
||||
import { getClientV2 } from '../api/client'
|
||||
import { getProxiesV2 } from '../api/proxy'
|
||||
import {
|
||||
BaseProxy,
|
||||
TCPProxy,
|
||||
@@ -149,6 +160,8 @@ import {
|
||||
} from '../utils/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
import type { ProxyStatsInfo } from '../types/proxy'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -163,119 +176,191 @@ const goBack = () => {
|
||||
}
|
||||
}
|
||||
const proxiesLoading = ref(false)
|
||||
const allProxies = ref<BaseProxy[]>([])
|
||||
const proxies = ref<BaseProxy[]>([])
|
||||
const proxySearch = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
let requestSeq = 0
|
||||
let searchDebounceTimer: number | null = null
|
||||
|
||||
let serverInfo: {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
let serverInfoPromise: Promise<ServerInfo> | null = null
|
||||
|
||||
const clientProxies = computed(() => {
|
||||
if (!client.value) return []
|
||||
return allProxies.value.filter(
|
||||
(p) =>
|
||||
p.clientID === client.value!.clientID && p.user === client.value!.user,
|
||||
)
|
||||
})
|
||||
|
||||
const filteredProxies = computed(() => {
|
||||
if (!proxySearch.value) return clientProxies.value
|
||||
const search = proxySearch.value.toLowerCase()
|
||||
return clientProxies.value.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.type.toLowerCase().includes(search),
|
||||
)
|
||||
})
|
||||
|
||||
const totalConnections = computed(() => {
|
||||
return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)
|
||||
})
|
||||
|
||||
const fetchServerInfo = async () => {
|
||||
if (serverInfo) return serverInfo
|
||||
const res = await getServerInfo()
|
||||
serverInfo = res
|
||||
return serverInfo
|
||||
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||
if (!serverInfoPromise) {
|
||||
serverInfoPromise = getServerInfo().catch((err) => {
|
||||
serverInfoPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return serverInfoPromise
|
||||
}
|
||||
|
||||
const fetchClient = async () => {
|
||||
const fetchClient = async (): Promise<boolean> => {
|
||||
const key = route.params.key as string
|
||||
if (!key) {
|
||||
loading.value = false
|
||||
return
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const data = await getClient(key)
|
||||
const data = await getClientV2(key)
|
||||
client.value = new Client(data)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
ElMessage.error('Failed to fetch client: ' + error.message)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProxies = async () => {
|
||||
proxiesLoading.value = true
|
||||
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
|
||||
const proxies: BaseProxy[] = []
|
||||
try {
|
||||
const convertProxy = async (
|
||||
proxy: ProxyStatsInfo,
|
||||
): Promise<BaseProxy | null> => {
|
||||
const type = proxy.type || ''
|
||||
if (type === 'tcp') {
|
||||
return new TCPProxy(proxy)
|
||||
}
|
||||
if (type === 'udp') {
|
||||
return new UDPProxy(proxy)
|
||||
}
|
||||
if (type === 'http') {
|
||||
const info = await fetchServerInfo()
|
||||
for (const type of proxyTypes) {
|
||||
try {
|
||||
const json = await getProxiesByType(type)
|
||||
if (!json.proxies) continue
|
||||
if (type === 'tcp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
|
||||
} else if (type === 'udp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
|
||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
||||
proxies.push(
|
||||
...json.proxies.map(
|
||||
(p: any) =>
|
||||
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
||||
),
|
||||
)
|
||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
||||
proxies.push(
|
||||
...json.proxies.map(
|
||||
(p: any) =>
|
||||
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
||||
),
|
||||
)
|
||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
||||
proxies.push(
|
||||
...json.proxies.map(
|
||||
(p: any) =>
|
||||
new TCPMuxProxy(
|
||||
p,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else if (type === 'stcp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
|
||||
} else if (type === 'sudp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
if (info && info.config.vhostHTTPPort) {
|
||||
return new HTTPProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
allProxies.value = proxies
|
||||
} catch {
|
||||
// Ignore
|
||||
return null
|
||||
}
|
||||
if (type === 'https') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.vhostHTTPSPort) {
|
||||
return new HTTPSProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPSPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'tcpmux') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||
return new TCPMuxProxy(
|
||||
proxy,
|
||||
info.config.tcpmuxHTTPConnectPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'stcp') {
|
||||
return new STCPProxy(proxy)
|
||||
}
|
||||
if (type === 'sudp') {
|
||||
return new SUDPProxy(proxy)
|
||||
}
|
||||
|
||||
const bp = new BaseProxy(proxy)
|
||||
bp.type = type
|
||||
return bp
|
||||
}
|
||||
|
||||
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
||||
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
||||
return converted.filter((item): item is BaseProxy => item !== null)
|
||||
}
|
||||
|
||||
const fetchProxies = async () => {
|
||||
if (!client.value) return
|
||||
const seq = ++requestSeq
|
||||
proxiesLoading.value = true
|
||||
|
||||
try {
|
||||
const q = proxySearch.value.trim()
|
||||
const data = await getProxiesV2({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
q: q || undefined,
|
||||
clientID: client.value.clientID,
|
||||
user: client.value.user,
|
||||
})
|
||||
if (seq !== requestSeq) return
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
||||
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
||||
page.value = maxPage
|
||||
await fetchProxies()
|
||||
return
|
||||
}
|
||||
|
||||
const converted = await convertProxies(data.items)
|
||||
if (seq !== requestSeq) return
|
||||
|
||||
proxies.value = converted
|
||||
total.value = data.total
|
||||
page.value = data.page
|
||||
pageSize.value = data.pageSize
|
||||
} catch (error: any) {
|
||||
if (seq !== requestSeq) return
|
||||
ElMessage.error('Failed to fetch proxies: ' + error.message)
|
||||
} finally {
|
||||
proxiesLoading.value = false
|
||||
if (seq === requestSeq) {
|
||||
proxiesLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchClient()
|
||||
const clearSearchDebounce = () => {
|
||||
if (searchDebounceTimer !== null) {
|
||||
window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const invalidateProxyRequests = () => {
|
||||
requestSeq++
|
||||
proxiesLoading.value = false
|
||||
}
|
||||
|
||||
const resetPageAndFetch = () => {
|
||||
clearSearchDebounce()
|
||||
page.value = 1
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
const onPageChange = (value: number) => {
|
||||
clearSearchDebounce()
|
||||
page.value = value
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
const onPageSizeChange = (value: number) => {
|
||||
pageSize.value = value
|
||||
resetPageAndFetch()
|
||||
}
|
||||
|
||||
watch(proxySearch, () => {
|
||||
clearSearchDebounce()
|
||||
invalidateProxyRequests()
|
||||
page.value = 1
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
searchDebounceTimer = null
|
||||
fetchProxies()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearSearchDebounce()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const ok = await fetchClient()
|
||||
if (!ok || !client.value) return
|
||||
|
||||
fetchProxies()
|
||||
})
|
||||
</script>
|
||||
@@ -483,6 +568,12 @@ html.dark .status-badge.online {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.proxies-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -27,36 +27,6 @@
|
||||
clearable
|
||||
class="main-search"
|
||||
/>
|
||||
|
||||
<PopoverMenu
|
||||
:model-value="selectedClientKey"
|
||||
:width="220"
|
||||
placement="bottom-end"
|
||||
selectable
|
||||
filterable
|
||||
filter-placeholder="Search clients..."
|
||||
:display-value="selectedClientLabel"
|
||||
clearable
|
||||
class="client-filter"
|
||||
@update:model-value="onClientFilterChange($event as string)"
|
||||
>
|
||||
<template #default="{ filterText }">
|
||||
<PopoverMenuItem value="">All Clients</PopoverMenuItem>
|
||||
<PopoverMenuItem
|
||||
v-if="clientIDFilter && !selectedClientInList"
|
||||
:value="selectedClientKey"
|
||||
>
|
||||
{{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem
|
||||
v-for="client in filteredClientOptions(filterText)"
|
||||
:key="client.key"
|
||||
:value="client.key"
|
||||
>
|
||||
{{ client.label }}
|
||||
</PopoverMenuItem>
|
||||
</template>
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
|
||||
<div class="type-tabs">
|
||||
@@ -111,7 +81,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElPagination } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
@@ -128,16 +98,13 @@ import {
|
||||
SUDPProxy,
|
||||
} from '../utils/proxy'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
||||
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
||||
import {
|
||||
getProxiesV2,
|
||||
clearOfflineProxies as apiClearOfflineProxies,
|
||||
} from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import { getClientsV2 } from '../api/client'
|
||||
import { Client } from '../utils/client'
|
||||
import type { ProxyStatsInfo } from '../types/proxy'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -154,115 +121,22 @@ const proxyTypes = [
|
||||
{ label: 'SUDP', value: 'sudp' },
|
||||
]
|
||||
|
||||
const activeType = ref((route.params.type as string) || 'tcp')
|
||||
const activeType = ref((route.params.type as string) || 'all')
|
||||
const proxies = ref<BaseProxy[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const showClearDialog = ref(false)
|
||||
const clientIDFilter = ref((route.query.clientID as string) || '')
|
||||
const userFilter = ref((route.query.user as string) || '')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const maxV2PageSize = 100
|
||||
let requestSeq = 0
|
||||
let searchDebounceTimer: number | null = null
|
||||
|
||||
const clientOptions = computed(() => {
|
||||
return clients.value
|
||||
.map((c) => ({
|
||||
key: c.key,
|
||||
clientID: c.clientID,
|
||||
user: c.user,
|
||||
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
})
|
||||
|
||||
// Compute selected client key for el-select v-model
|
||||
const selectedClientKey = computed(() => {
|
||||
if (!clientIDFilter.value) return ''
|
||||
const client = clientOptions.value.find(
|
||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||
)
|
||||
// Return a synthetic key even if not found, so the select shows the filter is active
|
||||
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
|
||||
})
|
||||
|
||||
const selectedClientLabel = computed(() => {
|
||||
if (!clientIDFilter.value) return 'All Clients'
|
||||
const client = clientOptions.value.find(
|
||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||
)
|
||||
return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`
|
||||
})
|
||||
|
||||
const filteredClientOptions = (filterText: string) => {
|
||||
if (!filterText) return clientOptions.value
|
||||
const search = filterText.toLowerCase()
|
||||
return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
// Check if the filtered client exists in the client list
|
||||
const selectedClientInList = computed(() => {
|
||||
if (!clientIDFilter.value) return true
|
||||
return clientOptions.value.some(
|
||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||
)
|
||||
})
|
||||
|
||||
const onClientFilterChange = (key: string) => {
|
||||
if (key) {
|
||||
const client = clientOptions.value.find((c) => c.key === key)
|
||||
if (client) {
|
||||
router.replace({
|
||||
query: { ...route.query, clientID: client.clientID, user: client.user },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const query = { ...route.query }
|
||||
delete query.clientID
|
||||
delete query.user
|
||||
router.replace({ query })
|
||||
}
|
||||
}
|
||||
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const allClients: Client[] = []
|
||||
let nextPage = 1
|
||||
let totalClients = 0
|
||||
|
||||
do {
|
||||
const data = await getClientsV2({
|
||||
page: nextPage,
|
||||
pageSize: maxV2PageSize,
|
||||
})
|
||||
allClients.push(...data.items.map((item) => new Client(item)))
|
||||
totalClients = data.total
|
||||
nextPage += 1
|
||||
} while (allClients.length < totalClients)
|
||||
|
||||
clients.value = allClients
|
||||
} catch (err) {
|
||||
// Client dropdown is a non-critical side load; log for diagnostics
|
||||
// but don't surface a toast (would compete with the main fetch error).
|
||||
console.warn('Failed to fetch clients for filter:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Server info cache - cache the Promise itself so concurrent first calls
|
||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||
type ServerInfoLite = {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
}
|
||||
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
||||
let serverInfoPromise: Promise<ServerInfo> | null = null
|
||||
|
||||
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
||||
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||
if (!serverInfoPromise) {
|
||||
serverInfoPromise = getServerInfo().catch((err) => {
|
||||
// Allow retry after failure
|
||||
@@ -285,25 +159,33 @@ const convertProxy = async (
|
||||
}
|
||||
if (type === 'http') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.vhostHTTPPort) {
|
||||
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
||||
if (info && info.config.vhostHTTPPort) {
|
||||
return new HTTPProxy(
|
||||
proxy,
|
||||
info.config.vhostHTTPPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type === 'https') {
|
||||
const info = await fetchServerInfo()
|
||||
if (info && info.vhostHTTPSPort) {
|
||||
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
||||
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.tcpmuxHTTPConnectPort) {
|
||||
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||
return new TCPMuxProxy(
|
||||
proxy,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
info.config.tcpmuxHTTPConnectPort,
|
||||
info.config.subdomainHost,
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -337,8 +219,6 @@ const fetchData = async (silent = false) => {
|
||||
pageSize: pageSize.value,
|
||||
type: activeType.value === 'all' ? undefined : activeType.value,
|
||||
q: q || undefined,
|
||||
clientID: clientIDFilter.value || undefined,
|
||||
user: clientIDFilter.value ? userFilter.value : undefined,
|
||||
})
|
||||
if (seq !== requestSeq) return
|
||||
|
||||
@@ -419,6 +299,18 @@ const clearOfflineProxies = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeClientQuery = () => {
|
||||
const hasClientQuery =
|
||||
Object.prototype.hasOwnProperty.call(route.query, 'clientID') ||
|
||||
Object.prototype.hasOwnProperty.call(route.query, 'user')
|
||||
if (!hasClientQuery) return
|
||||
|
||||
const query = { ...route.query }
|
||||
delete query.clientID
|
||||
delete query.user
|
||||
router.replace({ query })
|
||||
}
|
||||
|
||||
// Watch for type changes
|
||||
watch(activeType, (newType) => {
|
||||
clearSearchDebounce()
|
||||
@@ -437,23 +329,15 @@ watch(searchText, () => {
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// Watch for route query changes (client filter)
|
||||
watch(
|
||||
() => [route.query.clientID, route.query.user],
|
||||
([newClientID, newUser]) => {
|
||||
clientIDFilter.value = (newClientID as string) || ''
|
||||
userFilter.value = (newUser as string) || ''
|
||||
resetPageAndFetch()
|
||||
},
|
||||
)
|
||||
watch(() => route.query, sanitizeClientQuery)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearSearchDebounce()
|
||||
})
|
||||
|
||||
// Initial fetch
|
||||
sanitizeClientQuery()
|
||||
fetchData()
|
||||
fetchClients()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -520,16 +404,11 @@ fetchClients()
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.main-search :deep(.el-input__wrapper),
|
||||
.client-filter :deep(.el-input__wrapper) {
|
||||
.main-search :deep(.el-input__wrapper) {
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.client-filter {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -584,10 +463,6 @@ fetchClients()
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.client-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ import {
|
||||
Tickets,
|
||||
Location,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getProxyByName } from '../api/proxy'
|
||||
import { getProxyByNameV2 } from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import {
|
||||
BaseProxy,
|
||||
@@ -254,6 +254,7 @@ import {
|
||||
SUDPProxy,
|
||||
} from '../utils/proxy'
|
||||
import Traffic from '../components/Traffic.vue'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -275,12 +276,7 @@ const goBack = () => {
|
||||
}
|
||||
}
|
||||
|
||||
let serverInfo: {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
let serverInfo: ServerInfo | null = null
|
||||
|
||||
const clientLink = computed(() => {
|
||||
if (!proxy.value) return ''
|
||||
@@ -365,27 +361,32 @@ const fetchProxy = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getProxyByName(name)
|
||||
const data = await getProxyByNameV2(name)
|
||||
const info = await fetchServerInfo()
|
||||
const type = data.conf?.type || ''
|
||||
const config = info.config
|
||||
const type = data.type || data.conf?.type || ''
|
||||
|
||||
if (type === 'tcp') {
|
||||
proxy.value = new TCPProxy(data)
|
||||
} else if (type === 'udp') {
|
||||
proxy.value = new UDPProxy(data)
|
||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
||||
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
|
||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
||||
} else if (type === 'http' && config.vhostHTTPPort) {
|
||||
proxy.value = new HTTPProxy(
|
||||
data,
|
||||
config.vhostHTTPPort,
|
||||
config.subdomainHost,
|
||||
)
|
||||
} else if (type === 'https' && config.vhostHTTPSPort) {
|
||||
proxy.value = new HTTPSProxy(
|
||||
data,
|
||||
info.vhostHTTPSPort,
|
||||
info.subdomainHost,
|
||||
config.vhostHTTPSPort,
|
||||
config.subdomainHost,
|
||||
)
|
||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
||||
} else if (type === 'tcpmux' && config.tcpmuxHTTPConnectPort) {
|
||||
proxy.value = new TCPMuxProxy(
|
||||
data,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
config.tcpmuxHTTPConnectPort,
|
||||
config.subdomainHost,
|
||||
)
|
||||
} else if (type === 'stcp') {
|
||||
proxy.value = new STCPProxy(data)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Clients"
|
||||
:value="data.clientCounts"
|
||||
:value="data.status.clientCounts"
|
||||
type="clients"
|
||||
subtitle="Connected clients"
|
||||
to="/clients"
|
||||
@@ -13,7 +13,7 @@
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Proxies"
|
||||
:value="data.proxyCounts"
|
||||
:value="proxyCounts"
|
||||
type="proxies"
|
||||
subtitle="Active proxies"
|
||||
to="/proxies/tcp"
|
||||
@@ -22,7 +22,7 @@
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Connections"
|
||||
:value="data.curConns"
|
||||
:value="data.status.curConns"
|
||||
type="connections"
|
||||
subtitle="Current connections"
|
||||
/>
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="traffic-info">
|
||||
<div class="label">Inbound</div>
|
||||
<div class="value">
|
||||
{{ formatFileSize(data.totalTrafficIn) }}
|
||||
{{ formatFileSize(data.status.totalTrafficIn) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="traffic-info">
|
||||
<div class="label">Outbound</div>
|
||||
<div class="value">
|
||||
{{ formatFileSize(data.totalTrafficOut) }}
|
||||
{{ formatFileSize(data.status.totalTrafficOut) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@
|
||||
</template>
|
||||
<div class="proxy-types-grid">
|
||||
<div
|
||||
v-for="(count, type) in data.proxyTypeCounts"
|
||||
v-for="(count, type) in data.status.proxyTypeCount"
|
||||
:key="type"
|
||||
class="proxy-type-item"
|
||||
v-show="count > 0"
|
||||
@@ -109,51 +109,51 @@
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<span class="config-label">Bind Port</span>
|
||||
<span class="config-value">{{ data.bindPort }}</span>
|
||||
<span class="config-value">{{ data.config.bindPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.kcpBindPort != 0">
|
||||
<div class="config-item" v-if="data.config.kcpBindPort != 0">
|
||||
<span class="config-label">KCP Port</span>
|
||||
<span class="config-value">{{ data.kcpBindPort }}</span>
|
||||
<span class="config-value">{{ data.config.kcpBindPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.quicBindPort != 0">
|
||||
<div class="config-item" v-if="data.config.quicBindPort != 0">
|
||||
<span class="config-label">QUIC Port</span>
|
||||
<span class="config-value">{{ data.quicBindPort }}</span>
|
||||
<span class="config-value">{{ data.config.quicBindPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.vhostHTTPPort != 0">
|
||||
<div class="config-item" v-if="data.config.vhostHTTPPort != 0">
|
||||
<span class="config-label">HTTP Port</span>
|
||||
<span class="config-value">{{ data.vhostHTTPPort }}</span>
|
||||
<span class="config-value">{{ data.config.vhostHTTPPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.vhostHTTPSPort != 0">
|
||||
<div class="config-item" v-if="data.config.vhostHTTPSPort != 0">
|
||||
<span class="config-label">HTTPS Port</span>
|
||||
<span class="config-value">{{ data.vhostHTTPSPort }}</span>
|
||||
<span class="config-value">{{ data.config.vhostHTTPSPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0">
|
||||
<div class="config-item" v-if="data.config.tcpmuxHTTPConnectPort != 0">
|
||||
<span class="config-label">TCPMux Port</span>
|
||||
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span>
|
||||
<span class="config-value">{{ data.config.tcpmuxHTTPConnectPort }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.subdomainHost != ''">
|
||||
<div class="config-item" v-if="data.config.subdomainHost != ''">
|
||||
<span class="config-label">Subdomain Host</span>
|
||||
<span class="config-value">{{ data.subdomainHost }}</span>
|
||||
<span class="config-value">{{ data.config.subdomainHost }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Max Pool Count</span>
|
||||
<span class="config-value">{{ data.maxPoolCount }}</span>
|
||||
<span class="config-value">{{ data.config.maxPoolCount }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Max Ports/Client</span>
|
||||
<span class="config-value">{{ data.maxPortsPerClient }}</span>
|
||||
<span class="config-value">{{ maxPortsPerClientLabel }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.allowPortsStr != ''">
|
||||
<div class="config-item" v-if="data.config.allowPortsStr != ''">
|
||||
<span class="config-label">Allow Ports</span>
|
||||
<span class="config-value">{{ data.allowPortsStr }}</span>
|
||||
<span class="config-value">{{ data.config.allowPortsStr }}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="data.tlsForce">
|
||||
<div class="config-item" v-if="data.config.tlsForce">
|
||||
<span class="config-label">TLS Force</span>
|
||||
<el-tag size="small" type="warning">Enabled</el-tag>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Heartbeat Timeout</span>
|
||||
<span class="config-value">{{ data.heartbeatTimeout }}s</span>
|
||||
<span class="config-value">{{ data.config.heartbeatTimeout }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -167,69 +167,59 @@ import { formatFileSize } from '../utils/format'
|
||||
import { Download, Upload } from '@element-plus/icons-vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
const data = ref({
|
||||
const data = ref<ServerInfo>({
|
||||
version: '',
|
||||
bindPort: 0,
|
||||
kcpBindPort: 0,
|
||||
quicBindPort: 0,
|
||||
vhostHTTPPort: 0,
|
||||
vhostHTTPSPort: 0,
|
||||
tcpmuxHTTPConnectPort: 0,
|
||||
subdomainHost: '',
|
||||
maxPoolCount: 0,
|
||||
maxPortsPerClient: '',
|
||||
allowPortsStr: '',
|
||||
tlsForce: false,
|
||||
heartbeatTimeout: 0,
|
||||
clientCounts: 0,
|
||||
curConns: 0,
|
||||
proxyCounts: 0,
|
||||
totalTrafficIn: 0,
|
||||
totalTrafficOut: 0,
|
||||
proxyTypeCounts: {} as Record<string, number>,
|
||||
config: {
|
||||
bindPort: 0,
|
||||
kcpBindPort: 0,
|
||||
quicBindPort: 0,
|
||||
vhostHTTPPort: 0,
|
||||
vhostHTTPSPort: 0,
|
||||
tcpmuxHTTPConnectPort: 0,
|
||||
subdomainHost: '',
|
||||
maxPoolCount: 0,
|
||||
maxPortsPerClient: 0,
|
||||
allowPortsStr: '',
|
||||
tlsForce: false,
|
||||
heartbeatTimeout: 0,
|
||||
},
|
||||
status: {
|
||||
clientCounts: 0,
|
||||
curConns: 0,
|
||||
totalTrafficIn: 0,
|
||||
totalTrafficOut: 0,
|
||||
proxyTypeCount: {},
|
||||
},
|
||||
})
|
||||
|
||||
const hasActiveProxies = computed(() => {
|
||||
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)
|
||||
return Object.values(data.value.status.proxyTypeCount).some((c) => c > 0)
|
||||
})
|
||||
|
||||
const proxyCounts = computed(() => {
|
||||
return Object.values(data.value.status.proxyTypeCount).reduce(
|
||||
(sum, count) => sum + (count || 0),
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
const maxPortsPerClientLabel = computed(() => {
|
||||
const value = data.value.config.maxPortsPerClient
|
||||
return value === 0 ? 'no limit' : String(value)
|
||||
})
|
||||
|
||||
const formatTrafficTotal = () => {
|
||||
const total = data.value.totalTrafficIn + data.value.totalTrafficOut
|
||||
const total =
|
||||
data.value.status.totalTrafficIn + data.value.status.totalTrafficOut
|
||||
return formatFileSize(total)
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const json = await getServerInfo()
|
||||
data.value.version = json.version
|
||||
data.value.bindPort = json.bindPort
|
||||
data.value.kcpBindPort = json.kcpBindPort
|
||||
data.value.quicBindPort = json.quicBindPort
|
||||
data.value.vhostHTTPPort = json.vhostHTTPPort
|
||||
data.value.vhostHTTPSPort = json.vhostHTTPSPort
|
||||
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
|
||||
data.value.subdomainHost = json.subdomainHost
|
||||
data.value.maxPoolCount = json.maxPoolCount
|
||||
data.value.maxPortsPerClient = String(json.maxPortsPerClient)
|
||||
if (data.value.maxPortsPerClient == '0') {
|
||||
data.value.maxPortsPerClient = 'no limit'
|
||||
}
|
||||
data.value.allowPortsStr = json.allowPortsStr
|
||||
data.value.tlsForce = json.tlsForce
|
||||
data.value.heartbeatTimeout = json.heartbeatTimeout
|
||||
data.value.clientCounts = json.clientCounts
|
||||
data.value.curConns = json.curConns
|
||||
data.value.totalTrafficIn = json.totalTrafficIn
|
||||
data.value.totalTrafficOut = json.totalTrafficOut
|
||||
data.value.proxyTypeCounts = json.proxyTypeCount || {}
|
||||
|
||||
data.value.proxyCounts = 0
|
||||
if (json.proxyTypeCount != null) {
|
||||
Object.values(json.proxyTypeCount).forEach((count: any) => {
|
||||
data.value.proxyCounts += count || 0
|
||||
})
|
||||
}
|
||||
data.value = json
|
||||
} catch {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
|
||||
279
web/package-lock.json
generated
279
web/package-lock.json
generated
@@ -147,14 +147,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
@@ -164,14 +163,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -181,14 +179,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -198,14 +195,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -215,14 +211,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -232,14 +227,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -249,14 +243,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -266,14 +259,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -283,14 +275,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -300,14 +291,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -317,14 +307,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -334,14 +323,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -351,14 +339,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -368,14 +355,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -385,14 +371,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -402,14 +387,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -419,14 +403,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -436,14 +419,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -453,14 +435,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -470,14 +451,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -487,14 +467,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -504,14 +483,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
@@ -521,14 +499,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
@@ -538,14 +515,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -555,14 +531,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -572,14 +547,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -2497,9 +2471,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -3380,12 +3354,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -3393,32 +3366,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
@@ -4789,11 +4762,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -6206,11 +6188,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -7390,12 +7371,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
Reference in New Issue
Block a user