diff --git a/README.md b/README.md index d850c854..8dad3b44 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp) [![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases) -[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp) [![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](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 @@

Proxies

- {{ filteredProxies.length }} + {{ total }}
Loading...
-
+
-
+

No proxies match "{{ proxySearch }}"

No proxies found

+
+ +
@@ -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 @@
-
+
@@ -44,82 +46,123 @@
+ +
+ +
@@ -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" /> - - - -
@@ -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",