Compare commits

...

23 Commits

Author SHA1 Message Date
fatedier
7b6e01f04f Merge pull request #5411 from fatedier/dev
Release v0.70.0
2026-07-11 18:50:35 +08:00
fatedier
5396947c7c remove retired Go Report Card badge (#5409) 2026-07-11 15:59:51 +08:00
fatedier
fe61093bc2 bump version to 0.70.0 (#5406) 2026-07-11 14:28:19 +08:00
fatedier
54aeb2a7b0 feat(server): add typed frps v2 proxy specs (#5405) 2026-07-11 10:34:04 +08:00
fatedier
84be1938e4 api: expose v2 proxy timestamps as unix seconds (#5402) 2026-07-09 14:47:19 +08:00
fatedier
becee40715 docs: update API v2 release notes (#5400) 2026-07-08 15:54:16 +08:00
fatedier
17e788d43b refactor: clean up frps v2 frontend models (#5399) 2026-07-08 13:10:55 +08:00
fatedier
68509f5d44 Add frps proxy traffic API v2 (#5398) 2026-07-08 02:05:28 +08:00
fatedier
5cd722b177 feat: add system prune API v2 (#5395) 2026-07-07 13:00:53 +08:00
fatedier
5876beceac feat: add system info API v2 (#5394) 2026-07-07 02:00:44 +08:00
fatedier
7fe152e3aa web/frps: use API v2 for client and proxy details (#5386) 2026-06-30 01:33:57 +08:00
fatedier
7c343fc6e7 web: bump esbuild to 0.28.1 and remove unused ElPopover type (#5385) 2026-06-29 23:21:22 +08:00
fatedier
a3b3b35b69 feat(ui): default proxies view to all tab (#5384) 2026-06-29 22:49:08 +08:00
fatedier
8dd26c6961 Merge pull request #5350 from fatedier/dev
Release v0.69.1
2026-06-01 18:02:22 +08:00
fatedier
c8c1e5116c Merge pull request #5323 from fatedier/dev
Release v0.69.0
2026-05-22 00:55:23 +08:00
fatedier
4ec8de973f Merge pull request #5287 from fatedier/dev
bump version to v0.68.1
2026-04-14 01:28:33 +08:00
fatedier
5bfcea3d0c merge dev to master (#5254)
* ci: bump github actions to latest major versions (#5251)

* docker: copy shared web directory for npm workspace builds
2026-03-20 15:54:26 +08:00
fatedier
0a1b4ab21f Merge pull request #5249 from fatedier/dev
bump version
2026-03-20 13:56:28 +08:00
fatedier
5f575b8442 Merge pull request #5147 from fatedier/dev
bump version
2026-01-31 14:01:40 +08:00
fatedier
a1348cdf00 bump version (#5112) 2026-01-04 14:54:13 +08:00
fatedier
2f5e1f7945 Merge pull request #4999 from fatedier/dev
bump version
2025-09-25 20:23:42 +08:00
fatedier
22ae8166d3 Merge pull request #4925 from fatedier/dev
bump version
2025-08-10 23:26:32 +08:00
fatedier
af6bc6369d Merge pull request #4849 from fatedier/dev
bump version
2025-06-25 11:51:19 +08:00
28 changed files with 1822 additions and 455 deletions

View File

@@ -2,7 +2,6 @@
[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp) [![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) [![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) [![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) [README](README.md) | [中文文档](README_zh.md)
@@ -13,6 +12,16 @@ frp is an open source project with its ongoing development made possible entirel
<h3 align="center">Gold Sponsors</h3> <h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start--> <!--gold sponsors start-->
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center"> <p align="center">
<a href="https://jb.gg/frp" target="_blank"> <a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg"> <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
@@ -30,16 +39,6 @@ frp is an open source project with its ongoing development made possible entirel
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub> <sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a> </a>
</p> </p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end--> <!--gold sponsors end-->
## What is frp? ## What is frp?

View File

@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3> <h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start--> <!--gold sponsors start-->
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center"> <p align="center">
<a href="https://jb.gg/frp" target="_blank"> <a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg"> <img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
@@ -32,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub> <sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a> </a>
</p> </p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end--> <!--gold sponsors end-->
## 为什么使用 frp ## 为什么使用 frp

View File

@@ -1,7 +1,6 @@
## Features ## Features
* Added dashboard API v2 pagination endpoints for users, clients, and proxies. * 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.
* The frps dashboard Clients and Proxies pages now use API v2 pagination and server-side search, including proxy type filtering and searchable proxy spec fields such as remote ports, custom domains, and subdomains.
## Fixes ## Fixes

View File

@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
defer m.mu.Unlock() defer m.mu.Unlock()
total = len(m.info.ProxyStatistics) total = len(m.info.ProxyStatistics)
for name, data := range m.info.ProxyStatistics { for name, data := range m.info.ProxyStatistics {
if !data.LastCloseTime.IsZero() && if m.shouldClearProxyStats(data, continuousOfflineDuration) {
data.LastStartTime.Before(data.LastCloseTime) &&
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
delete(m.info.ProxyStatistics, name) delete(m.info.ProxyStatistics, name)
count++ count++
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String()) log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
@@ -106,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
return count, total return count, total
} }
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
return !data.LastCloseTime.IsZero() &&
data.LastStartTime.Before(data.LastCloseTime) &&
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
}
func (m *serverMetrics) ClearOfflineProxies() (int, int) { func (m *serverMetrics) ClearOfflineProxies() (int, int) {
return m.clearUselessInfo(0) return m.clearUselessInfo(0)
} }
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
return m.clearUselessInfo(0)
}
func (m *serverMetrics) NewClient() { func (m *serverMetrics) NewClient() {
m.info.ClientCounts.Inc(1) m.info.ClientCounts.Inc(1)
} }
@@ -231,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
} }
if !proxyStats.LastStartTime.IsZero() { if !proxyStats.LastStartTime.IsZero() {
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05") ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
ps.LastStartAt = proxyStats.LastStartTime.Unix()
} }
if !proxyStats.LastCloseTime.IsZero() { if !proxyStats.LastCloseTime.IsZero() {
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05") ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
} }
return ps return ps
} }

View File

@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
clk.SetTime(closedAt) clk.SetTime(closedAt)
metrics.CloseProxy("proxy", "tcp") metrics.CloseProxy("proxy", "tcp")
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime) require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
stats := metrics.GetProxyByName("proxy")
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
require.Equal(start.Unix(), stats.LastStartAt)
require.Equal(closedAt.Unix(), stats.LastCloseAt)
} }
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) { func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
@@ -43,6 +49,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
require.Empty(metrics.info.ProxyStatistics) require.Empty(metrics.info.ProxyStatistics)
} }
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
cleared, total := metrics.ClearOfflineProxies()
require.Equal(1, cleared)
require.Equal(2, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
}
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
require := require.New(t)
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
Name: "restarted",
LastStartTime: start.Add(30 * time.Second),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
Name: "same-time",
LastStartTime: start,
LastCloseTime: start,
}
cleared, total := metrics.PruneOfflineProxies()
require.Equal(1, cleared)
require.Equal(4, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
require.True(metrics.hasProxyStatistics("restarted"))
require.True(metrics.hasProxyStatistics("same-time"))
cleared, total = metrics.PruneOfflineProxies()
require.Equal(0, cleared)
require.Equal(3, total)
}
func TestServerMetricsRunUsesClockTicker(t *testing.T) { func TestServerMetricsRunUsesClockTicker(t *testing.T) {
require := require.New(t) require := require.New(t)

View File

@@ -41,6 +41,8 @@ type ProxyStats struct {
TodayTrafficOut int64 TodayTrafficOut int64
LastStartTime string LastStartTime string
LastCloseTime string LastCloseTime string
LastStartAt int64
LastCloseAt int64
CurConns int64 CurConns int64
} }
@@ -85,4 +87,5 @@ type Collector interface {
GetProxyByName(proxyName string) *ProxyStats GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int) ClearOfflineProxies() (int, int)
PruneOfflineProxies() (int, int)
} }

View File

@@ -14,7 +14,7 @@
package version package version
var version = "0.69.1" var version = "0.70.0"
func Full() string { func Full() string {
return version return version

View File

@@ -49,12 +49,15 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE") subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET") subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET") subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
v2EncodedPathRouter := subRouter.NewRoute().Subrouter() v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
v2EncodedPathRouter.UseEncodedPath() v2EncodedPathRouter.UseEncodedPath()
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET") v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET") subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET") v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
// view // view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")

View File

@@ -58,8 +58,12 @@ func NewController(
// /api/serverinfo // /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) { func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
return c.buildServerInfoResp(), nil
}
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
serverStats := mem.StatsCollector.GetServer() serverStats := mem.StatsCollector.GetServer()
svrResp := model.ServerInfoResp{ return model.ServerInfoResp{
Version: version.Full(), Version: version.Full(),
BindPort: c.serverCfg.BindPort, BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort, VhostHTTPPort: c.serverCfg.VhostHTTPPort,
@@ -80,8 +84,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
ClientCounts: serverStats.ClientCounts, ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts, ProxyTypeCounts: serverStats.ProxyTypeCounts,
} }
return svrResp, nil
} }
// /api/clients // /api/clients

View File

@@ -17,12 +17,14 @@ package http
import ( import (
"cmp" "cmp"
"fmt" "fmt"
"maps"
"math" "math"
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time"
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem" "github.com/fatedier/frp/pkg/metrics/mem"
@@ -35,6 +37,11 @@ const (
defaultV2Page = 1 defaultV2Page = 1
defaultV2PageSize = 50 defaultV2PageSize = 50
maxV2PageSize = 200 maxV2PageSize = 200
v2SystemPruneTypeOfflineProxies = "offline_proxies"
v2ProxyTrafficDefaultDays = 7
v2ProxyTrafficUnit = "bytes"
v2ProxyTrafficGranularity = "day"
) )
var apiV2ProxyTypes = []string{ var apiV2ProxyTypes = []string{
@@ -48,6 +55,55 @@ var apiV2ProxyTypes = []string{
string(v1.ProxyTypeSUDP), 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 // /api/v2/users
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) { func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx) page, pageSize, err := parseV2PageParams(ctx)
@@ -139,15 +195,10 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
// /api/v2/clients/{key} // /api/v2/clients/{key}
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) { func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
key := ctx.Param("key") key, err := decodeV2PathParam(ctx, "key", "client key")
if key == "" {
return nil, fmt.Errorf("missing client key")
}
decodedKey, err := url.PathUnescape(key)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid client key %q: %w", key, err) return nil, err
} }
key = decodedKey
if c.clientRegistry == nil { if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable") return nil, fmt.Errorf("client registry unavailable")
@@ -205,7 +256,7 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
} }
slices.SortFunc(items, func(a, b model.V2ProxyResp) int { slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
if v := cmp.Compare(a.Type, b.Type); v != 0 { if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
return v return v
} }
return cmp.Compare(a.Name, b.Name) return cmp.Compare(a.Name, b.Name)
@@ -216,9 +267,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
// /api/v2/proxies/{name} // /api/v2/proxies/{name}
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) { func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name") name, err := decodeV2PathParam(ctx, "name", "proxy name")
if name == "" { if err != nil {
return nil, fmt.Errorf("missing proxy name") return nil, err
} }
ps := mem.StatsCollector.GetProxyByName(name) ps := mem.StatsCollector.GetProxyByName(name)
@@ -228,6 +279,33 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
return c.buildV2ProxyResp(ps), nil 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 { func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
item, ok := items[user] item, ok := items[user]
if !ok { if !ok {
@@ -287,6 +365,18 @@ func parseV2ProxyTypeFilter(raw string) (string, error) {
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp") 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 { func matchV2StatusFilter(online bool, filter string) bool {
switch filter { switch filter {
case "", "all": case "", "all":
@@ -346,26 +436,36 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool { func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
values := []string{ values := []string{
item.Name, item.Name,
item.Type, item.Spec.Type,
item.User, item.User,
item.ClientID, item.ClientID,
item.Status.State, item.Status.State,
} }
switch spec := item.Spec.(type) { switch item.Spec.Type {
case *model.TCPOutConf: case string(v1.ProxyTypeTCP):
values = append(values, strconv.Itoa(spec.RemotePort)) if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
case *model.UDPOutConf: values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
values = append(values, strconv.Itoa(spec.RemotePort)) }
case *model.HTTPOutConf: case string(v1.ProxyTypeUDP):
values = append(values, spec.CustomDomains...) if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
values = append(values, spec.SubDomain) values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
case *model.HTTPSOutConf: }
values = append(values, spec.CustomDomains...) case string(v1.ProxyTypeHTTP):
values = append(values, spec.SubDomain) if item.Spec.HTTP != nil {
case *model.TCPMuxOutConf: values = append(values, item.Spec.HTTP.CustomDomains...)
values = append(values, spec.CustomDomains...) values = append(values, item.Spec.HTTP.Subdomain)
values = append(values, spec.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...) return containsV2Query(q, values...)
@@ -392,6 +492,31 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
return items 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 { func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
status := model.V2ClientStatusResp{State: "offline"} status := model.V2ClientStatusResp{State: "offline"}
if info.Online { if info.Online {
@@ -412,27 +537,111 @@ func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2Clien
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp { func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
state := "offline" state := "offline"
var spec any var cfg v1.ProxyConfigurer
if c.pxyManager != nil { if c.pxyManager != nil {
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok { if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
state = "online" state = "online"
spec = getConfFromConfigurer(pxy.GetConfigurer()) cfg = pxy.GetConfigurer()
} }
} }
return model.V2ProxyResp{ return model.V2ProxyResp{
Name: ps.Name, Name: ps.Name,
Type: ps.Type,
User: ps.User, User: ps.User,
ClientID: ps.ClientID, ClientID: ps.ClientID,
Spec: spec, Spec: buildV2ProxySpec(ps.Type, cfg),
Status: model.V2ProxyStatusResp{ Status: model.V2ProxyStatusResp{
State: state, State: state,
TodayTrafficIn: ps.TodayTrafficIn, TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut, TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns, CurConns: ps.CurConns,
LastStartTime: ps.LastStartTime, LastStartAt: ps.LastStartAt,
LastCloseTime: ps.LastCloseTime, 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,
}, },
} }
} }

View File

@@ -0,0 +1,393 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"encoding/json"
"strings"
"testing"
configtypes "github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
"github.com/fatedier/frp/server/http/model"
)
func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
tests := []struct {
proxyType string
cfg v1.ProxyConfigurer
blockKeys []string
}{
{
proxyType: "tcp",
cfg: &v1.TCPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
RemotePort: 6000,
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
},
{
proxyType: "udp",
cfg: &v1.UDPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
RemotePort: 7000,
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
},
{
proxyType: "http",
cfg: &v1.HTTPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
Locations: []string{"/api"},
HTTPUser: "secret-http-user",
HTTPPassword: "secret-http-password",
HostHeaderRewrite: "backend.example.com",
RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
RouteByHTTPUser: "secret-http-route-user",
},
blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
},
{
proxyType: "https",
cfg: &v1.HTTPSProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
},
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
},
{
proxyType: "tcpmux",
cfg: &v1.TCPMuxProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
RouteByHTTPUser: "displayed-mux-user",
Multiplexer: "httpconnect",
},
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
},
{
proxyType: "stcp",
cfg: &v1.STCPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
},
{
proxyType: "sudp",
cfg: &v1.SUDPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
},
{
proxyType: "xtcp",
cfg: &v1.XTCPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
},
}
for _, tt := range tests {
t.Run(tt.proxyType, func(t *testing.T) {
spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
raw := mustMarshalJSON(t, spec)
var specObject map[string]json.RawMessage
if err := json.Unmarshal(raw, &specObject); err != nil {
t.Fatalf("unmarshal spec failed: %v", err)
}
assertRawJSONKeys(t, specObject, tt.proxyType, "type")
var gotType string
if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
t.Fatalf("unmarshal spec type failed: %v", err)
}
if gotType != tt.proxyType {
t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
}
var block map[string]json.RawMessage
if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
t.Fatalf("unmarshal active block failed: %v", err)
}
assertRawJSONKeys(t, block, tt.blockKeys...)
assertV2ProxyCommonSpec(t, block)
assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
assertNoV2ProxySensitiveFields(t, block)
content := string(raw)
for _, secret := range []string{
"secret-proxy-name",
"secret-group-key",
"secret-local-host",
"secret-plugin-user",
"secret-plugin-password",
"secret-health-path",
"secret-http-user",
"secret-http-password",
"secret-request-header",
"secret-response-header",
"secret-http-route-user",
"secret-mux-http-user",
"secret-mux-http-password",
"secret-stcp-key",
"secret-stcp-user",
"secret-sudp-key",
"secret-sudp-user",
"secret-xtcp-key",
"secret-xtcp-user",
} {
if strings.Contains(content, secret) {
t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
}
}
})
}
}
func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
t.Helper()
switch proxyType {
case "tcp":
var block model.V2TCPProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal tcp block failed: %v", err)
}
if block.RemotePort == nil || *block.RemotePort != 6000 {
t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
}
case "udp":
var block model.V2UDPProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal udp block failed: %v", err)
}
if block.RemotePort == nil || *block.RemotePort != 7000 {
t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
}
case "http":
var block model.V2HTTPProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal http block failed: %v", err)
}
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
block.HostHeaderRewrite != "backend.example.com" {
t.Fatalf("http fields mismatch: %#v", block)
}
case "https":
var block model.V2HTTPSProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal https block failed: %v", err)
}
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
t.Fatalf("https fields mismatch: %#v", block)
}
case "tcpmux":
var block model.V2TCPMuxProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal tcpmux block failed: %v", err)
}
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
t.Fatalf("tcpmux fields mismatch: %#v", block)
}
}
}
func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
for _, proxyType := range apiV2ProxyTypes {
t.Run(proxyType, func(t *testing.T) {
resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
Name: "offline-" + proxyType,
Type: proxyType,
})
if resp.Status.State != "offline" {
t.Fatalf("offline phase mismatch: %#v", resp.Status)
}
var specObject map[string]json.RawMessage
if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
t.Fatalf("unmarshal offline spec failed: %v", err)
}
assertRawJSONKeys(t, specObject, proxyType, "type")
assertRawJSONKeysFromMessage(t, specObject[proxyType])
})
}
}
func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
RemotePort: 7000,
})
var specObject map[string]json.RawMessage
if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
t.Fatalf("unmarshal mismatched spec failed: %v", err)
}
assertRawJSONKeys(t, specObject, "tcp", "type")
assertRawJSONKeysFromMessage(t, specObject["tcp"])
}
func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
t.Helper()
bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
if err != nil {
t.Fatalf("create bandwidth limit failed: %v", err)
}
enabled := false
return v1.ProxyBaseConfig{
Name: "secret-proxy-name",
Type: proxyType,
Enabled: &enabled,
Annotations: map[string]string{"annotation-key": "annotation-value"},
Metadatas: map[string]string{"metadata-key": "metadata-value"},
Transport: v1.ProxyTransport{
UseEncryption: true,
UseCompression: true,
BandwidthLimit: bandwidthLimit,
BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
ProxyProtocolVersion: "v2",
},
LoadBalancer: v1.LoadBalancerConfig{
Group: "public-group",
GroupKey: "secret-group-key",
},
HealthCheck: v1.HealthCheckConfig{
Type: "http",
Path: "secret-health-path",
},
ProxyBackend: v1.ProxyBackend{
LocalIP: "secret-local-host",
LocalPort: 8080,
Plugin: v1.TypedClientPluginOptions{
Type: v1.PluginHTTPProxy,
ClientPluginOptions: &v1.HTTPProxyPluginOptions{
Type: v1.PluginHTTPProxy,
HTTPUser: "secret-plugin-user",
HTTPPassword: "secret-plugin-password",
},
},
},
}
}
func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
t.Helper()
var annotations map[string]string
if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
t.Fatalf("unmarshal annotations failed: %v", err)
}
if annotations["annotation-key"] != "annotation-value" {
t.Fatalf("annotations mismatch: %#v", annotations)
}
var metadatas map[string]string
if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
t.Fatalf("unmarshal metadatas failed: %v", err)
}
if metadatas["metadata-key"] != "metadata-value" {
t.Fatalf("metadatas mismatch: %#v", metadatas)
}
assertRawJSONKeysFromMessage(t, block["transport"],
"bandwidthLimit",
"bandwidthLimitMode",
"useCompression",
"useEncryption",
)
var transport model.V2ProxyTransportSpec
if err := json.Unmarshal(block["transport"], &transport); err != nil {
t.Fatalf("unmarshal transport failed: %v", err)
}
if !transport.UseEncryption || !transport.UseCompression ||
transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
t.Fatalf("transport mismatch: %#v", transport)
}
assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
var loadBalancer model.V2ProxyLoadBalancerSpec
if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
t.Fatalf("unmarshal load balancer failed: %v", err)
}
if loadBalancer.Group != "public-group" {
t.Fatalf("load balancer mismatch: %#v", loadBalancer)
}
}
func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
t.Helper()
forbidden := map[string]struct{}{
"allowUsers": {},
"enabled": {},
"groupKey": {},
"healthCheck": {},
"httpPassword": {},
"httpUser": {},
"localIP": {},
"localPort": {},
"name": {},
"natTraversal": {},
"plugin": {},
"proxyProtocolVersion": {},
"requestHeaders": {},
"responseHeaders": {},
"secretKey": {},
"type": {},
}
var walk func(any)
walk = func(current any) {
switch current := current.(type) {
case map[string]any:
for key, nested := range current {
if _, ok := forbidden[key]; ok {
t.Fatalf("sensitive field %q leaked in active block", key)
}
walk(nested)
}
case []any:
for _, nested := range current {
walk(nested)
}
}
}
raw, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal active block failed: %v", err)
}
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode active block failed: %v", err)
}
walk(decoded)
}

View File

@@ -22,9 +22,11 @@ import (
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"testing" "testing"
"time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem" "github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http" httppkg "github.com/fatedier/frp/pkg/util/http"
@@ -40,10 +42,16 @@ type v2EnvelopeForTest[T any] struct {
} }
type fakeStatsCollector struct { type fakeStatsCollector struct {
proxies map[string]*mem.ProxyStats server *mem.ServerStats
proxies map[string]*mem.ProxyStats
traffic map[string]*mem.ProxyTrafficInfo
pruneable map[string]bool
} }
func (f *fakeStatsCollector) GetServer() *mem.ServerStats { func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
if f.server != nil {
return f.server
}
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}} return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
} }
@@ -70,13 +78,210 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
} }
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo { func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
return nil return f.traffic[name]
} }
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) { func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
return 0, len(f.proxies) 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) { func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
controller := newV2TestController(t) controller := newV2TestController(t)
router := newV2TestRouter(controller) router := newV2TestRouter(controller)
@@ -211,15 +416,42 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data) t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
} }
proxyItem := proxyResp.Data.Items[0] proxyItem := proxyResp.Data.Items[0]
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" { if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
t.Fatalf("proxy item mismatch: %#v", proxyItem) 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") 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) proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" { if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data) 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") resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp) userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
@@ -238,6 +470,140 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
} }
} }
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) { func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -247,66 +613,85 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
}{ }{
{ {
name: "tcp remote port", name: "tcp remote port",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{ item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
RemotePort: 6000, Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
}}, }},
q: "6000", q: "6000",
want: true, want: true,
}, },
{ {
name: "udp remote port", name: "udp remote port",
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{ item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
RemotePort: 7000, Type: "udp",
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
}}, }},
q: "7000", q: "7000",
want: true, want: true,
}, },
{ {
name: "remote port does not match colon form", name: "remote port does not match colon form",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{ item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
RemotePort: 6000, Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
}}, }},
q: ":6000", q: ":6000",
want: false, want: false,
}, },
{ {
name: "http custom domain", name: "http custom domain",
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{ item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}}, Type: "http",
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
}}, }},
q: "app.example.com", q: "app.example.com",
want: true, want: true,
}, },
{ {
name: "https subdomain", name: "https subdomain",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{ item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
DomainConfig: v1.DomainConfig{SubDomain: "portal"}, Type: "https",
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
}}, }},
q: "portal", q: "portal",
want: true, want: true,
}, },
{ {
name: "subdomain does not match expanded host", name: "subdomain does not match expanded host",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{ item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
DomainConfig: v1.DomainConfig{SubDomain: "portal"}, Type: "https",
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
}}, }},
q: "portal.example.com", q: "portal.example.com",
want: false, want: false,
}, },
{ {
name: "tcpmux custom domain", name: "tcpmux custom domain",
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{ item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}}, Type: "tcpmux",
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
}}, }},
q: "mux.example.com", q: "mux.example.com",
want: true, want: true,
}, },
{ {
name: "nil spec does not match spec fields", name: "offline shell does not match online spec fields",
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil}, item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{},
}},
q: "6000", q: "6000",
want: false, 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 { for _, tt := range tests {
@@ -322,7 +707,26 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
controller := newV2TestController(t) controller := newV2TestController(t)
router := newV2TestRouter(controller) router := newV2TestRouter(controller)
resp := performRequest(router, "/api/clients") resp := performRequest(router, "/api/serverinfo")
var serverInfo model.ServerInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
}
if serverInfo.Version == "" {
t.Fatal("legacy serverinfo version should be set")
}
var serverInfoRaw map[string]json.RawMessage
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
}
if _, ok := serverInfoRaw["data"]; ok {
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
}
if _, ok := serverInfoRaw["config"]; ok {
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
}
resp = performRequest(router, "/api/clients")
var clients []model.ClientInfoResp var clients []model.ClientInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil { 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()) t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
@@ -343,6 +747,28 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 { 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) 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 { func newV2TestController(t *testing.T) *Controller {
@@ -368,6 +794,10 @@ func newV2TestController(t *testing.T) *Controller {
TodayTrafficIn: 30, TodayTrafficIn: 30,
TodayTrafficOut: 40, TodayTrafficOut: 40,
CurConns: 2, CurConns: 2,
LastStartTime: "07-08 12:30:00",
LastCloseTime: "07-08 12:31:40",
LastStartAt: 1783504200,
LastCloseAt: 1783504300,
}, },
"http-alice": { "http-alice": {
Name: "http-alice", Name: "http-alice",
@@ -383,6 +813,13 @@ func newV2TestController(t *testing.T) *Controller {
ClientID: "client-b", ClientID: "client-b",
}, },
}, },
traffic: map[string]*mem.ProxyTrafficInfo{
"tcp-alice": {
Name: "tcp-alice",
TrafficIn: []int64{7, 6},
TrafficOut: []int64{70, 60},
},
},
} }
t.Cleanup(func() { t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector mem.StatsCollector = oldStatsCollector
@@ -400,19 +837,28 @@ func newV2TestController(t *testing.T) *Controller {
func newV2TestRouter(controller *Controller) *mux.Router { func newV2TestRouter(controller *Controller) *mux.Router {
router := mux.NewRouter() router := mux.NewRouter()
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet) router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet) router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
encodedPathRouter := router.NewRoute().Subrouter() encodedPathRouter := router.NewRoute().Subrouter()
encodedPathRouter.UseEncodedPath() encodedPathRouter.UseEncodedPath()
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet) encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet) router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet) encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet) router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).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 return router
} }
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder { func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, target, nil) return performRequestWithMethod(handler, http.MethodGet, target)
}
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
resp := httptest.NewRecorder() resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req) handler.ServeHTTP(resp, req)
return resp return resp
@@ -427,3 +873,36 @@ func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
} }
return out 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
}

View File

@@ -21,6 +21,41 @@ type V2PageResp[T any] struct {
Items []T `json:"items"` 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 { type V2UserResp struct {
User string `json:"user"` User string `json:"user"`
ClientCount int `json:"clientCount"` ClientCount int `json:"clientCount"`
@@ -40,18 +75,105 @@ type V2ClientStatusResp struct {
type V2ProxyResp struct { type V2ProxyResp struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"`
User string `json:"user"` User string `json:"user"`
ClientID string `json:"clientID"` ClientID string `json:"clientID"`
Spec any `json:"spec"` Spec V2ProxySpec `json:"spec"`
Status V2ProxyStatusResp `json:"status"` 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 { type V2ProxyStatusResp struct {
State string `json:"phase"` State string `json:"phase"`
TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"` TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"` CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"` LastStartAt int64 `json:"lastStartAt,omitempty"`
LastCloseTime string `json:"lastCloseTime"` 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"`
} }

View File

@@ -15,7 +15,6 @@ declare module 'vue' {
ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']

View File

@@ -98,6 +98,13 @@ export const http = {
request<T>(url, { ...options, method: 'GET' }), request<T>(url, { ...options, method: 'GET' }),
getV2: <T>(url: string, options?: RequestInit) => getV2: <T>(url: string, options?: RequestInit) =>
requestV2<T>(url, { ...options, method: 'GET' }), requestV2<T>(url, { ...options, method: 'GET' }),
postV2: <T>(url: string, body?: any, options?: RequestInit) =>
requestV2<T>(url, {
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(body),
}),
post: <T>(url: string, body?: any, options?: RequestInit) => post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, { request<T>(url, {
...options, ...options,

View File

@@ -1,13 +1,23 @@
import { buildQueryString, http } from './http' import { buildQueryString, http } from './http'
import { formatUnixSeconds } from '../utils/format'
import type { V2Page } from './http' import type { V2Page } from './http'
import type { import type {
GetProxyResponse, GetProxyResponse,
ProxyListV2Params, ProxyListV2Params,
ProxyStatsInfo, ProxyStatsInfo,
ProxyV2Info, ProxyV2Info,
ProxyV2Spec,
ProxyV2SpecBlocks,
ProxyV2Type,
TrafficResponse, TrafficResponse,
} from '../types/proxy' } from '../types/proxy'
export interface SystemPruneResponse {
type: 'offline_proxies'
cleared: number
total: number
}
export const getProxiesByType = (type: string) => { export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`) return http.get<GetProxyResponse>(`../api/proxy/${type}`)
} }
@@ -32,32 +42,77 @@ export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
} }
} }
const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({ const getActiveProxySpec = (
name: proxy.name, spec: ProxyV2Spec,
type: proxy.type, ): ProxyV2SpecBlocks[ProxyV2Type] => {
conf: proxy.spec, switch (spec.type) {
user: proxy.user, case 'tcp':
clientID: proxy.clientID, return spec.tcp
todayTrafficIn: proxy.status.todayTrafficIn, case 'udp':
todayTrafficOut: proxy.status.todayTrafficOut, return spec.udp
curConns: proxy.status.curConns, case 'http':
lastStartTime: proxy.status.lastStartTime, return spec.http
lastCloseTime: proxy.status.lastCloseTime, case 'https':
status: proxy.status.phase, return spec.https
}) case 'tcpmux':
return spec.tcpmux
case 'stcp':
return spec.stcp
case 'sudp':
return spec.sudp
case 'xtcp':
return spec.xtcp
default:
return assertNever(spec)
}
}
const assertNever = (value: never): never => {
throw new Error(`Unsupported proxy spec: ${JSON.stringify(value)}`)
}
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => {
const type = proxy.spec.type
const activeSpec = getActiveProxySpec(proxy.spec)
return {
name: proxy.name,
type,
conf: proxy.status.phase === 'offline' ? null : activeSpec,
user: proxy.user,
clientID: proxy.clientID,
todayTrafficIn: proxy.status.todayTrafficIn,
todayTrafficOut: proxy.status.todayTrafficOut,
curConns: proxy.status.curConns,
lastStartTime: formatUnixSeconds(proxy.status.lastStartAt),
lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt),
status: proxy.status.phase,
}
}
export const getProxy = (type: string, name: string) => { export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`) return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
} }
export const getProxyByNameV2 = async (name: string) => {
const proxy = await http.getV2<ProxyV2Info>(
`../api/v2/proxies/${encodeURIComponent(name)}`,
)
return toLegacyProxyStats(proxy)
}
export const getProxyByName = (name: string) => { export const getProxyByName = (name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`) return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
} }
export const getProxyTraffic = (name: string) => { export const getProxyTraffic = (name: string) => {
return http.get<TrafficResponse>(`../api/traffic/${name}`) return http.getV2<TrafficResponse>(
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
)
} }
export const clearOfflineProxies = () => { export const clearOfflineProxies = () => {
return http.delete('../api/proxies?status=offline') return http.postV2<SystemPruneResponse>(
'../api/v2/system/prune?type=offline_proxies',
)
} }

View File

@@ -2,5 +2,5 @@ import { http } from './http'
import type { ServerInfo } from '../types/server' import type { ServerInfo } from '../types/server'
export const getServerInfo = () => { export const getServerInfo = () => {
return http.get<ServerInfo>('../api/serverinfo') return http.getV2<ServerInfo>('../api/v2/system/info')
} }

View File

@@ -54,6 +54,7 @@ import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { formatFileSize } from '../utils/format' import { formatFileSize } from '../utils/format'
import { getProxyTraffic } from '../api/proxy' import { getProxyTraffic } from '../api/proxy'
import type { TrafficResponse } from '../types/proxy'
const props = defineProps<{ const props = defineProps<{
proxyName: string proxyName: string
@@ -71,41 +72,24 @@ const chartData = ref<
>([]) >([])
const maxVal = ref(0) const maxVal = ref(0)
const processData = (trafficIn: number[], trafficOut: number[]) => { const formatDateLabel = (date: string) => {
// Ensure we have arrays and reverse them (server returns newest first) const parts = date.split('-')
const inArr = [...(trafficIn || [])].reverse() if (parts.length !== 3) return date
const outArr = [...(trafficOut || [])].reverse() return `${Number(parts[1])}-${Number(parts[2])}`
}
// Pad with zeros if less than 7 days const processData = (history: TrafficResponse['history'] = []) => {
while (inArr.length < 7) inArr.unshift(0) const points = history || []
while (outArr.length < 7) outArr.unshift(0) const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
// Slice to last 7 entries just in case
const finalIn = inArr.slice(-7)
const finalOut = outArr.slice(-7)
// Calculate dates (last 7 days ending today)
const dates: string[] = []
const d = new Date()
d.setDate(d.getDate() - 6)
for (let i = 0; i < 7; i++) {
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
d.setDate(d.getDate() + 1)
}
// Find max value for scaling
const maxIn = Math.max(...finalIn)
const maxOut = Math.max(...finalOut)
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
// Build chart data chartData.value = points.map((item) => ({
chartData.value = dates.map((date, i) => ({ date: formatDateLabel(item.date),
date, in: item.trafficIn,
in: finalIn[i], out: item.trafficOut,
out: finalOut[i], inPercent: (item.trafficIn / maxVal.value) * 100,
inPercent: (finalIn[i] / maxVal.value) * 100, outPercent: (item.trafficOut / maxVal.value) * 100,
outPercent: (finalOut[i] / maxVal.value) * 100,
})) }))
} }
@@ -113,7 +97,7 @@ const fetchData = () => {
loading.value = true loading.value = true
getProxyTraffic(props.proxyName) getProxyTraffic(props.proxyName)
.then((json) => { .then((json) => {
processData(json.trafficIn, json.trafficOut) processData(json.history)
}) })
.catch((err) => { .catch((err) => {
ElMessage({ ElMessage({

View File

@@ -7,7 +7,6 @@ export interface ClientInfoData {
wireProtocol?: string wireProtocol?: string
hostname: string hostname: string
clientIP?: string clientIP?: string
metas?: Record<string, string>
firstConnectedAt: number firstConnectedAt: number
lastConnectedAt: number lastConnectedAt: number
disconnectedAt?: number disconnectedAt?: number

View File

@@ -28,24 +28,102 @@ export interface ProxyListV2Params {
export interface ProxyV2Info { export interface ProxyV2Info {
name: string name: string
type: string
user: string user: string
clientID: string clientID: string
spec: any spec: ProxyV2Spec
status: ProxyV2Status status: ProxyV2Status
} }
export interface ProxyV2BaseSpec {
annotations?: Record<string, string>
metadatas?: Record<string, string>
transport?: {
useEncryption: boolean
useCompression: boolean
bandwidthLimit: string
bandwidthLimitMode: string
}
loadBalancer?: {
group: string
}
}
export interface ProxyV2TCPBlock extends ProxyV2BaseSpec {
remotePort?: number
}
export interface ProxyV2UDPBlock extends ProxyV2BaseSpec {
remotePort?: number
}
export interface ProxyV2HTTPBlock extends ProxyV2BaseSpec {
customDomains?: string[]
subdomain?: string
locations?: string[]
hostHeaderRewrite?: string
}
export interface ProxyV2HTTPSBlock extends ProxyV2BaseSpec {
customDomains?: string[]
subdomain?: string
}
export interface ProxyV2TCPMuxBlock extends ProxyV2BaseSpec {
customDomains?: string[]
subdomain?: string
multiplexer?: string
routeByHTTPUser?: string
}
export type ProxyV2STCPBlock = ProxyV2BaseSpec
export type ProxyV2SUDPBlock = ProxyV2BaseSpec
export type ProxyV2XTCPBlock = ProxyV2BaseSpec
export interface ProxyV2SpecBlocks {
tcp: ProxyV2TCPBlock
udp: ProxyV2UDPBlock
http: ProxyV2HTTPBlock
https: ProxyV2HTTPSBlock
tcpmux: ProxyV2TCPMuxBlock
stcp: ProxyV2STCPBlock
sudp: ProxyV2SUDPBlock
xtcp: ProxyV2XTCPBlock
}
export type ProxyV2Type = keyof ProxyV2SpecBlocks
type ProxyV2SpecFor<T extends ProxyV2Type> = {
type: T
} & {
[K in T]: ProxyV2SpecBlocks[K]
} & {
[K in Exclude<ProxyV2Type, T>]?: never
}
export type ProxyV2Spec = {
[T in ProxyV2Type]: ProxyV2SpecFor<T>
}[ProxyV2Type]
export interface ProxyV2Status { export interface ProxyV2Status {
phase: string phase: 'online' | 'offline'
todayTrafficIn: number todayTrafficIn: number
todayTrafficOut: number todayTrafficOut: number
curConns: number curConns: number
lastStartTime: string lastStartAt?: number
lastCloseTime: string lastCloseAt?: number
} }
export interface TrafficResponse { export interface TrafficResponse {
name: string name: string
trafficIn: number[] unit: 'bytes'
trafficOut: number[] granularity: 'day'
history: TrafficPoint[]
}
export interface TrafficPoint {
date: string
trafficIn: number
trafficOut: number
} }

View File

@@ -1,5 +1,10 @@
export interface ServerInfo { export interface ServerInfo {
version: string version: string
config: ServerInfoConfig
status: ServerInfoStatus
}
export interface ServerInfoConfig {
bindPort: number bindPort: number
vhostHTTPPort: number vhostHTTPPort: number
vhostHTTPSPort: number vhostHTTPSPort: number
@@ -12,8 +17,9 @@ export interface ServerInfo {
heartbeatTimeout: number heartbeatTimeout: number
allowPortsStr: string allowPortsStr: string
tlsForce: boolean tlsForce: boolean
}
// Stats export interface ServerInfoStatus {
totalTrafficIn: number totalTrafficIn: number
totalTrafficOut: number totalTrafficOut: number
curConns: number curConns: number

View File

@@ -10,7 +10,6 @@ export class Client {
wireProtocol: string wireProtocol: string
hostname: string hostname: string
ip: string ip: string
metas: Map<string, string>
firstConnectedAt: Date firstConnectedAt: Date
lastConnectedAt: Date lastConnectedAt: Date
disconnectedAt?: Date disconnectedAt?: Date
@@ -26,12 +25,6 @@ export class Client {
this.wireProtocol = data.wireProtocol || '' this.wireProtocol = data.wireProtocol || ''
this.hostname = data.hostname this.hostname = data.hostname
this.ip = data.clientIP || '' this.ip = data.clientIP || ''
this.metas = new Map<string, string>()
if (data.metas) {
for (const [key, value] of Object.entries(data.metas)) {
this.metas.set(key, value)
}
}
this.firstConnectedAt = new Date(data.firstConnectedAt * 1000) this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000) this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
if (data.disconnectedAt && data.disconnectedAt > 0) { if (data.disconnectedAt && data.disconnectedAt > 0) {
@@ -52,10 +45,6 @@ export class Client {
return this.runID return this.runID
} }
get shortRunId(): string {
return this.runID.substring(0, 8)
}
get wireProtocolLabel(): string { get wireProtocolLabel(): string {
if (!this.wireProtocol) return '' if (!this.wireProtocol) return ''
return `Protocol ${this.wireProtocol}` return `Protocol ${this.wireProtocol}`
@@ -73,28 +62,4 @@ export class Client {
if (!this.disconnectedAt) return '' if (!this.disconnectedAt) return ''
return formatDistanceToNow(this.disconnectedAt) return formatDistanceToNow(this.disconnectedAt)
} }
get statusColor(): string {
return this.online ? 'success' : 'danger'
}
get metasArray(): Array<{ key: string; value: string }> {
const arr: Array<{ key: string; value: string }> = []
this.metas.forEach((value, key) => {
arr.push({ key, value })
})
return arr
}
matchesFilter(searchText: string): boolean {
const search = searchText.toLowerCase()
return (
this.key.toLowerCase().includes(search) ||
this.user.toLowerCase().includes(search) ||
this.clientID.toLowerCase().includes(search) ||
this.runID.toLowerCase().includes(search) ||
this.wireProtocol.toLowerCase().includes(search) ||
this.hostname.toLowerCase().includes(search)
)
}
} }

View File

@@ -19,6 +19,14 @@ export function formatDistanceToNow(date: Date): string {
return Math.floor(seconds) + ' seconds ago' return Math.floor(seconds) + ' seconds ago'
} }
export function formatUnixSeconds(seconds?: number): string {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return ''
const date = new Date(seconds * 1000)
const pad = (value: number) => value.toString().padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B' if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
if (bytes === 0) return '0 B' if (bytes === 0) return '0 B'

View File

@@ -161,6 +161,7 @@ import {
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import ProxyCard from '../components/ProxyCard.vue' import ProxyCard from '../components/ProxyCard.vue'
import type { ProxyStatsInfo } from '../types/proxy' import type { ProxyStatsInfo } from '../types/proxy'
import type { ServerInfo } from '../types/server'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -183,16 +184,9 @@ const total = ref(0)
let requestSeq = 0 let requestSeq = 0
let searchDebounceTimer: number | null = null let searchDebounceTimer: number | null = null
type ServerInfoLite = { let serverInfoPromise: Promise<ServerInfo> | null = null
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
}
let serverInfoPromise: Promise<ServerInfoLite> | null = null const fetchServerInfo = (): Promise<ServerInfo> => {
const fetchServerInfo = (): Promise<ServerInfoLite> => {
if (!serverInfoPromise) { if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => { serverInfoPromise = getServerInfo().catch((err) => {
serverInfoPromise = null serverInfoPromise = null
@@ -232,25 +226,33 @@ const convertProxy = async (
} }
if (type === 'http') { if (type === 'http') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) { if (info && info.config.vhostHTTPPort) {
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost) return new HTTPProxy(
proxy,
info.config.vhostHTTPPort,
info.config.subdomainHost,
)
} }
return null return null
} }
if (type === 'https') { if (type === 'https') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) { if (info && info.config.vhostHTTPSPort) {
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost) return new HTTPSProxy(
proxy,
info.config.vhostHTTPSPort,
info.config.subdomainHost,
)
} }
return null return null
} }
if (type === 'tcpmux') { if (type === 'tcpmux') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) { if (info && info.config.tcpmuxHTTPConnectPort) {
return new TCPMuxProxy( return new TCPMuxProxy(
proxy, proxy,
info.tcpmuxHTTPConnectPort, info.config.tcpmuxHTTPConnectPort,
info.subdomainHost, info.config.subdomainHost,
) )
} }
return null return null

View File

@@ -104,6 +104,7 @@ import {
} from '../api/proxy' } from '../api/proxy'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import type { ProxyStatsInfo } from '../types/proxy' import type { ProxyStatsInfo } from '../types/proxy'
import type { ServerInfo } from '../types/server'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -120,7 +121,7 @@ const proxyTypes = [
{ label: 'SUDP', value: 'sudp' }, { label: 'SUDP', value: 'sudp' },
] ]
const activeType = ref((route.params.type as string) || 'tcp') const activeType = ref((route.params.type as string) || 'all')
const proxies = ref<BaseProxy[]>([]) const proxies = ref<BaseProxy[]>([])
const loading = ref(false) const loading = ref(false)
const searchText = ref('') const searchText = ref('')
@@ -133,15 +134,9 @@ let searchDebounceTimer: number | null = null
// Server info cache - cache the Promise itself so concurrent first calls // Server info cache - cache the Promise itself so concurrent first calls
// from Promise.all (convertProxies) don't kick off multiple HTTP requests. // from Promise.all (convertProxies) don't kick off multiple HTTP requests.
type ServerInfoLite = { let serverInfoPromise: Promise<ServerInfo> | null = null
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
}
let serverInfoPromise: Promise<ServerInfoLite> | null = null
const fetchServerInfo = (): Promise<ServerInfoLite> => { const fetchServerInfo = (): Promise<ServerInfo> => {
if (!serverInfoPromise) { if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => { serverInfoPromise = getServerInfo().catch((err) => {
// Allow retry after failure // Allow retry after failure
@@ -164,25 +159,33 @@ const convertProxy = async (
} }
if (type === 'http') { if (type === 'http') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) { if (info && info.config.vhostHTTPPort) {
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost) return new HTTPProxy(
proxy,
info.config.vhostHTTPPort,
info.config.subdomainHost,
)
} }
return null return null
} }
if (type === 'https') { if (type === 'https') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) { if (info && info.config.vhostHTTPSPort) {
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost) return new HTTPSProxy(
proxy,
info.config.vhostHTTPSPort,
info.config.subdomainHost,
)
} }
return null return null
} }
if (type === 'tcpmux') { if (type === 'tcpmux') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) { if (info && info.config.tcpmuxHTTPConnectPort) {
return new TCPMuxProxy( return new TCPMuxProxy(
proxy, proxy,
info.tcpmuxHTTPConnectPort, info.config.tcpmuxHTTPConnectPort,
info.subdomainHost, info.config.subdomainHost,
) )
} }
return null return null

View File

@@ -241,7 +241,7 @@ import {
Tickets, Tickets,
Location, Location,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { getProxyByName } from '../api/proxy' import { getProxyByNameV2 } from '../api/proxy'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import { import {
BaseProxy, BaseProxy,
@@ -254,6 +254,7 @@ import {
SUDPProxy, SUDPProxy,
} from '../utils/proxy' } from '../utils/proxy'
import Traffic from '../components/Traffic.vue' import Traffic from '../components/Traffic.vue'
import type { ServerInfo } from '../types/server'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -275,12 +276,7 @@ const goBack = () => {
} }
} }
let serverInfo: { let serverInfo: ServerInfo | null = null
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const clientLink = computed(() => { const clientLink = computed(() => {
if (!proxy.value) return '' if (!proxy.value) return ''
@@ -365,27 +361,32 @@ const fetchProxy = async () => {
} }
try { try {
const data = await getProxyByName(name) const data = await getProxyByNameV2(name)
const info = await fetchServerInfo() const info = await fetchServerInfo()
const type = data.conf?.type || '' const config = info.config
const type = data.type || data.conf?.type || ''
if (type === 'tcp') { if (type === 'tcp') {
proxy.value = new TCPProxy(data) proxy.value = new TCPProxy(data)
} else if (type === 'udp') { } else if (type === 'udp') {
proxy.value = new UDPProxy(data) proxy.value = new UDPProxy(data)
} else if (type === 'http' && info?.vhostHTTPPort) { } else if (type === 'http' && config.vhostHTTPPort) {
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost) proxy.value = new HTTPProxy(
} else if (type === 'https' && info?.vhostHTTPSPort) { data,
config.vhostHTTPPort,
config.subdomainHost,
)
} else if (type === 'https' && config.vhostHTTPSPort) {
proxy.value = new HTTPSProxy( proxy.value = new HTTPSProxy(
data, data,
info.vhostHTTPSPort, config.vhostHTTPSPort,
info.subdomainHost, config.subdomainHost,
) )
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) { } else if (type === 'tcpmux' && config.tcpmuxHTTPConnectPort) {
proxy.value = new TCPMuxProxy( proxy.value = new TCPMuxProxy(
data, data,
info.tcpmuxHTTPConnectPort, config.tcpmuxHTTPConnectPort,
info.subdomainHost, config.subdomainHost,
) )
} else if (type === 'stcp') { } else if (type === 'stcp') {
proxy.value = new STCPProxy(data) proxy.value = new STCPProxy(data)

View File

@@ -4,7 +4,7 @@
<el-col :xs="24" :sm="12" :lg="6"> <el-col :xs="24" :sm="12" :lg="6">
<StatCard <StatCard
label="Clients" label="Clients"
:value="data.clientCounts" :value="data.status.clientCounts"
type="clients" type="clients"
subtitle="Connected clients" subtitle="Connected clients"
to="/clients" to="/clients"
@@ -13,7 +13,7 @@
<el-col :xs="24" :sm="12" :lg="6"> <el-col :xs="24" :sm="12" :lg="6">
<StatCard <StatCard
label="Proxies" label="Proxies"
:value="data.proxyCounts" :value="proxyCounts"
type="proxies" type="proxies"
subtitle="Active proxies" subtitle="Active proxies"
to="/proxies/tcp" to="/proxies/tcp"
@@ -22,7 +22,7 @@
<el-col :xs="24" :sm="12" :lg="6"> <el-col :xs="24" :sm="12" :lg="6">
<StatCard <StatCard
label="Connections" label="Connections"
:value="data.curConns" :value="data.status.curConns"
type="connections" type="connections"
subtitle="Current connections" subtitle="Current connections"
/> />
@@ -54,7 +54,7 @@
<div class="traffic-info"> <div class="traffic-info">
<div class="label">Inbound</div> <div class="label">Inbound</div>
<div class="value"> <div class="value">
{{ formatFileSize(data.totalTrafficIn) }} {{ formatFileSize(data.status.totalTrafficIn) }}
</div> </div>
</div> </div>
</div> </div>
@@ -66,7 +66,7 @@
<div class="traffic-info"> <div class="traffic-info">
<div class="label">Outbound</div> <div class="label">Outbound</div>
<div class="value"> <div class="value">
{{ formatFileSize(data.totalTrafficOut) }} {{ formatFileSize(data.status.totalTrafficOut) }}
</div> </div>
</div> </div>
</div> </div>
@@ -83,7 +83,7 @@
</template> </template>
<div class="proxy-types-grid"> <div class="proxy-types-grid">
<div <div
v-for="(count, type) in data.proxyTypeCounts" v-for="(count, type) in data.status.proxyTypeCount"
:key="type" :key="type"
class="proxy-type-item" class="proxy-type-item"
v-show="count > 0" v-show="count > 0"
@@ -109,51 +109,51 @@
<div class="config-grid"> <div class="config-grid">
<div class="config-item"> <div class="config-item">
<span class="config-label">Bind Port</span> <span class="config-label">Bind Port</span>
<span class="config-value">{{ data.bindPort }}</span> <span class="config-value">{{ data.config.bindPort }}</span>
</div> </div>
<div class="config-item" v-if="data.kcpBindPort != 0"> <div class="config-item" v-if="data.config.kcpBindPort != 0">
<span class="config-label">KCP Port</span> <span class="config-label">KCP Port</span>
<span class="config-value">{{ data.kcpBindPort }}</span> <span class="config-value">{{ data.config.kcpBindPort }}</span>
</div> </div>
<div class="config-item" v-if="data.quicBindPort != 0"> <div class="config-item" v-if="data.config.quicBindPort != 0">
<span class="config-label">QUIC Port</span> <span class="config-label">QUIC Port</span>
<span class="config-value">{{ data.quicBindPort }}</span> <span class="config-value">{{ data.config.quicBindPort }}</span>
</div> </div>
<div class="config-item" v-if="data.vhostHTTPPort != 0"> <div class="config-item" v-if="data.config.vhostHTTPPort != 0">
<span class="config-label">HTTP Port</span> <span class="config-label">HTTP Port</span>
<span class="config-value">{{ data.vhostHTTPPort }}</span> <span class="config-value">{{ data.config.vhostHTTPPort }}</span>
</div> </div>
<div class="config-item" v-if="data.vhostHTTPSPort != 0"> <div class="config-item" v-if="data.config.vhostHTTPSPort != 0">
<span class="config-label">HTTPS Port</span> <span class="config-label">HTTPS Port</span>
<span class="config-value">{{ data.vhostHTTPSPort }}</span> <span class="config-value">{{ data.config.vhostHTTPSPort }}</span>
</div> </div>
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0"> <div class="config-item" v-if="data.config.tcpmuxHTTPConnectPort != 0">
<span class="config-label">TCPMux Port</span> <span class="config-label">TCPMux Port</span>
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span> <span class="config-value">{{ data.config.tcpmuxHTTPConnectPort }}</span>
</div> </div>
<div class="config-item" v-if="data.subdomainHost != ''"> <div class="config-item" v-if="data.config.subdomainHost != ''">
<span class="config-label">Subdomain Host</span> <span class="config-label">Subdomain Host</span>
<span class="config-value">{{ data.subdomainHost }}</span> <span class="config-value">{{ data.config.subdomainHost }}</span>
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="config-label">Max Pool Count</span> <span class="config-label">Max Pool Count</span>
<span class="config-value">{{ data.maxPoolCount }}</span> <span class="config-value">{{ data.config.maxPoolCount }}</span>
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="config-label">Max Ports/Client</span> <span class="config-label">Max Ports/Client</span>
<span class="config-value">{{ data.maxPortsPerClient }}</span> <span class="config-value">{{ maxPortsPerClientLabel }}</span>
</div> </div>
<div class="config-item" v-if="data.allowPortsStr != ''"> <div class="config-item" v-if="data.config.allowPortsStr != ''">
<span class="config-label">Allow Ports</span> <span class="config-label">Allow Ports</span>
<span class="config-value">{{ data.allowPortsStr }}</span> <span class="config-value">{{ data.config.allowPortsStr }}</span>
</div> </div>
<div class="config-item" v-if="data.tlsForce"> <div class="config-item" v-if="data.config.tlsForce">
<span class="config-label">TLS Force</span> <span class="config-label">TLS Force</span>
<el-tag size="small" type="warning">Enabled</el-tag> <el-tag size="small" type="warning">Enabled</el-tag>
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="config-label">Heartbeat Timeout</span> <span class="config-label">Heartbeat Timeout</span>
<span class="config-value">{{ data.heartbeatTimeout }}s</span> <span class="config-value">{{ data.config.heartbeatTimeout }}s</span>
</div> </div>
</div> </div>
</el-card> </el-card>
@@ -167,69 +167,59 @@ import { formatFileSize } from '../utils/format'
import { Download, Upload } from '@element-plus/icons-vue' import { Download, Upload } from '@element-plus/icons-vue'
import StatCard from '../components/StatCard.vue' import StatCard from '../components/StatCard.vue'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import type { ServerInfo } from '../types/server'
const data = ref({ const data = ref<ServerInfo>({
version: '', version: '',
bindPort: 0, config: {
kcpBindPort: 0, bindPort: 0,
quicBindPort: 0, kcpBindPort: 0,
vhostHTTPPort: 0, quicBindPort: 0,
vhostHTTPSPort: 0, vhostHTTPPort: 0,
tcpmuxHTTPConnectPort: 0, vhostHTTPSPort: 0,
subdomainHost: '', tcpmuxHTTPConnectPort: 0,
maxPoolCount: 0, subdomainHost: '',
maxPortsPerClient: '', maxPoolCount: 0,
allowPortsStr: '', maxPortsPerClient: 0,
tlsForce: false, allowPortsStr: '',
heartbeatTimeout: 0, tlsForce: false,
clientCounts: 0, heartbeatTimeout: 0,
curConns: 0, },
proxyCounts: 0, status: {
totalTrafficIn: 0, clientCounts: 0,
totalTrafficOut: 0, curConns: 0,
proxyTypeCounts: {} as Record<string, number>, totalTrafficIn: 0,
totalTrafficOut: 0,
proxyTypeCount: {},
},
}) })
const hasActiveProxies = computed(() => { const hasActiveProxies = computed(() => {
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0) return Object.values(data.value.status.proxyTypeCount).some((c) => c > 0)
})
const proxyCounts = computed(() => {
return Object.values(data.value.status.proxyTypeCount).reduce(
(sum, count) => sum + (count || 0),
0,
)
})
const maxPortsPerClientLabel = computed(() => {
const value = data.value.config.maxPortsPerClient
return value === 0 ? 'no limit' : String(value)
}) })
const formatTrafficTotal = () => { const formatTrafficTotal = () => {
const total = data.value.totalTrafficIn + data.value.totalTrafficOut const total =
data.value.status.totalTrafficIn + data.value.status.totalTrafficOut
return formatFileSize(total) return formatFileSize(total)
} }
const fetchData = async () => { const fetchData = async () => {
try { try {
const json = await getServerInfo() const json = await getServerInfo()
data.value.version = json.version data.value = json
data.value.bindPort = json.bindPort
data.value.kcpBindPort = json.kcpBindPort
data.value.quicBindPort = json.quicBindPort
data.value.vhostHTTPPort = json.vhostHTTPPort
data.value.vhostHTTPSPort = json.vhostHTTPSPort
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
data.value.subdomainHost = json.subdomainHost
data.value.maxPoolCount = json.maxPoolCount
data.value.maxPortsPerClient = String(json.maxPortsPerClient)
if (data.value.maxPortsPerClient == '0') {
data.value.maxPortsPerClient = 'no limit'
}
data.value.allowPortsStr = json.allowPortsStr
data.value.tlsForce = json.tlsForce
data.value.heartbeatTimeout = json.heartbeatTimeout
data.value.clientCounts = json.clientCounts
data.value.curConns = json.curConns
data.value.totalTrafficIn = json.totalTrafficIn
data.value.totalTrafficOut = json.totalTrafficOut
data.value.proxyTypeCounts = json.proxyTypeCount || {}
data.value.proxyCounts = 0
if (json.proxyTypeCount != null) {
Object.values(json.proxyTypeCount).forEach((count: any) => {
data.value.proxyCounts += count || 0
})
}
} catch { } catch {
ElMessage({ ElMessage({
showClose: true, showClose: true,

279
web/package-lock.json generated
View File

@@ -147,14 +147,13 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"aix" "aix"
@@ -164,14 +163,13 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -181,14 +179,13 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -198,14 +195,13 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -215,14 +211,13 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -232,14 +227,13 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -249,14 +243,13 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -266,14 +259,13 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -283,14 +275,13 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -300,14 +291,13 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -317,14 +307,13 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -334,14 +323,13 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -351,14 +339,13 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -368,14 +355,13 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -385,14 +371,13 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -402,14 +387,13 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -419,14 +403,13 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -436,14 +419,13 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -453,14 +435,13 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -470,14 +451,13 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -487,14 +467,13 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -504,14 +483,13 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openharmony" "openharmony"
@@ -521,14 +499,13 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"sunos" "sunos"
@@ -538,14 +515,13 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -555,14 +531,13 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -572,14 +547,13 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -2497,9 +2471,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
@@ -3380,12 +3354,11 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.4", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -3393,32 +3366,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4", "@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.27.4", "@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.27.4", "@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.27.4", "@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.27.4", "@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.27.4", "@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.27.4", "@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.27.4", "@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.27.4", "@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.27.4", "@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.27.4", "@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.27.4" "@esbuild/win32-x64": "0.28.1"
} }
}, },
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
@@ -4789,11 +4762,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"license": "MIT", "funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
}, },
@@ -6206,11 +6188,10 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.3", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -7390,12 +7371,12 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.2", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",