diff --git a/README.md b/README.md
index d850c854..8dad3b44 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,6 @@
[](https://circleci.com/gh/fatedier/frp)
[](https://github.com/fatedier/frp/releases)
-[](https://goreportcard.com/report/github.com/fatedier/frp)
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
[README](README.md) | [中文文档](README_zh.md)
@@ -13,6 +12,16 @@ frp is an open source project with its ongoing development made possible entirel
Gold Sponsors
+
+
+## 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.
+
+
+
@@ -30,16 +39,6 @@ frp is an open source project with its ongoing development made possible entirel
An open source, self-hosted alternative to public clouds, built for data ownership and privacy
-
-
-
-## 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.
-
-
## What is frp?
diff --git a/README_zh.md b/README_zh.md
index a08d4401..713c7b57 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
Gold Sponsors
+
+
+## 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.
+
+
+
@@ -32,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
An open source, self-hosted alternative to public clouds, built for data ownership and privacy
-
-
-
-## 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.
-
-
## 为什么使用 frp ?
diff --git a/Release.md b/Release.md
index 2549a736..2858be11 100644
--- a/Release.md
+++ b/Release.md
@@ -1,9 +1,9 @@
## 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`.
+* Expanded the frps dashboard API v2 migration across Clients, Proxies, Server Overview, Client Detail, and Proxy Detail, covering paginated users/clients/proxies, detail data, proxy traffic history, server system info, offline proxy statistics pruning, server-side pagination, search, and proxy type filtering.
-## 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.
diff --git a/pkg/config/load.go b/pkg/config/load.go
index 38634fc5..2aaf1457 100644
--- a/pkg/config/load.go
+++ b/pkg/config/load.go
@@ -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 {
diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go
index b711a5c1..a1e29247 100644
--- a/pkg/config/load_test.go
+++ b/pkg/config/load_test.go
@@ -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)
diff --git a/pkg/metrics/mem/server.go b/pkg/metrics/mem/server.go
index a3c2bb65..fad8baef 100644
--- a/pkg/metrics/mem/server.go
+++ b/pkg/metrics/mem/server.go
@@ -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)
}
@@ -231,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
}
if !proxyStats.LastStartTime.IsZero() {
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
+ ps.LastStartAt = proxyStats.LastStartTime.Unix()
}
if !proxyStats.LastCloseTime.IsZero() {
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
+ ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
}
return ps
}
diff --git a/pkg/metrics/mem/server_test.go b/pkg/metrics/mem/server_test.go
index fe9f9984..12040c9f 100644
--- a/pkg/metrics/mem/server_test.go
+++ b/pkg/metrics/mem/server_test.go
@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
clk.SetTime(closedAt)
metrics.CloseProxy("proxy", "tcp")
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
+
+ stats := metrics.GetProxyByName("proxy")
+ require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
+ require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
+ require.Equal(start.Unix(), stats.LastStartAt)
+ require.Equal(closedAt.Unix(), stats.LastCloseAt)
}
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
@@ -43,6 +49,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)
diff --git a/pkg/metrics/mem/types.go b/pkg/metrics/mem/types.go
index b7661ba8..6361693b 100644
--- a/pkg/metrics/mem/types.go
+++ b/pkg/metrics/mem/types.go
@@ -41,6 +41,8 @@ type ProxyStats struct {
TodayTrafficOut int64
LastStartTime string
LastCloseTime string
+ LastStartAt int64
+ LastCloseAt int64
CurConns int64
}
@@ -85,4 +87,5 @@ type Collector interface {
GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int)
+ PruneOfflineProxies() (int, int)
}
diff --git a/pkg/plugin/client/tls2raw.go b/pkg/plugin/client/tls2raw.go
index ccb75149..6ce05420 100644
--- a/pkg/plugin/client/tls2raw.go
+++ b/pkg/plugin/client/tls2raw.go
@@ -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)
}
diff --git a/pkg/util/http/handler.go b/pkg/util/http/handler.go
index d6c7be18..55de2932 100644
--- a/pkg/util/http/handler.go
+++ b/pkg/util/http/handler.go
@@ -26,6 +26,12 @@ type GeneralResponse struct {
Msg string
}
+type V2Response struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ Data any `json:"data"`
+}
+
// APIHandler is a handler function that returns a response object or an error.
type APIHandler func(ctx *Context) (any, error)
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
}
}
}
+
+// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
+func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := NewContext(w, r)
+ res, err := handler(ctx)
+ if err != nil {
+ log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
+ code := http.StatusInternalServerError
+ if e, ok := err.(*Error); ok {
+ code = e.Code
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
+ }
+}
diff --git a/pkg/util/net/dial.go b/pkg/util/net/dial.go
index 1a3859ed..b0a5e437 100644
--- a/pkg/util/net/dial.go
+++ b/pkg/util/net/dial.go
@@ -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
}
}
diff --git a/pkg/util/net/websocket.go b/pkg/util/net/websocket.go
index 6c2f39c4..57641b31 100644
--- a/pkg/util/net/websocket.go
+++ b/pkg/util/net/websocket.go
@@ -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)
diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go
index 3d2e668d..550c239d 100644
--- a/pkg/util/version/version.go
+++ b/pkg/util/version/version.go
@@ -14,7 +14,7 @@
package version
-var version = "0.69.1"
+var version = "0.70.0"
func Full() string {
return version
diff --git a/server/api_router.go b/server/api_router.go
index bb9e44ed..59a55016 100644
--- a/server/api_router.go
+++ b/server/api_router.go
@@ -48,6 +48,17 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
+ subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
+ subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
+ subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
+ subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
+ v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
+ v2EncodedPathRouter.UseEncodedPath()
+ v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
+ subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
+ v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
+ v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
+
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
diff --git a/server/http/controller.go b/server/http/controller.go
index 65f6c00f..c1c25725 100644
--- a/server/http/controller.go
+++ b/server/http/controller.go
@@ -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
diff --git a/server/http/controller_v2.go b/server/http/controller_v2.go
new file mode 100644
index 00000000..dfa7b495
--- /dev/null
+++ b/server/http/controller_v2.go
@@ -0,0 +1,647 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+ "cmp"
+ "fmt"
+ "maps"
+ "math"
+ "net/http"
+ "net/url"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/metrics/mem"
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+ "github.com/fatedier/frp/server/http/model"
+ "github.com/fatedier/frp/server/registry"
+)
+
+const (
+ defaultV2Page = 1
+ defaultV2PageSize = 50
+ maxV2PageSize = 200
+
+ v2SystemPruneTypeOfflineProxies = "offline_proxies"
+ v2ProxyTrafficDefaultDays = 7
+ v2ProxyTrafficUnit = "bytes"
+ v2ProxyTrafficGranularity = "day"
+)
+
+var apiV2ProxyTypes = []string{
+ string(v1.ProxyTypeTCP),
+ string(v1.ProxyTypeUDP),
+ string(v1.ProxyTypeHTTP),
+ string(v1.ProxyTypeHTTPS),
+ string(v1.ProxyTypeTCPMUX),
+ string(v1.ProxyTypeSTCP),
+ string(v1.ProxyTypeXTCP),
+ string(v1.ProxyTypeSUDP),
+}
+
+// /api/v2/system/info
+func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
+ info := c.buildServerInfoResp()
+ proxyTypeCounts := info.ProxyTypeCounts
+ if proxyTypeCounts == nil {
+ proxyTypeCounts = map[string]int64{}
+ }
+
+ return model.V2SystemInfoResp{
+ Version: info.Version,
+ Config: model.V2SystemInfoConfigResp{
+ BindPort: info.BindPort,
+ VhostHTTPPort: info.VhostHTTPPort,
+ VhostHTTPSPort: info.VhostHTTPSPort,
+ TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
+ KCPBindPort: info.KCPBindPort,
+ QUICBindPort: info.QUICBindPort,
+ SubdomainHost: info.SubdomainHost,
+ MaxPoolCount: info.MaxPoolCount,
+ MaxPortsPerClient: info.MaxPortsPerClient,
+ HeartbeatTimeout: info.HeartBeatTimeout,
+ AllowPortsStr: info.AllowPortsStr,
+ TLSForce: info.TLSForce,
+ },
+ Status: model.V2SystemInfoStatusResp{
+ TotalTrafficIn: info.TotalTrafficIn,
+ TotalTrafficOut: info.TotalTrafficOut,
+ CurConns: info.CurConns,
+ ClientCounts: info.ClientCounts,
+ ProxyTypeCounts: proxyTypeCounts,
+ },
+ }, nil
+}
+
+// /api/v2/system/prune
+func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
+ pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
+ if err != nil {
+ return nil, err
+ }
+
+ cleared, total := mem.StatsCollector.PruneOfflineProxies()
+ return model.V2SystemPruneResp{
+ Type: pruneType,
+ Cleared: cleared,
+ Total: total,
+ }, nil
+}
+
+// /api/v2/users
+func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
+ page, pageSize, err := parseV2PageParams(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if c.clientRegistry == nil {
+ return nil, fmt.Errorf("client registry unavailable")
+ }
+
+ userStats := make(map[string]*model.V2UserResp)
+ for _, info := range c.clientRegistry.List() {
+ item := getOrCreateV2User(userStats, info.User)
+ item.ClientCount++
+ }
+ for _, proxyInfo := range c.listV2ProxyStats("") {
+ item := getOrCreateV2User(userStats, proxyInfo.User)
+ item.ProxyCount++
+ }
+
+ q := strings.ToLower(ctx.Query("q"))
+ items := make([]model.V2UserResp, 0, len(userStats))
+ for _, item := range userStats {
+ if q != "" && !strings.Contains(strings.ToLower(item.User), q) {
+ continue
+ }
+ items = append(items, *item)
+ }
+ slices.SortFunc(items, func(a, b model.V2UserResp) int {
+ return cmp.Compare(a.User, b.User)
+ })
+
+ return buildV2PageResp(items, page, pageSize), nil
+}
+
+// /api/v2/clients
+func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
+ page, pageSize, err := parseV2PageParams(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if c.clientRegistry == nil {
+ return nil, fmt.Errorf("client registry unavailable")
+ }
+ statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
+ if err != nil {
+ return nil, err
+ }
+
+ userFilter, filterByUser := queryValue(ctx, "user")
+ clientIDFilter := ctx.Query("clientID")
+ runIDFilter := ctx.Query("runID")
+ q := strings.ToLower(ctx.Query("q"))
+
+ records := c.clientRegistry.List()
+ items := make([]model.ClientInfoResp, 0, len(records))
+ for _, info := range records {
+ if filterByUser && info.User != userFilter {
+ continue
+ }
+ if clientIDFilter != "" && info.ClientID() != clientIDFilter {
+ continue
+ }
+ if runIDFilter != "" && info.RunID != runIDFilter {
+ continue
+ }
+ if !matchV2StatusFilter(info.Online, statusFilter) {
+ continue
+ }
+ resp := buildClientInfoResp(info)
+ if q != "" && !matchV2ClientQuery(resp, q) {
+ continue
+ }
+ items = append(items, resp)
+ }
+
+ slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
+ if v := cmp.Compare(a.User, b.User); v != 0 {
+ return v
+ }
+ if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
+ return v
+ }
+ return cmp.Compare(a.Key, b.Key)
+ })
+
+ return buildV2PageResp(items, page, pageSize), nil
+}
+
+// /api/v2/clients/{key}
+func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
+ key, err := decodeV2PathParam(ctx, "key", "client key")
+ if err != nil {
+ return nil, err
+ }
+
+ if c.clientRegistry == nil {
+ return nil, fmt.Errorf("client registry unavailable")
+ }
+
+ info, ok := c.clientRegistry.GetByKey(key)
+ if !ok {
+ return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
+ }
+
+ resp := buildClientInfoResp(info)
+ status := c.buildV2ClientStatus(info)
+ return model.V2ClientDetailResp{
+ ClientInfoResp: resp,
+ Status: status,
+ }, nil
+}
+
+// /api/v2/proxies
+func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
+ page, pageSize, err := parseV2PageParams(ctx)
+ if err != nil {
+ return nil, err
+ }
+ statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
+ if err != nil {
+ return nil, err
+ }
+
+ proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type"))
+ if err != nil {
+ return nil, err
+ }
+ userFilter, filterByUser := queryValue(ctx, "user")
+ clientIDFilter := ctx.Query("clientID")
+ q := strings.ToLower(ctx.Query("q"))
+
+ stats := c.listV2ProxyStats(proxyType)
+ items := make([]model.V2ProxyResp, 0, len(stats))
+ for _, ps := range stats {
+ resp := c.buildV2ProxyResp(ps)
+ if filterByUser && resp.User != userFilter {
+ continue
+ }
+ if clientIDFilter != "" && resp.ClientID != clientIDFilter {
+ continue
+ }
+ if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) {
+ continue
+ }
+ if q != "" && !matchV2ProxyQuery(resp, q) {
+ continue
+ }
+ items = append(items, resp)
+ }
+
+ slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
+ if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
+ return v
+ }
+ return cmp.Compare(a.Name, b.Name)
+ })
+
+ return buildV2PageResp(items, page, pageSize), nil
+}
+
+// /api/v2/proxies/{name}
+func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
+ name, err := decodeV2PathParam(ctx, "name", "proxy name")
+ if err != nil {
+ return nil, err
+ }
+
+ ps := mem.StatsCollector.GetProxyByName(name)
+ if ps == nil {
+ return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
+ }
+ return c.buildV2ProxyResp(ps), nil
+}
+
+// /api/v2/proxies/{name}/traffic
+func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
+ name, err := decodeV2PathParam(ctx, "name", "proxy name")
+ if err != nil {
+ return nil, err
+ }
+
+ proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
+ if proxyTrafficInfo == nil {
+ return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
+ }
+
+ return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
+}
+
+func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
+ raw := ctx.Param(key)
+ if raw == "" {
+ return "", fmt.Errorf("missing %s", label)
+ }
+ decoded, err := url.PathUnescape(raw)
+ if err != nil {
+ return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
+ }
+ return decoded, nil
+}
+
+func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
+ item, ok := items[user]
+ if !ok {
+ item = &model.V2UserResp{User: user}
+ items[user] = item
+ }
+ return item
+}
+
+func parseV2PageParams(ctx *httppkg.Context) (int, int, error) {
+ page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page")
+ if err != nil {
+ return 0, 0, err
+ }
+ pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize")
+ if err != nil {
+ return 0, 0, err
+ }
+ if pageSize > maxV2PageSize {
+ return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize))
+ }
+ if page > math.MaxInt/pageSize {
+ return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large")
+ }
+ return page, pageSize, nil
+}
+
+func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) {
+ if raw == "" {
+ return defaultValue, nil
+ }
+ value, err := strconv.Atoi(raw)
+ if err != nil || value < 1 {
+ return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name))
+ }
+ return value, nil
+}
+
+func parseV2StatusFilter(raw string) (string, error) {
+ status := strings.ToLower(raw)
+ switch status {
+ case "", "all", "online", "offline":
+ return status, nil
+ default:
+ return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline")
+ }
+}
+
+func parseV2ProxyTypeFilter(raw string) (string, error) {
+ proxyType := strings.ToLower(raw)
+ if proxyType == "" {
+ return "", nil
+ }
+ if slices.Contains(apiV2ProxyTypes, proxyType) {
+ return proxyType, nil
+ }
+ return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
+}
+
+func parseV2SystemPruneType(raw string) (string, error) {
+ pruneType := strings.ToLower(raw)
+ switch pruneType {
+ case "":
+ return "", httppkg.NewError(http.StatusBadRequest, "type is required")
+ case v2SystemPruneTypeOfflineProxies:
+ return pruneType, nil
+ default:
+ return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
+ }
+}
+
+func matchV2StatusFilter(online bool, filter string) bool {
+ switch filter {
+ case "", "all":
+ return true
+ case "online":
+ return online
+ case "offline":
+ return !online
+ default:
+ return true
+ }
+}
+
+func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] {
+ total := len(items)
+ return model.V2PageResp[T]{
+ Total: total,
+ Page: page,
+ PageSize: pageSize,
+ Items: paginateV2Items(items, page, pageSize),
+ }
+}
+
+func paginateV2Items[T any](items []T, page, pageSize int) []T {
+ start := (page - 1) * pageSize
+ if start >= len(items) {
+ return []T{}
+ }
+ end := min(start+pageSize, len(items))
+ return items[start:end]
+}
+
+func queryValue(ctx *httppkg.Context, key string) (string, bool) {
+ values, ok := ctx.Req.URL.Query()[key]
+ if !ok {
+ return "", false
+ }
+ if len(values) == 0 {
+ return "", true
+ }
+ return values[0], true
+}
+
+func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
+ return containsV2Query(q,
+ item.Key,
+ item.User,
+ item.ClientID,
+ item.RunID,
+ item.Version,
+ item.WireProtocol,
+ item.Hostname,
+ item.ClientIP,
+ )
+}
+
+func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
+ values := []string{
+ item.Name,
+ item.Spec.Type,
+ item.User,
+ item.ClientID,
+ item.Status.State,
+ }
+
+ switch item.Spec.Type {
+ case string(v1.ProxyTypeTCP):
+ if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
+ values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
+ }
+ case string(v1.ProxyTypeUDP):
+ if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
+ values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
+ }
+ case string(v1.ProxyTypeHTTP):
+ if item.Spec.HTTP != nil {
+ values = append(values, item.Spec.HTTP.CustomDomains...)
+ values = append(values, item.Spec.HTTP.Subdomain)
+ }
+ case string(v1.ProxyTypeHTTPS):
+ if item.Spec.HTTPS != nil {
+ values = append(values, item.Spec.HTTPS.CustomDomains...)
+ values = append(values, item.Spec.HTTPS.Subdomain)
+ }
+ case string(v1.ProxyTypeTCPMUX):
+ if item.Spec.TCPMux != nil {
+ values = append(values, item.Spec.TCPMux.CustomDomains...)
+ values = append(values, item.Spec.TCPMux.Subdomain)
+ }
+ }
+
+ return containsV2Query(q, values...)
+}
+
+func containsV2Query(q string, values ...string) bool {
+ for _, value := range values {
+ if strings.Contains(strings.ToLower(value), q) {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
+ if proxyType != "" {
+ return mem.StatsCollector.GetProxiesByType(proxyType)
+ }
+
+ items := make([]*mem.ProxyStats, 0)
+ for _, t := range apiV2ProxyTypes {
+ items = append(items, mem.StatsCollector.GetProxiesByType(t)...)
+ }
+ return items
+}
+
+func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
+ history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
+ for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
+ history = append(history, model.V2ProxyTrafficPointResp{
+ Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
+ TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
+ TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
+ })
+ }
+
+ return model.V2ProxyTrafficResp{
+ Name: name,
+ Unit: v2ProxyTrafficUnit,
+ Granularity: v2ProxyTrafficGranularity,
+ History: history,
+ }
+}
+
+func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
+ if todayFirstIndex >= len(values) {
+ return 0
+ }
+ return values[todayFirstIndex]
+}
+
+func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
+ status := model.V2ClientStatusResp{State: "offline"}
+ if info.Online {
+ status.State = "online"
+ }
+
+ user := info.User
+ clientID := info.ClientID()
+ for _, ps := range c.listV2ProxyStats("") {
+ if ps.User != user || ps.ClientID != clientID {
+ continue
+ }
+ status.CurConns += ps.CurConns
+ status.ProxyCount++
+ }
+ return status
+}
+
+func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
+ state := "offline"
+ var cfg v1.ProxyConfigurer
+ if c.pxyManager != nil {
+ if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
+ state = "online"
+ cfg = pxy.GetConfigurer()
+ }
+ }
+
+ return model.V2ProxyResp{
+ Name: ps.Name,
+ User: ps.User,
+ ClientID: ps.ClientID,
+ Spec: buildV2ProxySpec(ps.Type, cfg),
+ Status: model.V2ProxyStatusResp{
+ State: state,
+ TodayTrafficIn: ps.TodayTrafficIn,
+ TodayTrafficOut: ps.TodayTrafficOut,
+ CurConns: ps.CurConns,
+ LastStartAt: ps.LastStartAt,
+ LastCloseAt: ps.LastCloseAt,
+ },
+ }
+}
+
+func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
+ spec := model.V2ProxySpec{Type: proxyType}
+
+ switch proxyType {
+ case string(v1.ProxyTypeTCP):
+ block := &model.V2TCPProxySpec{}
+ if c, ok := cfg.(*v1.TCPProxyConfig); ok {
+ block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
+ block.RemotePort = &c.RemotePort
+ }
+ spec.TCP = block
+ case string(v1.ProxyTypeUDP):
+ block := &model.V2UDPProxySpec{}
+ if c, ok := cfg.(*v1.UDPProxyConfig); ok {
+ block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
+ block.RemotePort = &c.RemotePort
+ }
+ spec.UDP = block
+ case string(v1.ProxyTypeHTTP):
+ block := &model.V2HTTPProxySpec{}
+ if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
+ block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
+ block.CustomDomains = slices.Clone(c.CustomDomains)
+ block.Subdomain = c.SubDomain
+ block.Locations = slices.Clone(c.Locations)
+ block.HostHeaderRewrite = c.HostHeaderRewrite
+ }
+ spec.HTTP = block
+ case string(v1.ProxyTypeHTTPS):
+ block := &model.V2HTTPSProxySpec{}
+ if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
+ block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
+ block.CustomDomains = slices.Clone(c.CustomDomains)
+ block.Subdomain = c.SubDomain
+ }
+ spec.HTTPS = block
+ case string(v1.ProxyTypeTCPMUX):
+ block := &model.V2TCPMuxProxySpec{}
+ if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
+ block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
+ block.CustomDomains = slices.Clone(c.CustomDomains)
+ block.Subdomain = c.SubDomain
+ block.Multiplexer = c.Multiplexer
+ block.RouteByHTTPUser = c.RouteByHTTPUser
+ }
+ spec.TCPMux = block
+ case string(v1.ProxyTypeSTCP):
+ block := &model.V2STCPProxySpec{}
+ if c, ok := cfg.(*v1.STCPProxyConfig); ok {
+ block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
+ }
+ spec.STCP = block
+ case string(v1.ProxyTypeSUDP):
+ block := &model.V2SUDPProxySpec{}
+ if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
+ block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
+ }
+ spec.SUDP = block
+ case string(v1.ProxyTypeXTCP):
+ block := &model.V2XTCPProxySpec{}
+ if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
+ block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
+ }
+ spec.XTCP = block
+ }
+
+ return spec
+}
+
+func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
+ return model.V2ProxyBaseSpec{
+ Annotations: maps.Clone(base.Annotations),
+ Metadatas: maps.Clone(base.Metadatas),
+ Transport: &model.V2ProxyTransportSpec{
+ UseEncryption: base.Transport.UseEncryption,
+ UseCompression: base.Transport.UseCompression,
+ BandwidthLimit: base.Transport.BandwidthLimit.String(),
+ BandwidthLimitMode: base.Transport.BandwidthLimitMode,
+ },
+ LoadBalancer: &model.V2ProxyLoadBalancerSpec{
+ Group: base.LoadBalancer.Group,
+ },
+ }
+}
diff --git a/server/http/controller_v2_proxy_spec_test.go b/server/http/controller_v2_proxy_spec_test.go
new file mode 100644
index 00000000..82229bd7
--- /dev/null
+++ b/server/http/controller_v2_proxy_spec_test.go
@@ -0,0 +1,393 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ configtypes "github.com/fatedier/frp/pkg/config/types"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/metrics/mem"
+ "github.com/fatedier/frp/server/http/model"
+)
+
+func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
+ tests := []struct {
+ proxyType string
+ cfg v1.ProxyConfigurer
+ blockKeys []string
+ }{
+ {
+ proxyType: "tcp",
+ cfg: &v1.TCPProxyConfig{
+ ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
+ RemotePort: 6000,
+ },
+ blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
+ },
+ {
+ proxyType: "udp",
+ cfg: &v1.UDPProxyConfig{
+ ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
+ RemotePort: 7000,
+ },
+ blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
+ },
+ {
+ proxyType: "http",
+ cfg: &v1.HTTPProxyConfig{
+ ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
+ DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
+ Locations: []string{"/api"},
+ HTTPUser: "secret-http-user",
+ HTTPPassword: "secret-http-password",
+ HostHeaderRewrite: "backend.example.com",
+ RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
+ ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
+ RouteByHTTPUser: "secret-http-route-user",
+ },
+ blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
+ },
+ {
+ proxyType: "https",
+ cfg: &v1.HTTPSProxyConfig{
+ ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
+ DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
+ },
+ blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
+ },
+ {
+ proxyType: "tcpmux",
+ cfg: &v1.TCPMuxProxyConfig{
+ ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
+ DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
+ HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
+ HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
+ RouteByHTTPUser: "displayed-mux-user",
+ Multiplexer: "httpconnect",
+ },
+ blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
+ },
+ {
+ proxyType: "stcp",
+ cfg: &v1.STCPProxyConfig{
+ ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
+ Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
+ AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
+ },
+ blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
+ },
+ {
+ proxyType: "sudp",
+ cfg: &v1.SUDPProxyConfig{
+ ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
+ Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
+ AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
+ },
+ blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
+ },
+ {
+ proxyType: "xtcp",
+ cfg: &v1.XTCPProxyConfig{
+ ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
+ Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
+ AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
+ },
+ blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.proxyType, func(t *testing.T) {
+ spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
+ raw := mustMarshalJSON(t, spec)
+
+ var specObject map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &specObject); err != nil {
+ t.Fatalf("unmarshal spec failed: %v", err)
+ }
+ assertRawJSONKeys(t, specObject, tt.proxyType, "type")
+
+ var gotType string
+ if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
+ t.Fatalf("unmarshal spec type failed: %v", err)
+ }
+ if gotType != tt.proxyType {
+ t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
+ }
+
+ var block map[string]json.RawMessage
+ if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
+ t.Fatalf("unmarshal active block failed: %v", err)
+ }
+ assertRawJSONKeys(t, block, tt.blockKeys...)
+ assertV2ProxyCommonSpec(t, block)
+ assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
+ assertNoV2ProxySensitiveFields(t, block)
+
+ content := string(raw)
+ for _, secret := range []string{
+ "secret-proxy-name",
+ "secret-group-key",
+ "secret-local-host",
+ "secret-plugin-user",
+ "secret-plugin-password",
+ "secret-health-path",
+ "secret-http-user",
+ "secret-http-password",
+ "secret-request-header",
+ "secret-response-header",
+ "secret-http-route-user",
+ "secret-mux-http-user",
+ "secret-mux-http-password",
+ "secret-stcp-key",
+ "secret-stcp-user",
+ "secret-sudp-key",
+ "secret-sudp-user",
+ "secret-xtcp-key",
+ "secret-xtcp-user",
+ } {
+ if strings.Contains(content, secret) {
+ t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
+ }
+ }
+ })
+ }
+}
+
+func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
+ t.Helper()
+
+ switch proxyType {
+ case "tcp":
+ var block model.V2TCPProxySpec
+ if err := json.Unmarshal(raw, &block); err != nil {
+ t.Fatalf("unmarshal tcp block failed: %v", err)
+ }
+ if block.RemotePort == nil || *block.RemotePort != 6000 {
+ t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
+ }
+ case "udp":
+ var block model.V2UDPProxySpec
+ if err := json.Unmarshal(raw, &block); err != nil {
+ t.Fatalf("unmarshal udp block failed: %v", err)
+ }
+ if block.RemotePort == nil || *block.RemotePort != 7000 {
+ t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
+ }
+ case "http":
+ var block model.V2HTTPProxySpec
+ if err := json.Unmarshal(raw, &block); err != nil {
+ t.Fatalf("unmarshal http block failed: %v", err)
+ }
+ if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
+ block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
+ block.HostHeaderRewrite != "backend.example.com" {
+ t.Fatalf("http fields mismatch: %#v", block)
+ }
+ case "https":
+ var block model.V2HTTPSProxySpec
+ if err := json.Unmarshal(raw, &block); err != nil {
+ t.Fatalf("unmarshal https block failed: %v", err)
+ }
+ if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
+ t.Fatalf("https fields mismatch: %#v", block)
+ }
+ case "tcpmux":
+ var block model.V2TCPMuxProxySpec
+ if err := json.Unmarshal(raw, &block); err != nil {
+ t.Fatalf("unmarshal tcpmux block failed: %v", err)
+ }
+ if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
+ block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
+ t.Fatalf("tcpmux fields mismatch: %#v", block)
+ }
+ }
+}
+
+func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
+ for _, proxyType := range apiV2ProxyTypes {
+ t.Run(proxyType, func(t *testing.T) {
+ resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
+ Name: "offline-" + proxyType,
+ Type: proxyType,
+ })
+ if resp.Status.State != "offline" {
+ t.Fatalf("offline phase mismatch: %#v", resp.Status)
+ }
+
+ var specObject map[string]json.RawMessage
+ if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
+ t.Fatalf("unmarshal offline spec failed: %v", err)
+ }
+ assertRawJSONKeys(t, specObject, proxyType, "type")
+ assertRawJSONKeysFromMessage(t, specObject[proxyType])
+ })
+ }
+}
+
+func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
+ spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
+ ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
+ RemotePort: 7000,
+ })
+
+ var specObject map[string]json.RawMessage
+ if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
+ t.Fatalf("unmarshal mismatched spec failed: %v", err)
+ }
+ assertRawJSONKeys(t, specObject, "tcp", "type")
+ assertRawJSONKeysFromMessage(t, specObject["tcp"])
+}
+
+func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
+ t.Helper()
+
+ bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
+ if err != nil {
+ t.Fatalf("create bandwidth limit failed: %v", err)
+ }
+ enabled := false
+ return v1.ProxyBaseConfig{
+ Name: "secret-proxy-name",
+ Type: proxyType,
+ Enabled: &enabled,
+ Annotations: map[string]string{"annotation-key": "annotation-value"},
+ Metadatas: map[string]string{"metadata-key": "metadata-value"},
+ Transport: v1.ProxyTransport{
+ UseEncryption: true,
+ UseCompression: true,
+ BandwidthLimit: bandwidthLimit,
+ BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
+ ProxyProtocolVersion: "v2",
+ },
+ LoadBalancer: v1.LoadBalancerConfig{
+ Group: "public-group",
+ GroupKey: "secret-group-key",
+ },
+ HealthCheck: v1.HealthCheckConfig{
+ Type: "http",
+ Path: "secret-health-path",
+ },
+ ProxyBackend: v1.ProxyBackend{
+ LocalIP: "secret-local-host",
+ LocalPort: 8080,
+ Plugin: v1.TypedClientPluginOptions{
+ Type: v1.PluginHTTPProxy,
+ ClientPluginOptions: &v1.HTTPProxyPluginOptions{
+ Type: v1.PluginHTTPProxy,
+ HTTPUser: "secret-plugin-user",
+ HTTPPassword: "secret-plugin-password",
+ },
+ },
+ },
+ }
+}
+
+func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
+ t.Helper()
+
+ var annotations map[string]string
+ if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
+ t.Fatalf("unmarshal annotations failed: %v", err)
+ }
+ if annotations["annotation-key"] != "annotation-value" {
+ t.Fatalf("annotations mismatch: %#v", annotations)
+ }
+
+ var metadatas map[string]string
+ if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
+ t.Fatalf("unmarshal metadatas failed: %v", err)
+ }
+ if metadatas["metadata-key"] != "metadata-value" {
+ t.Fatalf("metadatas mismatch: %#v", metadatas)
+ }
+
+ assertRawJSONKeysFromMessage(t, block["transport"],
+ "bandwidthLimit",
+ "bandwidthLimitMode",
+ "useCompression",
+ "useEncryption",
+ )
+ var transport model.V2ProxyTransportSpec
+ if err := json.Unmarshal(block["transport"], &transport); err != nil {
+ t.Fatalf("unmarshal transport failed: %v", err)
+ }
+ if !transport.UseEncryption || !transport.UseCompression ||
+ transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
+ t.Fatalf("transport mismatch: %#v", transport)
+ }
+
+ assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
+ var loadBalancer model.V2ProxyLoadBalancerSpec
+ if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
+ t.Fatalf("unmarshal load balancer failed: %v", err)
+ }
+ if loadBalancer.Group != "public-group" {
+ t.Fatalf("load balancer mismatch: %#v", loadBalancer)
+ }
+}
+
+func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
+ t.Helper()
+
+ forbidden := map[string]struct{}{
+ "allowUsers": {},
+ "enabled": {},
+ "groupKey": {},
+ "healthCheck": {},
+ "httpPassword": {},
+ "httpUser": {},
+ "localIP": {},
+ "localPort": {},
+ "name": {},
+ "natTraversal": {},
+ "plugin": {},
+ "proxyProtocolVersion": {},
+ "requestHeaders": {},
+ "responseHeaders": {},
+ "secretKey": {},
+ "type": {},
+ }
+
+ var walk func(any)
+ walk = func(current any) {
+ switch current := current.(type) {
+ case map[string]any:
+ for key, nested := range current {
+ if _, ok := forbidden[key]; ok {
+ t.Fatalf("sensitive field %q leaked in active block", key)
+ }
+ walk(nested)
+ }
+ case []any:
+ for _, nested := range current {
+ walk(nested)
+ }
+ }
+ }
+
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("marshal active block failed: %v", err)
+ }
+ var decoded any
+ if err := json.Unmarshal(raw, &decoded); err != nil {
+ t.Fatalf("decode active block failed: %v", err)
+ }
+ walk(decoded)
+}
diff --git a/server/http/controller_v2_test.go b/server/http/controller_v2_test.go
new file mode 100644
index 00000000..042cedbc
--- /dev/null
+++ b/server/http/controller_v2_test.go
@@ -0,0 +1,908 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/gorilla/mux"
+
+ "github.com/fatedier/frp/pkg/config/types"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/metrics/mem"
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+ "github.com/fatedier/frp/server/http/model"
+ serverproxy "github.com/fatedier/frp/server/proxy"
+ "github.com/fatedier/frp/server/registry"
+)
+
+type v2EnvelopeForTest[T any] struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ Data T `json:"data"`
+}
+
+type fakeStatsCollector struct {
+ server *mem.ServerStats
+ proxies map[string]*mem.ProxyStats
+ traffic map[string]*mem.ProxyTrafficInfo
+ pruneable map[string]bool
+}
+
+func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
+ if f.server != nil {
+ return f.server
+ }
+ return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
+}
+
+func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
+ items := make([]*mem.ProxyStats, 0)
+ for _, ps := range f.proxies {
+ if ps.Type == proxyType {
+ items = append(items, ps)
+ }
+ }
+ return items
+}
+
+func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
+ ps := f.proxies[proxyName]
+ if ps != nil && ps.Type == proxyType {
+ return ps
+ }
+ return nil
+}
+
+func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
+ return f.proxies[proxyName]
+}
+
+func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
+ return f.traffic[name]
+}
+
+func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
+ return 0, len(f.proxies)
+}
+
+func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
+ total := len(f.proxies)
+ cleared := 0
+ for name := range f.pruneable {
+ if _, ok := f.proxies[name]; ok {
+ delete(f.proxies, name)
+ cleared++
+ }
+ }
+ f.pruneable = map[string]bool{}
+ return cleared, total
+}
+
+func TestAPIV2SystemInfoEnvelope(t *testing.T) {
+ oldStatsCollector := mem.StatsCollector
+ mem.StatsCollector = &fakeStatsCollector{
+ server: &mem.ServerStats{
+ TotalTrafficIn: 1024,
+ TotalTrafficOut: 2048,
+ CurConns: 3,
+ ClientCounts: 4,
+ ProxyTypeCounts: map[string]int64{
+ "tcp": 2,
+ "http": 1,
+ },
+ },
+ proxies: map[string]*mem.ProxyStats{},
+ }
+ t.Cleanup(func() {
+ mem.StatsCollector = oldStatsCollector
+ })
+
+ controller := NewController(&v1.ServerConfig{
+ BindPort: 7000,
+ VhostHTTPPort: 8080,
+ VhostHTTPSPort: 8443,
+ TCPMuxHTTPConnectPort: 9000,
+ KCPBindPort: 7001,
+ QUICBindPort: 7002,
+ SubDomainHost: "example.com",
+ MaxPortsPerClient: 8,
+ AllowPorts: []types.PortsRange{
+ {Start: 1000, End: 1002},
+ {Single: 2000},
+ },
+ Transport: v1.ServerTransportConfig{
+ MaxPoolCount: 5,
+ HeartbeatTimeout: 90,
+ TLS: v1.TLSServerConfig{
+ Force: true,
+ },
+ },
+ }, registry.NewClientRegistry(), serverproxy.NewManager())
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/system/info")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
+ }
+
+ rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
+ if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
+ t.Fatalf("envelope mismatch: %#v", rawResp)
+ }
+ assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
+ assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
+ "allowPortsStr",
+ "bindPort",
+ "heartbeatTimeout",
+ "kcpBindPort",
+ "maxPoolCount",
+ "maxPortsPerClient",
+ "quicBindPort",
+ "subdomainHost",
+ "tcpmuxHTTPConnectPort",
+ "tlsForce",
+ "vhostHTTPPort",
+ "vhostHTTPSPort",
+ )
+ assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
+ "clientCounts",
+ "curConns",
+ "proxyTypeCount",
+ "totalTrafficIn",
+ "totalTrafficOut",
+ )
+
+ systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
+ if systemResp.Data.Version == "" {
+ t.Fatal("version should be set at top level")
+ }
+ if systemResp.Data.Config.BindPort != 7000 ||
+ systemResp.Data.Config.VhostHTTPPort != 8080 ||
+ systemResp.Data.Config.VhostHTTPSPort != 8443 ||
+ systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
+ systemResp.Data.Config.KCPBindPort != 7001 ||
+ systemResp.Data.Config.QUICBindPort != 7002 ||
+ systemResp.Data.Config.SubdomainHost != "example.com" ||
+ systemResp.Data.Config.MaxPoolCount != 5 ||
+ systemResp.Data.Config.MaxPortsPerClient != 8 ||
+ systemResp.Data.Config.HeartbeatTimeout != 90 ||
+ systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
+ !systemResp.Data.Config.TLSForce {
+ t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
+ }
+ if systemResp.Data.Status.TotalTrafficIn != 1024 ||
+ systemResp.Data.Status.TotalTrafficOut != 2048 ||
+ systemResp.Data.Status.CurConns != 3 ||
+ systemResp.Data.Status.ClientCounts != 4 ||
+ systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
+ systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
+ t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
+ }
+}
+
+func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
+ oldStatsCollector := mem.StatsCollector
+ collector := &fakeStatsCollector{
+ proxies: map[string]*mem.ProxyStats{
+ "tcp-offline": {Name: "tcp-offline", Type: "tcp"},
+ "http-offline": {Name: "http-offline", Type: "http"},
+ "udp-offline": {Name: "udp-offline", Type: "udp"},
+ "tcp-online": {Name: "tcp-online", Type: "tcp"},
+ "http-online": {Name: "http-online", Type: "http"},
+ "udp-online": {Name: "udp-online", Type: "udp"},
+ "stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
+ "xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
+ "sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
+ "tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
+ },
+ pruneable: map[string]bool{
+ "tcp-offline": true,
+ "http-offline": true,
+ "udp-offline": true,
+ },
+ }
+ mem.StatsCollector = collector
+ t.Cleanup(func() {
+ mem.StatsCollector = oldStatsCollector
+ })
+
+ controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
+ router := newV2TestRouter(controller)
+
+ resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
+ }
+ rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
+ if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
+ t.Fatalf("envelope mismatch: %#v", rawResp)
+ }
+ assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
+ pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
+ if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
+ t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
+ }
+ if _, ok := collector.proxies["tcp-offline"]; ok {
+ t.Fatal("pruned proxy statistics should be removed")
+ }
+ if _, ok := collector.proxies["tcp-online"]; !ok {
+ t.Fatal("online proxy statistics should remain")
+ }
+
+ resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
+ }
+ pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
+ if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
+ t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
+ }
+}
+
+func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+ errResp := decodeResponse[httppkg.V2Response](t, resp)
+ if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
+ t.Fatalf("missing type error envelope mismatch: %#v", errResp)
+ }
+
+ resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+ errResp = decodeResponse[httppkg.V2Response](t, resp)
+ if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
+ t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
+ }
+}
+
+func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
+ }
+ pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
+ if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
+ t.Fatalf("envelope mismatch: %#v", pageResp)
+ }
+ if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
+ t.Fatalf("page data mismatch: %#v", pageResp.Data)
+ }
+ if got := pageResp.Data.Items[0].User; got != "" {
+ t.Fatalf("first sorted user mismatch, want empty got %q", got)
+ }
+
+ resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
+ emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
+ if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
+ t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
+ }
+
+ resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
+ aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
+ if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
+ t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
+ }
+
+ resp = performRequest(router, "/api/v2/clients?status=offline")
+ offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
+ if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
+ t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
+ }
+}
+
+func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/clients?page=0")
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+ errResp := decodeResponse[httppkg.V2Response](t, resp)
+ if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
+ t.Fatalf("error envelope mismatch: %#v", errResp)
+ }
+
+ resp = performRequest(router, "/api/v2/clients?pageSize=201")
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+
+ resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+}
+
+func TestAPIV2ClientDetailEnvelope(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/clients/alice.client-a")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
+ }
+ detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
+ if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
+ t.Fatalf("client detail mismatch: %#v", detailResp.Data)
+ }
+ if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
+ t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
+ }
+}
+
+func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
+ oldStatsCollector := mem.StatsCollector
+ mem.StatsCollector = &fakeStatsCollector{
+ proxies: map[string]*mem.ProxyStats{
+ "tcp-url": {
+ Name: "tcp-url",
+ Type: "tcp",
+ User: "url",
+ ClientID: "client/a?b#c",
+ CurConns: 7,
+ },
+ },
+ }
+ t.Cleanup(func() {
+ mem.StatsCollector = oldStatsCollector
+ })
+
+ clientRegistry := registry.NewClientRegistry()
+ clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
+ controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
+ router := newV2TestRouter(controller)
+
+ encodedKey := url.PathEscape("url.client/a?b#c")
+ resp := performRequest(router, "/api/v2/clients/"+encodedKey)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
+ }
+ encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
+ if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
+ t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
+ }
+ if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
+ t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
+ }
+}
+
+func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/proxies?type=invalid")
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
+ }
+ errResp := decodeResponse[httppkg.V2Response](t, resp)
+ if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
+ t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
+ }
+
+ resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
+ proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
+ if proxyResp.Data.Total != 1 {
+ t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
+ }
+ proxyItem := proxyResp.Data.Items[0]
+ if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
+ t.Fatalf("proxy item mismatch: %#v", proxyItem)
+ }
+ rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
+ assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
+ var rawListSpec map[string]json.RawMessage
+ if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
+ t.Fatalf("unmarshal list proxy spec failed: %v", err)
+ }
+ assertRawJSONKeys(t, rawListSpec, "tcp", "type")
+ assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
+
+ resp = performRequest(router, "/api/v2/proxies/tcp-alice")
+ rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
+ assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
+ "curConns",
+ "lastCloseAt",
+ "lastStartAt",
+ "phase",
+ "todayTrafficIn",
+ "todayTrafficOut",
+ )
+ proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
+ if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
+ t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
+ }
+ assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
+ var rawDetailSpec map[string]json.RawMessage
+ if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
+ t.Fatalf("unmarshal detail proxy spec failed: %v", err)
+ }
+ assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
+ assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
+ if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
+ t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
+ }
+
+ resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
+ userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
+ if userResp.Data.Total != 3 {
+ t.Fatalf("user total mismatch: %#v", userResp.Data)
+ }
+ expectedProxyCounts := map[string]int{
+ "": 1,
+ "alice": 2,
+ "bob": 1,
+ }
+ for _, item := range userResp.Data.Items {
+ if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
+ t.Fatalf("user counts mismatch: %#v", item)
+ }
+ }
+}
+
+func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
+ oldStatsCollector := mem.StatsCollector
+ mem.StatsCollector = &fakeStatsCollector{
+ proxies: map[string]*mem.ProxyStats{
+ "ssh": {Name: "ssh", Type: "tcp"},
+ },
+ traffic: map[string]*mem.ProxyTrafficInfo{
+ "ssh": {
+ Name: "ssh",
+ TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
+ TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
+ },
+ },
+ }
+ t.Cleanup(func() {
+ mem.StatsCollector = oldStatsCollector
+ })
+
+ controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
+ }
+ rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
+ if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
+ t.Fatalf("envelope mismatch: %#v", rawResp)
+ }
+ assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
+
+ trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
+ if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
+ t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
+ }
+ if len(trafficResp.Data.History) != 7 {
+ t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
+ }
+
+ wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
+ wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
+ var prevDate time.Time
+ for i, point := range trafficResp.Data.History {
+ assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
+ if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
+ t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
+ }
+ parsedDate, err := time.Parse(time.DateOnly, point.Date)
+ if err != nil {
+ t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
+ }
+ if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
+ t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
+ }
+ prevDate = parsedDate
+ }
+}
+
+func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/v2/proxies/missing/traffic")
+ if resp.Code != http.StatusNotFound {
+ t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
+ }
+ errResp := decodeResponse[httppkg.V2Response](t, resp)
+ if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
+ t.Fatalf("not found envelope mismatch: %#v", errResp)
+ }
+}
+
+func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
+ name := "folder/ssh?x#y"
+ oldStatsCollector := mem.StatsCollector
+ mem.StatsCollector = &fakeStatsCollector{
+ proxies: map[string]*mem.ProxyStats{
+ name: {Name: name, Type: "tcp", User: "encoded"},
+ },
+ traffic: map[string]*mem.ProxyTrafficInfo{
+ name: {
+ Name: name,
+ TrafficIn: []int64{1},
+ TrafficOut: []int64{2},
+ },
+ },
+ }
+ t.Cleanup(func() {
+ mem.StatsCollector = oldStatsCollector
+ })
+
+ controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
+ router := newV2TestRouter(controller)
+ encodedName := url.PathEscape(name)
+
+ resp := performRequest(router, "/api/v2/proxies/"+encodedName)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
+ }
+ detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
+ if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
+ t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
+ }
+
+ resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
+ }
+ trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
+ if trafficResp.Data.Name != name {
+ t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
+ }
+ if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
+ t.Fatalf("encoded traffic latest point mismatch: %#v", got)
+ }
+}
+
+func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
+ controller := newV2TestController(t)
+ handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
+ req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
+ req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
+ resp := httptest.NewRecorder()
+ handler.ServeHTTP(resp, req)
+
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
+ }
+ errResp := decodeResponse[httppkg.V2Response](t, resp)
+ if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
+ t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
+ }
+}
+
+func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
+ tests := []struct {
+ name string
+ item model.V2ProxyResp
+ q string
+ want bool
+ }{
+ {
+ name: "tcp remote port",
+ item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
+ Type: "tcp",
+ TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
+ }},
+ q: "6000",
+ want: true,
+ },
+ {
+ name: "udp remote port",
+ item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
+ Type: "udp",
+ UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
+ }},
+ q: "7000",
+ want: true,
+ },
+ {
+ name: "remote port does not match colon form",
+ item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
+ Type: "tcp",
+ TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
+ }},
+ q: ":6000",
+ want: false,
+ },
+ {
+ name: "http custom domain",
+ item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
+ Type: "http",
+ HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
+ }},
+ q: "app.example.com",
+ want: true,
+ },
+ {
+ name: "https subdomain",
+ item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
+ Type: "https",
+ HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
+ }},
+ q: "portal",
+ want: true,
+ },
+ {
+ name: "subdomain does not match expanded host",
+ item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
+ Type: "https",
+ HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
+ }},
+ q: "portal.example.com",
+ want: false,
+ },
+ {
+ name: "tcpmux custom domain",
+ item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
+ Type: "tcpmux",
+ TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
+ }},
+ q: "mux.example.com",
+ want: true,
+ },
+ {
+ name: "offline shell does not match online spec fields",
+ item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
+ Type: "tcp",
+ TCP: &model.V2TCPProxySpec{},
+ }},
+ q: "6000",
+ want: false,
+ },
+ {
+ name: "offline shell does not contribute zero remote port",
+ item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
+ Type: "tcp",
+ TCP: &model.V2TCPProxySpec{},
+ }},
+ q: "0",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
+ t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestLegacyAPIResponsesRemainBare(t *testing.T) {
+ controller := newV2TestController(t)
+ router := newV2TestRouter(controller)
+
+ resp := performRequest(router, "/api/serverinfo")
+ var serverInfo model.ServerInfoResp
+ if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
+ t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
+ }
+ if serverInfo.Version == "" {
+ t.Fatal("legacy serverinfo version should be set")
+ }
+ var serverInfoRaw map[string]json.RawMessage
+ if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
+ t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
+ }
+ if _, ok := serverInfoRaw["data"]; ok {
+ t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
+ }
+ if _, ok := serverInfoRaw["config"]; ok {
+ t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
+ }
+
+ resp = performRequest(router, "/api/clients")
+ var clients []model.ClientInfoResp
+ if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
+ t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
+ }
+ if len(clients) != 3 {
+ t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
+ }
+
+ resp = performRequest(router, "/api/proxy/tcp")
+ var proxies model.GetProxyInfoResp
+ if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
+ t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
+ }
+ if len(proxies.Proxies) != 2 {
+ t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
+ }
+ var envelope httppkg.V2Response
+ if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
+ t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
+ }
+
+ resp = performRequest(router, "/api/traffic/tcp-alice")
+ var traffic model.GetProxyTrafficResp
+ if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
+ t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
+ }
+ if traffic.Name != "tcp-alice" ||
+ len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
+ len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
+ t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
+ }
+ var trafficRaw map[string]json.RawMessage
+ if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
+ t.Fatalf("unmarshal legacy traffic object failed: %v", err)
+ }
+ if _, ok := trafficRaw["data"]; ok {
+ t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
+ }
+}
+
+func v2TestIntPtr(value int) *int {
+ return &value
+}
+
+func newV2TestController(t *testing.T) *Controller {
+ t.Helper()
+
+ oldStatsCollector := mem.StatsCollector
+ mem.StatsCollector = &fakeStatsCollector{
+ proxies: map[string]*mem.ProxyStats{
+ "tcp-empty": {
+ Name: "tcp-empty",
+ Type: "tcp",
+ User: "",
+ ClientID: "legacy-client",
+ TodayTrafficIn: 10,
+ TodayTrafficOut: 20,
+ CurConns: 1,
+ },
+ "tcp-alice": {
+ Name: "tcp-alice",
+ Type: "tcp",
+ User: "alice",
+ ClientID: "client-a",
+ TodayTrafficIn: 30,
+ TodayTrafficOut: 40,
+ CurConns: 2,
+ LastStartTime: "07-08 12:30:00",
+ LastCloseTime: "07-08 12:31:40",
+ LastStartAt: 1783504200,
+ LastCloseAt: 1783504300,
+ },
+ "http-alice": {
+ Name: "http-alice",
+ Type: "http",
+ User: "alice",
+ ClientID: "client-a",
+ CurConns: 3,
+ },
+ "udp-bob": {
+ Name: "udp-bob",
+ Type: "udp",
+ User: "bob",
+ ClientID: "client-b",
+ },
+ },
+ traffic: map[string]*mem.ProxyTrafficInfo{
+ "tcp-alice": {
+ Name: "tcp-alice",
+ TrafficIn: []int64{7, 6},
+ TrafficOut: []int64{70, 60},
+ },
+ },
+ }
+ t.Cleanup(func() {
+ mem.StatsCollector = oldStatsCollector
+ })
+
+ clientRegistry := registry.NewClientRegistry()
+ clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
+ clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
+ clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
+ clientRegistry.MarkOfflineByRunID("run-b")
+
+ return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
+}
+
+func newV2TestRouter(controller *Controller) *mux.Router {
+ router := mux.NewRouter()
+ router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
+ router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
+ router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
+ router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
+ encodedPathRouter := router.NewRoute().Subrouter()
+ encodedPathRouter.UseEncodedPath()
+ encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
+ router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
+ encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
+ encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
+ router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
+ router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
+ router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
+ router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
+ return router
+}
+
+func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
+ return performRequestWithMethod(handler, http.MethodGet, target)
+}
+
+func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, target, nil)
+ resp := httptest.NewRecorder()
+ handler.ServeHTTP(resp, req)
+ return resp
+}
+
+func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
+ t.Helper()
+
+ var out T
+ if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
+ t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
+ }
+ return out
+}
+
+func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
+ t.Helper()
+
+ if len(raw) != len(want) {
+ t.Fatalf("json keys mismatch, want %v got %v", want, raw)
+ }
+ for _, key := range want {
+ if _, ok := raw[key]; !ok {
+ t.Fatalf("json key %q missing from %v", key, raw)
+ }
+ }
+}
+
+func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
+ t.Helper()
+
+ var out map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &out); err != nil {
+ t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
+ }
+ assertRawJSONKeys(t, out, want...)
+}
+
+func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
+ t.Helper()
+
+ out, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("marshal json failed: %v", err)
+ }
+ return out
+}
diff --git a/server/http/model/v2.go b/server/http/model/v2.go
new file mode 100644
index 00000000..383041b1
--- /dev/null
+++ b/server/http/model/v2.go
@@ -0,0 +1,179 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package model
+
+type V2PageResp[T any] struct {
+ Total int `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"pageSize"`
+ Items []T `json:"items"`
+}
+
+type V2SystemInfoResp struct {
+ Version string `json:"version"`
+ Config V2SystemInfoConfigResp `json:"config"`
+ Status V2SystemInfoStatusResp `json:"status"`
+}
+
+type V2SystemInfoConfigResp struct {
+ BindPort int `json:"bindPort"`
+ VhostHTTPPort int `json:"vhostHTTPPort"`
+ VhostHTTPSPort int `json:"vhostHTTPSPort"`
+ TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
+ KCPBindPort int `json:"kcpBindPort"`
+ QUICBindPort int `json:"quicBindPort"`
+ SubdomainHost string `json:"subdomainHost"`
+ MaxPoolCount int64 `json:"maxPoolCount"`
+ MaxPortsPerClient int64 `json:"maxPortsPerClient"`
+ HeartbeatTimeout int64 `json:"heartbeatTimeout"`
+ AllowPortsStr string `json:"allowPortsStr"`
+ TLSForce bool `json:"tlsForce"`
+}
+
+type V2SystemInfoStatusResp struct {
+ TotalTrafficIn int64 `json:"totalTrafficIn"`
+ TotalTrafficOut int64 `json:"totalTrafficOut"`
+ CurConns int64 `json:"curConns"`
+ ClientCounts int64 `json:"clientCounts"`
+ ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
+}
+
+type V2SystemPruneResp struct {
+ Type string `json:"type"`
+ Cleared int `json:"cleared"`
+ Total int `json:"total"`
+}
+
+type V2UserResp struct {
+ User string `json:"user"`
+ ClientCount int `json:"clientCount"`
+ ProxyCount int `json:"proxyCount"`
+}
+
+type V2ClientDetailResp struct {
+ ClientInfoResp
+ Status V2ClientStatusResp `json:"status"`
+}
+
+type V2ClientStatusResp struct {
+ State string `json:"phase"`
+ CurConns int64 `json:"curConns"`
+ ProxyCount int64 `json:"proxyCount"`
+}
+
+type V2ProxyResp struct {
+ Name string `json:"name"`
+ User string `json:"user"`
+ ClientID string `json:"clientID"`
+ Spec V2ProxySpec `json:"spec"`
+ Status V2ProxyStatusResp `json:"status"`
+}
+
+type V2ProxySpec struct {
+ Type string `json:"type"`
+
+ TCP *V2TCPProxySpec `json:"tcp,omitempty"`
+ UDP *V2UDPProxySpec `json:"udp,omitempty"`
+ HTTP *V2HTTPProxySpec `json:"http,omitempty"`
+ HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
+ TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
+ STCP *V2STCPProxySpec `json:"stcp,omitempty"`
+ SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
+ XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
+}
+
+type V2ProxyBaseSpec struct {
+ Annotations map[string]string `json:"annotations,omitempty"`
+ Metadatas map[string]string `json:"metadatas,omitempty"`
+ Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
+ LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
+}
+
+type V2ProxyTransportSpec struct {
+ UseEncryption bool `json:"useEncryption"`
+ UseCompression bool `json:"useCompression"`
+ BandwidthLimit string `json:"bandwidthLimit"`
+ BandwidthLimitMode string `json:"bandwidthLimitMode"`
+}
+
+type V2ProxyLoadBalancerSpec struct {
+ Group string `json:"group"`
+}
+
+type V2TCPProxySpec struct {
+ V2ProxyBaseSpec
+ RemotePort *int `json:"remotePort,omitempty"`
+}
+
+type V2UDPProxySpec struct {
+ V2ProxyBaseSpec
+ RemotePort *int `json:"remotePort,omitempty"`
+}
+
+type V2HTTPProxySpec struct {
+ V2ProxyBaseSpec
+ CustomDomains []string `json:"customDomains,omitempty"`
+ Subdomain string `json:"subdomain,omitempty"`
+ Locations []string `json:"locations,omitempty"`
+ HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
+}
+
+type V2HTTPSProxySpec struct {
+ V2ProxyBaseSpec
+ CustomDomains []string `json:"customDomains,omitempty"`
+ Subdomain string `json:"subdomain,omitempty"`
+}
+
+type V2TCPMuxProxySpec struct {
+ V2ProxyBaseSpec
+ CustomDomains []string `json:"customDomains,omitempty"`
+ Subdomain string `json:"subdomain,omitempty"`
+ Multiplexer string `json:"multiplexer,omitempty"`
+ RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
+}
+
+type V2STCPProxySpec struct {
+ V2ProxyBaseSpec
+}
+
+type V2SUDPProxySpec struct {
+ V2ProxyBaseSpec
+}
+
+type V2XTCPProxySpec struct {
+ V2ProxyBaseSpec
+}
+
+type V2ProxyStatusResp struct {
+ State string `json:"phase"`
+ TodayTrafficIn int64 `json:"todayTrafficIn"`
+ TodayTrafficOut int64 `json:"todayTrafficOut"`
+ CurConns int64 `json:"curConns"`
+ LastStartAt int64 `json:"lastStartAt,omitempty"`
+ LastCloseAt int64 `json:"lastCloseAt,omitempty"`
+}
+
+type V2ProxyTrafficResp struct {
+ Name string `json:"name"`
+ Unit string `json:"unit"`
+ Granularity string `json:"granularity"`
+ History []V2ProxyTrafficPointResp `json:"history"`
+}
+
+type V2ProxyTrafficPointResp struct {
+ Date string `json:"date"`
+ TrafficIn int64 `json:"trafficIn"`
+ TrafficOut int64 `json:"trafficOut"`
+}
diff --git a/test/e2e/v1/plugin/client.go b/test/e2e/v1/plugin/client.go
index 1ea25a6e..f2afad41 100644
--- a/test/e2e/v1/plugin/client.go
+++ b/test/e2e/v1/plugin/client.go
@@ -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()
+ })
})
diff --git a/web/frps/components.d.ts b/web/frps/components.d.ts
index b5a18d79..cd53c58f 100644
--- a/web/frps/components.d.ts
+++ b/web/frps/components.d.ts
@@ -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']
diff --git a/web/frps/src/api/client.ts b/web/frps/src/api/client.ts
index 41d382cc..7a3982b7 100644
--- a/web/frps/src/api/client.ts
+++ b/web/frps/src/api/client.ts
@@ -1,10 +1,30 @@
-import { http } from './http'
-import type { ClientInfoData } from '../types/client'
+import { buildQueryString, http } from './http'
+import type { V2Page } from './http'
+import type { ClientInfoData, ClientListV2Params } from '../types/client'
export const getClients = () => {
return http.get('../api/clients')
}
+export const getClientsV2 = (params: ClientListV2Params = {}) => {
+ return http.getV2>(
+ `../api/v2/clients${buildQueryString({
+ page: params.page,
+ pageSize: params.pageSize,
+ status:
+ params.status && params.status !== 'all' ? params.status : undefined,
+ q: params.q || undefined,
+ user: params.user,
+ clientID: params.clientID || undefined,
+ runID: params.runID || undefined,
+ })}`,
+ )
+}
+
export const getClient = (key: string) => {
return http.get(`../api/clients/${key}`)
}
+
+export const getClientV2 = (key: string) => {
+ return http.getV2(`../api/v2/clients/${encodeURIComponent(key)}`)
+}
diff --git a/web/frps/src/api/http.ts b/web/frps/src/api/http.ts
index d6291e9e..2f59c1b7 100644
--- a/web/frps/src/api/http.ts
+++ b/web/frps/src/api/http.ts
@@ -11,6 +11,21 @@ class HTTPError extends Error {
}
}
+export interface V2Envelope {
+ code: number
+ msg: string
+ data: T
+}
+
+export interface V2Page {
+ total: number
+ page: number
+ pageSize: number
+ items: T[]
+}
+
+type QueryParamValue = string | number | boolean | null | undefined
+
async function request(url: string, options: RequestInit = {}): Promise {
const defaultOptions: RequestInit = {
credentials: 'include',
@@ -34,9 +49,62 @@ async function request(url: string, options: RequestInit = {}): Promise {
return response.json()
}
+async function requestV2(
+ url: string,
+ options: RequestInit = {},
+): Promise {
+ const defaultOptions: RequestInit = {
+ credentials: 'include',
+ }
+
+ const response = await fetch(url, { ...defaultOptions, ...options })
+ const envelope = (await response.json().catch(() => null)) as
+ | V2Envelope
+ | null
+
+ if (!response.ok) {
+ throw new HTTPError(
+ response.status,
+ response.statusText,
+ envelope?.msg || `HTTP ${response.status}`,
+ )
+ }
+
+ if (!envelope || typeof envelope.code !== 'number') {
+ throw new Error('Invalid API v2 response')
+ }
+
+ if (envelope.code >= 400) {
+ throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
+ }
+
+ return envelope.data
+}
+
+export const buildQueryString = (
+ params: Record,
+): string => {
+ const query = new URLSearchParams()
+ for (const [key, value] of Object.entries(params)) {
+ if (value === null || value === undefined) continue
+ query.append(key, String(value))
+ }
+ const text = query.toString()
+ return text ? `?${text}` : ''
+}
+
export const http = {
get: (url: string, options?: RequestInit) =>
request(url, { ...options, method: 'GET' }),
+ getV2: (url: string, options?: RequestInit) =>
+ requestV2(url, { ...options, method: 'GET' }),
+ postV2: (url: string, body?: any, options?: RequestInit) =>
+ requestV2(url, {
+ ...options,
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
+ body: JSON.stringify(body),
+ }),
post: (url: string, body?: any, options?: RequestInit) =>
request(url, {
...options,
diff --git a/web/frps/src/api/proxy.ts b/web/frps/src/api/proxy.ts
index b323a588..de07cac7 100644
--- a/web/frps/src/api/proxy.ts
+++ b/web/frps/src/api/proxy.ts
@@ -1,26 +1,118 @@
-import { http } from './http'
+import { buildQueryString, http } from './http'
+import { formatUnixSeconds } from '../utils/format'
+import type { V2Page } from './http'
import type {
GetProxyResponse,
+ ProxyListV2Params,
ProxyStatsInfo,
+ ProxyV2Info,
+ ProxyV2Spec,
+ ProxyV2SpecBlocks,
+ ProxyV2Type,
TrafficResponse,
} from '../types/proxy'
+export interface SystemPruneResponse {
+ type: 'offline_proxies'
+ cleared: number
+ total: number
+}
+
export const getProxiesByType = (type: string) => {
return http.get(`../api/proxy/${type}`)
}
+export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
+ const page = await http.getV2>(
+ `../api/v2/proxies${buildQueryString({
+ page: params.page,
+ pageSize: params.pageSize,
+ status:
+ params.status && params.status !== 'all' ? params.status : undefined,
+ q: params.q || undefined,
+ type: params.type || undefined,
+ user: params.user,
+ clientID: params.clientID || undefined,
+ })}`,
+ )
+
+ return {
+ ...page,
+ items: page.items.map(toLegacyProxyStats),
+ }
+}
+
+const getActiveProxySpec = (
+ spec: ProxyV2Spec,
+): ProxyV2SpecBlocks[ProxyV2Type] => {
+ switch (spec.type) {
+ case 'tcp':
+ return spec.tcp
+ case 'udp':
+ return spec.udp
+ case 'http':
+ return spec.http
+ case 'https':
+ return spec.https
+ case 'tcpmux':
+ return spec.tcpmux
+ case 'stcp':
+ return spec.stcp
+ case 'sudp':
+ return spec.sudp
+ case 'xtcp':
+ return spec.xtcp
+ default:
+ return assertNever(spec)
+ }
+}
+
+const assertNever = (value: never): never => {
+ throw new Error(`Unsupported proxy spec: ${JSON.stringify(value)}`)
+}
+
+export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => {
+ const type = proxy.spec.type
+ const activeSpec = getActiveProxySpec(proxy.spec)
+
+ return {
+ name: proxy.name,
+ type,
+ conf: proxy.status.phase === 'offline' ? null : activeSpec,
+ user: proxy.user,
+ clientID: proxy.clientID,
+ todayTrafficIn: proxy.status.todayTrafficIn,
+ todayTrafficOut: proxy.status.todayTrafficOut,
+ curConns: proxy.status.curConns,
+ lastStartTime: formatUnixSeconds(proxy.status.lastStartAt),
+ lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt),
+ status: proxy.status.phase,
+ }
+}
+
export const getProxy = (type: string, name: string) => {
return http.get(`../api/proxy/${type}/${name}`)
}
+export const getProxyByNameV2 = async (name: string) => {
+ const proxy = await http.getV2(
+ `../api/v2/proxies/${encodeURIComponent(name)}`,
+ )
+ return toLegacyProxyStats(proxy)
+}
+
export const getProxyByName = (name: string) => {
return http.get(`../api/proxies/${name}`)
}
export const getProxyTraffic = (name: string) => {
- return http.get(`../api/traffic/${name}`)
+ return http.getV2(
+ `../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
+ )
}
export const clearOfflineProxies = () => {
- return http.delete('../api/proxies?status=offline')
+ return http.postV2(
+ '../api/v2/system/prune?type=offline_proxies',
+ )
}
diff --git a/web/frps/src/api/server.ts b/web/frps/src/api/server.ts
index f46f21d3..f6621ca8 100644
--- a/web/frps/src/api/server.ts
+++ b/web/frps/src/api/server.ts
@@ -2,5 +2,5 @@ import { http } from './http'
import type { ServerInfo } from '../types/server'
export const getServerInfo = () => {
- return http.get('../api/serverinfo')
+ return http.getV2('../api/v2/system/info')
}
diff --git a/web/frps/src/components/Traffic.vue b/web/frps/src/components/Traffic.vue
index 9142f3e0..9f4d422c 100644
--- a/web/frps/src/components/Traffic.vue
+++ b/web/frps/src/components/Traffic.vue
@@ -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({
diff --git a/web/frps/src/types/client.ts b/web/frps/src/types/client.ts
index 85413ff0..94d59098 100644
--- a/web/frps/src/types/client.ts
+++ b/web/frps/src/types/client.ts
@@ -7,9 +7,25 @@ export interface ClientInfoData {
wireProtocol?: string
hostname: string
clientIP?: string
- metas?: Record
firstConnectedAt: number
lastConnectedAt: number
disconnectedAt?: number
online: boolean
+ status?: ClientStatus
+}
+
+export interface ClientStatus {
+ phase: 'online' | 'offline'
+ curConns: number
+ proxyCount: number
+}
+
+export interface ClientListV2Params {
+ page?: number
+ pageSize?: number
+ status?: 'all' | 'online' | 'offline'
+ q?: string
+ user?: string
+ clientID?: string
+ runID?: string
}
diff --git a/web/frps/src/types/proxy.ts b/web/frps/src/types/proxy.ts
index f5bc8e65..27606ef5 100644
--- a/web/frps/src/types/proxy.ts
+++ b/web/frps/src/types/proxy.ts
@@ -1,5 +1,6 @@
export interface ProxyStatsInfo {
name: string
+ type?: string
conf: any
user: string
clientID: string
@@ -15,8 +16,114 @@ export interface GetProxyResponse {
proxies: ProxyStatsInfo[]
}
+export interface ProxyListV2Params {
+ page?: number
+ pageSize?: number
+ status?: 'all' | 'online' | 'offline'
+ q?: string
+ type?: string
+ user?: string
+ clientID?: string
+}
+
+export interface ProxyV2Info {
+ name: string
+ user: string
+ clientID: string
+ spec: ProxyV2Spec
+ status: ProxyV2Status
+}
+
+export interface ProxyV2BaseSpec {
+ annotations?: Record
+ metadatas?: Record
+ transport?: {
+ useEncryption: boolean
+ useCompression: boolean
+ bandwidthLimit: string
+ bandwidthLimitMode: string
+ }
+ loadBalancer?: {
+ group: string
+ }
+}
+
+export interface ProxyV2TCPBlock extends ProxyV2BaseSpec {
+ remotePort?: number
+}
+
+export interface ProxyV2UDPBlock extends ProxyV2BaseSpec {
+ remotePort?: number
+}
+
+export interface ProxyV2HTTPBlock extends ProxyV2BaseSpec {
+ customDomains?: string[]
+ subdomain?: string
+ locations?: string[]
+ hostHeaderRewrite?: string
+}
+
+export interface ProxyV2HTTPSBlock extends ProxyV2BaseSpec {
+ customDomains?: string[]
+ subdomain?: string
+}
+
+export interface ProxyV2TCPMuxBlock extends ProxyV2BaseSpec {
+ customDomains?: string[]
+ subdomain?: string
+ multiplexer?: string
+ routeByHTTPUser?: string
+}
+
+export type ProxyV2STCPBlock = ProxyV2BaseSpec
+
+export type ProxyV2SUDPBlock = ProxyV2BaseSpec
+
+export type ProxyV2XTCPBlock = ProxyV2BaseSpec
+
+export interface ProxyV2SpecBlocks {
+ tcp: ProxyV2TCPBlock
+ udp: ProxyV2UDPBlock
+ http: ProxyV2HTTPBlock
+ https: ProxyV2HTTPSBlock
+ tcpmux: ProxyV2TCPMuxBlock
+ stcp: ProxyV2STCPBlock
+ sudp: ProxyV2SUDPBlock
+ xtcp: ProxyV2XTCPBlock
+}
+
+export type ProxyV2Type = keyof ProxyV2SpecBlocks
+
+type ProxyV2SpecFor = {
+ type: T
+} & {
+ [K in T]: ProxyV2SpecBlocks[K]
+} & {
+ [K in Exclude]?: never
+}
+
+export type ProxyV2Spec = {
+ [T in ProxyV2Type]: ProxyV2SpecFor
+}[ProxyV2Type]
+
+export interface ProxyV2Status {
+ phase: 'online' | 'offline'
+ todayTrafficIn: number
+ todayTrafficOut: number
+ curConns: number
+ lastStartAt?: number
+ lastCloseAt?: number
+}
+
export interface TrafficResponse {
name: string
- trafficIn: number[]
- trafficOut: number[]
+ unit: 'bytes'
+ granularity: 'day'
+ history: TrafficPoint[]
+}
+
+export interface TrafficPoint {
+ date: string
+ trafficIn: number
+ trafficOut: number
}
diff --git a/web/frps/src/types/server.ts b/web/frps/src/types/server.ts
index ada31cc5..b4796e90 100644
--- a/web/frps/src/types/server.ts
+++ b/web/frps/src/types/server.ts
@@ -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
diff --git a/web/frps/src/utils/client.ts b/web/frps/src/utils/client.ts
index 8d26eeb8..0cb9e5a0 100644
--- a/web/frps/src/utils/client.ts
+++ b/web/frps/src/utils/client.ts
@@ -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
@@ -10,11 +10,11 @@ export class Client {
wireProtocol: string
hostname: string
ip: string
- metas: Map
firstConnectedAt: Date
lastConnectedAt: Date
disconnectedAt?: Date
online: boolean
+ status: ClientStatus
constructor(data: ClientInfoData) {
this.key = data.key
@@ -25,18 +25,17 @@ export class Client {
this.wireProtocol = data.wireProtocol || ''
this.hostname = data.hostname
this.ip = data.clientIP || ''
- this.metas = new Map()
- if (data.metas) {
- for (const [key, value] of Object.entries(data.metas)) {
- this.metas.set(key, value)
- }
- }
this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
if (data.disconnectedAt && data.disconnectedAt > 0) {
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
}
this.online = data.online
+ this.status = data.status || {
+ phase: this.online ? 'online' : 'offline',
+ curConns: 0,
+ proxyCount: 0,
+ }
}
get displayName(): string {
@@ -46,10 +45,6 @@ export class Client {
return this.runID
}
- get shortRunId(): string {
- return this.runID.substring(0, 8)
- }
-
get wireProtocolLabel(): string {
if (!this.wireProtocol) return ''
return `Protocol ${this.wireProtocol}`
@@ -67,28 +62,4 @@ export class Client {
if (!this.disconnectedAt) return ''
return formatDistanceToNow(this.disconnectedAt)
}
-
- get statusColor(): string {
- return this.online ? 'success' : 'danger'
- }
-
- get metasArray(): Array<{ key: string; value: string }> {
- const arr: Array<{ key: string; value: string }> = []
- this.metas.forEach((value, key) => {
- arr.push({ key, value })
- })
- return arr
- }
-
- matchesFilter(searchText: string): boolean {
- const search = searchText.toLowerCase()
- return (
- this.key.toLowerCase().includes(search) ||
- this.user.toLowerCase().includes(search) ||
- this.clientID.toLowerCase().includes(search) ||
- this.runID.toLowerCase().includes(search) ||
- this.wireProtocol.toLowerCase().includes(search) ||
- this.hostname.toLowerCase().includes(search)
- )
- }
}
diff --git a/web/frps/src/utils/format.ts b/web/frps/src/utils/format.ts
index 11cd398f..cca9e31a 100644
--- a/web/frps/src/utils/format.ts
+++ b/web/frps/src/utils/format.ts
@@ -19,6 +19,14 @@ export function formatDistanceToNow(date: Date): string {
return Math.floor(seconds) + ' seconds ago'
}
+export function formatUnixSeconds(seconds?: number): string {
+ if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return ''
+
+ const date = new Date(seconds * 1000)
+ const pad = (value: number) => value.toString().padStart(2, '0')
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
+}
+
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
if (bytes === 0) return '0 B'
diff --git a/web/frps/src/views/ClientDetail.vue b/web/frps/src/views/ClientDetail.vue
index 42c9a25e..17a50468 100644
--- a/web/frps/src/views/ClientDetail.vue
+++ b/web/frps/src/views/ClientDetail.vue
@@ -55,7 +55,7 @@
Connections
- {{ totalConnections }}
+ {{ client.status.curConns }}
Run ID
@@ -85,7 +85,7 @@
-
+
-
+
No proxies match "{{ proxySearch }}"
+
@@ -130,13 +141,13 @@
@@ -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;
diff --git a/web/frps/src/views/Clients.vue b/web/frps/src/views/Clients.vue
index 7afaa481..43d9f25b 100644
--- a/web/frps/src/views/Clients.vue
+++ b/web/frps/src/views/Clients.vue
@@ -16,7 +16,9 @@
>
{{ tab.label }}
-
{{ tab.count }}
+
{{
+ tab.count
+ }}
@@ -33,9 +35,9 @@
@@ -274,6 +331,11 @@ onUnmounted(() => {
padding: 60px 0;
}
+.pagination-section {
+ display: flex;
+ justify-content: flex-end;
+}
+
/* Dark mode adjustments */
html.dark .status-tab {
background: var(--el-bg-color-overlay);
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
.status-tab {
flex-shrink: 0;
}
+
+ .pagination-section {
+ justify-content: center;
+ }
}
diff --git a/web/frps/src/views/Proxies.vue b/web/frps/src/views/Proxies.vue
index b7fdc700..734c85a5 100644
--- a/web/frps/src/views/Proxies.vue
+++ b/web/frps/src/views/Proxies.vue
@@ -8,7 +8,7 @@
-
+
Refresh
@@ -27,36 +27,6 @@
clearable
class="main-search"
/>
-
-
-
- All Clients
-
- {{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)
-
-
- {{ client.label }}
-
-
-
@@ -74,9 +44,9 @@
-
+
+
+
diff --git a/web/frps/src/views/ProxyDetail.vue b/web/frps/src/views/ProxyDetail.vue
index a6720424..b4487dd2 100644
--- a/web/frps/src/views/ProxyDetail.vue
+++ b/web/frps/src/views/ProxyDetail.vue
@@ -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)
diff --git a/web/frps/src/views/ServerOverview.vue b/web/frps/src/views/ServerOverview.vue
index ed3c4a64..5e38e1e5 100644
--- a/web/frps/src/views/ServerOverview.vue
+++ b/web/frps/src/views/ServerOverview.vue
@@ -4,7 +4,7 @@
@@ -54,7 +54,7 @@
Inbound
- {{ formatFileSize(data.totalTrafficIn) }}
+ {{ formatFileSize(data.status.totalTrafficIn) }}
@@ -66,7 +66,7 @@
Outbound
- {{ formatFileSize(data.totalTrafficOut) }}
+ {{ formatFileSize(data.status.totalTrafficOut) }}
@@ -83,7 +83,7 @@
Bind Port
- {{ data.bindPort }}
+ {{ data.config.bindPort }}
-
+
KCP Port
- {{ data.kcpBindPort }}
+ {{ data.config.kcpBindPort }}
-
+
QUIC Port
- {{ data.quicBindPort }}
+ {{ data.config.quicBindPort }}
-
+
HTTP Port
- {{ data.vhostHTTPPort }}
+ {{ data.config.vhostHTTPPort }}
-
+
HTTPS Port
- {{ data.vhostHTTPSPort }}
+ {{ data.config.vhostHTTPSPort }}
-
+
TCPMux Port
- {{ data.tcpmuxHTTPConnectPort }}
+ {{ data.config.tcpmuxHTTPConnectPort }}
-
+
Subdomain Host
- {{ data.subdomainHost }}
+ {{ data.config.subdomainHost }}
Max Pool Count
- {{ data.maxPoolCount }}
+ {{ data.config.maxPoolCount }}
Max Ports/Client
- {{ data.maxPortsPerClient }}
+ {{ maxPortsPerClientLabel }}
-
+
Allow Ports
- {{ data.allowPortsStr }}
+ {{ data.config.allowPortsStr }}
-
+
TLS Force
Enabled
Heartbeat Timeout
- {{ data.heartbeatTimeout }}s
+ {{ data.config.heartbeatTimeout }}s
@@ -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
({
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,
+ 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,
diff --git a/web/package-lock.json b/web/package-lock.json
index d9a66bc8..d5ce255c 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -147,14 +147,13 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
- "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"aix"
@@ -164,14 +163,13 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
- "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
@@ -181,14 +179,13 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
- "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
@@ -198,14 +195,13 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
- "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
@@ -215,14 +211,13 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
- "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -232,14 +227,13 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
- "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -249,14 +243,13 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
- "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -266,14 +259,13 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
- "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -283,14 +275,13 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
- "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -300,14 +291,13 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
- "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -317,14 +307,13 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
- "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -334,14 +323,13 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
- "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -351,14 +339,13 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
- "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -368,14 +355,13 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
- "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -385,14 +371,13 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
- "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -402,14 +387,13 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
- "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -419,14 +403,13 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
- "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -436,14 +419,13 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
- "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -453,14 +435,13 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
- "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -470,14 +451,13 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
- "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -487,14 +467,13 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
- "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -504,14 +483,13 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
- "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"openharmony"
@@ -521,14 +499,13 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
- "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"sunos"
@@ -538,14 +515,13 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
- "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -555,14 +531,13 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
- "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -572,14 +547,13 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
- "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -954,76 +928,6 @@
"node": ">= 8"
}
},
- "node_modules/@nuxt/kit": {
- "version": "3.21.2",
- "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
- "integrity": "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "c12": "^3.3.3",
- "consola": "^3.4.2",
- "defu": "^6.1.4",
- "destr": "^2.0.5",
- "errx": "^0.1.0",
- "exsolve": "^1.0.8",
- "ignore": "^7.0.5",
- "jiti": "^2.6.1",
- "klona": "^2.0.6",
- "knitwork": "^1.3.0",
- "mlly": "^1.8.1",
- "ohash": "^2.0.11",
- "pathe": "^2.0.3",
- "pkg-types": "^2.3.0",
- "rc9": "^3.0.0",
- "scule": "^1.3.0",
- "semver": "^7.7.4",
- "tinyglobby": "^0.2.15",
- "ufo": "^1.6.3",
- "unctx": "^2.5.0",
- "untyped": "^2.0.0"
- },
- "engines": {
- "node": ">=18.12.0"
- }
- },
- "node_modules/@nuxt/kit/node_modules/confbox": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
- "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
- "node_modules/@nuxt/kit/node_modules/ignore": {
- "version": "7.0.5",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
- "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/@nuxt/kit/node_modules/pkg-types": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
- "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "confbox": "^0.2.2",
- "exsolve": "^1.0.7",
- "pathe": "^2.0.3"
- }
- },
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
@@ -2567,9 +2471,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
- "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
+ "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"dependencies": {
"balanced-match": "^4.0.2"
@@ -3450,12 +3354,11 @@
}
},
"node_modules/esbuild": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
- "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
@@ -3463,32 +3366,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.4",
- "@esbuild/android-arm": "0.27.4",
- "@esbuild/android-arm64": "0.27.4",
- "@esbuild/android-x64": "0.27.4",
- "@esbuild/darwin-arm64": "0.27.4",
- "@esbuild/darwin-x64": "0.27.4",
- "@esbuild/freebsd-arm64": "0.27.4",
- "@esbuild/freebsd-x64": "0.27.4",
- "@esbuild/linux-arm": "0.27.4",
- "@esbuild/linux-arm64": "0.27.4",
- "@esbuild/linux-ia32": "0.27.4",
- "@esbuild/linux-loong64": "0.27.4",
- "@esbuild/linux-mips64el": "0.27.4",
- "@esbuild/linux-ppc64": "0.27.4",
- "@esbuild/linux-riscv64": "0.27.4",
- "@esbuild/linux-s390x": "0.27.4",
- "@esbuild/linux-x64": "0.27.4",
- "@esbuild/netbsd-arm64": "0.27.4",
- "@esbuild/netbsd-x64": "0.27.4",
- "@esbuild/openbsd-arm64": "0.27.4",
- "@esbuild/openbsd-x64": "0.27.4",
- "@esbuild/openharmony-arm64": "0.27.4",
- "@esbuild/sunos-x64": "0.27.4",
- "@esbuild/win32-arm64": "0.27.4",
- "@esbuild/win32-ia32": "0.27.4",
- "@esbuild/win32-x64": "0.27.4"
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escape-string-regexp": {
@@ -4859,11 +4762,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
- "license": "MIT",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"dependencies": {
"argparse": "^2.0.1"
},
@@ -6276,11 +6188,10 @@
}
},
"node_modules/shell-quote": {
- "version": "1.8.3",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
- "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
+ "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -7460,12 +7371,12 @@
}
},
"node_modules/vite": {
- "version": "7.3.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
- "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true,
"dependencies": {
- "esbuild": "^0.27.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",