Compare commits

...

21 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
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
25 changed files with 1671 additions and 284 deletions

View File

@@ -2,7 +2,6 @@
[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp)
[![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp)
[![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
[README](README.md) | [中文文档](README_zh.md)

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
package version
var version = "0.69.1"
var version = "0.70.0"
func Full() string {
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/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
v2EncodedPathRouter.UseEncodedPath()
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")

View File

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

View File

@@ -17,12 +17,14 @@ package http
import (
"cmp"
"fmt"
"maps"
"math"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
@@ -35,6 +37,11 @@ const (
defaultV2Page = 1
defaultV2PageSize = 50
maxV2PageSize = 200
v2SystemPruneTypeOfflineProxies = "offline_proxies"
v2ProxyTrafficDefaultDays = 7
v2ProxyTrafficUnit = "bytes"
v2ProxyTrafficGranularity = "day"
)
var apiV2ProxyTypes = []string{
@@ -48,6 +55,55 @@ var apiV2ProxyTypes = []string{
string(v1.ProxyTypeSUDP),
}
// /api/v2/system/info
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
info := c.buildServerInfoResp()
proxyTypeCounts := info.ProxyTypeCounts
if proxyTypeCounts == nil {
proxyTypeCounts = map[string]int64{}
}
return model.V2SystemInfoResp{
Version: info.Version,
Config: model.V2SystemInfoConfigResp{
BindPort: info.BindPort,
VhostHTTPPort: info.VhostHTTPPort,
VhostHTTPSPort: info.VhostHTTPSPort,
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
KCPBindPort: info.KCPBindPort,
QUICBindPort: info.QUICBindPort,
SubdomainHost: info.SubdomainHost,
MaxPoolCount: info.MaxPoolCount,
MaxPortsPerClient: info.MaxPortsPerClient,
HeartbeatTimeout: info.HeartBeatTimeout,
AllowPortsStr: info.AllowPortsStr,
TLSForce: info.TLSForce,
},
Status: model.V2SystemInfoStatusResp{
TotalTrafficIn: info.TotalTrafficIn,
TotalTrafficOut: info.TotalTrafficOut,
CurConns: info.CurConns,
ClientCounts: info.ClientCounts,
ProxyTypeCounts: proxyTypeCounts,
},
}, nil
}
// /api/v2/system/prune
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
if err != nil {
return nil, err
}
cleared, total := mem.StatsCollector.PruneOfflineProxies()
return model.V2SystemPruneResp{
Type: pruneType,
Cleared: cleared,
Total: total,
}, nil
}
// /api/v2/users
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
@@ -139,15 +195,10 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
// /api/v2/clients/{key}
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
key := ctx.Param("key")
if key == "" {
return nil, fmt.Errorf("missing client key")
}
decodedKey, err := url.PathUnescape(key)
key, err := decodeV2PathParam(ctx, "key", "client key")
if err != nil {
return nil, fmt.Errorf("invalid client key %q: %w", key, err)
return nil, err
}
key = decodedKey
if c.clientRegistry == nil {
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 {
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 cmp.Compare(a.Name, b.Name)
@@ -216,9 +267,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
// /api/v2/proxies/{name}
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, fmt.Errorf("missing proxy name")
name, err := decodeV2PathParam(ctx, "name", "proxy name")
if err != nil {
return nil, err
}
ps := mem.StatsCollector.GetProxyByName(name)
@@ -228,6 +279,33 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
return c.buildV2ProxyResp(ps), nil
}
// /api/v2/proxies/{name}/traffic
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
name, err := decodeV2PathParam(ctx, "name", "proxy name")
if err != nil {
return nil, err
}
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
if proxyTrafficInfo == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
}
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
raw := ctx.Param(key)
if raw == "" {
return "", fmt.Errorf("missing %s", label)
}
decoded, err := url.PathUnescape(raw)
if err != nil {
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
}
return decoded, nil
}
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
item, ok := items[user]
if !ok {
@@ -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")
}
func parseV2SystemPruneType(raw string) (string, error) {
pruneType := strings.ToLower(raw)
switch pruneType {
case "":
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
case v2SystemPruneTypeOfflineProxies:
return pruneType, nil
default:
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
}
}
func matchV2StatusFilter(online bool, filter string) bool {
switch filter {
case "", "all":
@@ -346,26 +436,36 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
values := []string{
item.Name,
item.Type,
item.Spec.Type,
item.User,
item.ClientID,
item.Status.State,
}
switch spec := item.Spec.(type) {
case *model.TCPOutConf:
values = append(values, strconv.Itoa(spec.RemotePort))
case *model.UDPOutConf:
values = append(values, strconv.Itoa(spec.RemotePort))
case *model.HTTPOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
case *model.HTTPSOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
case *model.TCPMuxOutConf:
values = append(values, spec.CustomDomains...)
values = append(values, spec.SubDomain)
switch item.Spec.Type {
case string(v1.ProxyTypeTCP):
if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
}
case string(v1.ProxyTypeUDP):
if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
}
case string(v1.ProxyTypeHTTP):
if item.Spec.HTTP != nil {
values = append(values, item.Spec.HTTP.CustomDomains...)
values = append(values, item.Spec.HTTP.Subdomain)
}
case string(v1.ProxyTypeHTTPS):
if item.Spec.HTTPS != nil {
values = append(values, item.Spec.HTTPS.CustomDomains...)
values = append(values, item.Spec.HTTPS.Subdomain)
}
case string(v1.ProxyTypeTCPMUX):
if item.Spec.TCPMux != nil {
values = append(values, item.Spec.TCPMux.CustomDomains...)
values = append(values, item.Spec.TCPMux.Subdomain)
}
}
return containsV2Query(q, values...)
@@ -392,6 +492,31 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
return items
}
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
history = append(history, model.V2ProxyTrafficPointResp{
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
})
}
return model.V2ProxyTrafficResp{
Name: name,
Unit: v2ProxyTrafficUnit,
Granularity: v2ProxyTrafficGranularity,
History: history,
}
}
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
if todayFirstIndex >= len(values) {
return 0
}
return values[todayFirstIndex]
}
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
status := model.V2ClientStatusResp{State: "offline"}
if info.Online {
@@ -412,27 +537,111 @@ func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2Clien
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
state := "offline"
var spec any
var cfg v1.ProxyConfigurer
if c.pxyManager != nil {
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
state = "online"
spec = getConfFromConfigurer(pxy.GetConfigurer())
cfg = pxy.GetConfigurer()
}
}
return model.V2ProxyResp{
Name: ps.Name,
Type: ps.Type,
User: ps.User,
ClientID: ps.ClientID,
Spec: spec,
Spec: buildV2ProxySpec(ps.Type, cfg),
Status: model.V2ProxyStatusResp{
State: state,
TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns,
LastStartTime: ps.LastStartTime,
LastCloseTime: ps.LastCloseTime,
LastStartAt: ps.LastStartAt,
LastCloseAt: ps.LastCloseAt,
},
}
}
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
spec := model.V2ProxySpec{Type: proxyType}
switch proxyType {
case string(v1.ProxyTypeTCP):
block := &model.V2TCPProxySpec{}
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.RemotePort = &c.RemotePort
}
spec.TCP = block
case string(v1.ProxyTypeUDP):
block := &model.V2UDPProxySpec{}
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.RemotePort = &c.RemotePort
}
spec.UDP = block
case string(v1.ProxyTypeHTTP):
block := &model.V2HTTPProxySpec{}
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.CustomDomains = slices.Clone(c.CustomDomains)
block.Subdomain = c.SubDomain
block.Locations = slices.Clone(c.Locations)
block.HostHeaderRewrite = c.HostHeaderRewrite
}
spec.HTTP = block
case string(v1.ProxyTypeHTTPS):
block := &model.V2HTTPSProxySpec{}
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.CustomDomains = slices.Clone(c.CustomDomains)
block.Subdomain = c.SubDomain
}
spec.HTTPS = block
case string(v1.ProxyTypeTCPMUX):
block := &model.V2TCPMuxProxySpec{}
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.CustomDomains = slices.Clone(c.CustomDomains)
block.Subdomain = c.SubDomain
block.Multiplexer = c.Multiplexer
block.RouteByHTTPUser = c.RouteByHTTPUser
}
spec.TCPMux = block
case string(v1.ProxyTypeSTCP):
block := &model.V2STCPProxySpec{}
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
}
spec.STCP = block
case string(v1.ProxyTypeSUDP):
block := &model.V2SUDPProxySpec{}
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
}
spec.SUDP = block
case string(v1.ProxyTypeXTCP):
block := &model.V2XTCPProxySpec{}
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
}
spec.XTCP = block
}
return spec
}
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
return model.V2ProxyBaseSpec{
Annotations: maps.Clone(base.Annotations),
Metadatas: maps.Clone(base.Metadatas),
Transport: &model.V2ProxyTransportSpec{
UseEncryption: base.Transport.UseEncryption,
UseCompression: base.Transport.UseCompression,
BandwidthLimit: base.Transport.BandwidthLimit.String(),
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
},
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
Group: base.LoadBalancer.Group,
},
}
}

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/url"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
@@ -40,10 +42,16 @@ type v2EnvelopeForTest[T any] struct {
}
type fakeStatsCollector struct {
proxies map[string]*mem.ProxyStats
server *mem.ServerStats
proxies map[string]*mem.ProxyStats
traffic map[string]*mem.ProxyTrafficInfo
pruneable map[string]bool
}
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
if f.server != nil {
return f.server
}
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
}
@@ -70,13 +78,210 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
}
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
return nil
return f.traffic[name]
}
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
return 0, len(f.proxies)
}
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
total := len(f.proxies)
cleared := 0
for name := range f.pruneable {
if _, ok := f.proxies[name]; ok {
delete(f.proxies, name)
cleared++
}
}
f.pruneable = map[string]bool{}
return cleared, total
}
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
server: &mem.ServerStats{
TotalTrafficIn: 1024,
TotalTrafficOut: 2048,
CurConns: 3,
ClientCounts: 4,
ProxyTypeCounts: map[string]int64{
"tcp": 2,
"http": 1,
},
},
proxies: map[string]*mem.ProxyStats{},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
VhostHTTPSPort: 8443,
TCPMuxHTTPConnectPort: 9000,
KCPBindPort: 7001,
QUICBindPort: 7002,
SubDomainHost: "example.com",
MaxPortsPerClient: 8,
AllowPorts: []types.PortsRange{
{Start: 1000, End: 1002},
{Single: 2000},
},
Transport: v1.ServerTransportConfig{
MaxPoolCount: 5,
HeartbeatTimeout: 90,
TLS: v1.TLSServerConfig{
Force: true,
},
},
}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/system/info")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
"allowPortsStr",
"bindPort",
"heartbeatTimeout",
"kcpBindPort",
"maxPoolCount",
"maxPortsPerClient",
"quicBindPort",
"subdomainHost",
"tcpmuxHTTPConnectPort",
"tlsForce",
"vhostHTTPPort",
"vhostHTTPSPort",
)
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
"clientCounts",
"curConns",
"proxyTypeCount",
"totalTrafficIn",
"totalTrafficOut",
)
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
if systemResp.Data.Version == "" {
t.Fatal("version should be set at top level")
}
if systemResp.Data.Config.BindPort != 7000 ||
systemResp.Data.Config.VhostHTTPPort != 8080 ||
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
systemResp.Data.Config.KCPBindPort != 7001 ||
systemResp.Data.Config.QUICBindPort != 7002 ||
systemResp.Data.Config.SubdomainHost != "example.com" ||
systemResp.Data.Config.MaxPoolCount != 5 ||
systemResp.Data.Config.MaxPortsPerClient != 8 ||
systemResp.Data.Config.HeartbeatTimeout != 90 ||
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
!systemResp.Data.Config.TLSForce {
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
}
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
systemResp.Data.Status.TotalTrafficOut != 2048 ||
systemResp.Data.Status.CurConns != 3 ||
systemResp.Data.Status.ClientCounts != 4 ||
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
}
}
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
oldStatsCollector := mem.StatsCollector
collector := &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
"http-offline": {Name: "http-offline", Type: "http"},
"udp-offline": {Name: "udp-offline", Type: "udp"},
"tcp-online": {Name: "tcp-online", Type: "tcp"},
"http-online": {Name: "http-online", Type: "http"},
"udp-online": {Name: "udp-online", Type: "udp"},
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
},
pruneable: map[string]bool{
"tcp-offline": true,
"http-offline": true,
"udp-offline": true,
},
}
mem.StatsCollector = collector
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
}
if _, ok := collector.proxies["tcp-offline"]; ok {
t.Fatal("pruned proxy statistics should be removed")
}
if _, ok := collector.proxies["tcp-online"]; !ok {
t.Fatal("online proxy statistics should remain")
}
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
if resp.Code != http.StatusOK {
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
}
}
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
}
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
if resp.Code != http.StatusBadRequest {
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp = decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
}
}
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
@@ -211,15 +416,42 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
}
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)
}
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
var rawListSpec map[string]json.RawMessage
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
t.Fatalf("unmarshal list proxy spec failed: %v", err)
}
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
"curConns",
"lastCloseAt",
"lastStartAt",
"phase",
"todayTrafficIn",
"todayTrafficOut",
)
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
}
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
var rawDetailSpec map[string]json.RawMessage
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
}
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
}
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
@@ -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) {
tests := []struct {
name string
@@ -247,66 +613,85 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
}{
{
name: "tcp remote port",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
RemotePort: 6000,
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
}},
q: "6000",
want: true,
},
{
name: "udp remote port",
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
RemotePort: 7000,
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
Type: "udp",
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
}},
q: "7000",
want: true,
},
{
name: "remote port does not match colon form",
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
RemotePort: 6000,
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
}},
q: ":6000",
want: false,
},
{
name: "http custom domain",
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
Type: "http",
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
}},
q: "app.example.com",
want: true,
},
{
name: "https subdomain",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
Type: "https",
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
}},
q: "portal",
want: true,
},
{
name: "subdomain does not match expanded host",
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
Type: "https",
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
}},
q: "portal.example.com",
want: false,
},
{
name: "tcpmux custom domain",
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
Type: "tcpmux",
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
}},
q: "mux.example.com",
want: true,
},
{
name: "nil spec does not match spec fields",
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
name: "offline shell does not match online spec fields",
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{},
}},
q: "6000",
want: false,
},
{
name: "offline shell does not contribute zero remote port",
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{},
}},
q: "0",
want: false,
},
}
for _, tt := range tests {
@@ -322,7 +707,26 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/clients")
resp := performRequest(router, "/api/serverinfo")
var serverInfo model.ServerInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
}
if serverInfo.Version == "" {
t.Fatal("legacy serverinfo version should be set")
}
var serverInfoRaw map[string]json.RawMessage
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
}
if _, ok := serverInfoRaw["data"]; ok {
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
}
if _, ok := serverInfoRaw["config"]; ok {
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
}
resp = performRequest(router, "/api/clients")
var clients []model.ClientInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
@@ -343,6 +747,28 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
}
resp = performRequest(router, "/api/traffic/tcp-alice")
var traffic model.GetProxyTrafficResp
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
}
if traffic.Name != "tcp-alice" ||
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
}
var trafficRaw map[string]json.RawMessage
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
}
if _, ok := trafficRaw["data"]; ok {
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
}
}
func v2TestIntPtr(value int) *int {
return &value
}
func newV2TestController(t *testing.T) *Controller {
@@ -368,6 +794,10 @@ func newV2TestController(t *testing.T) *Controller {
TodayTrafficIn: 30,
TodayTrafficOut: 40,
CurConns: 2,
LastStartTime: "07-08 12:30:00",
LastCloseTime: "07-08 12:31:40",
LastStartAt: 1783504200,
LastCloseAt: 1783504300,
},
"http-alice": {
Name: "http-alice",
@@ -383,6 +813,13 @@ func newV2TestController(t *testing.T) *Controller {
ClientID: "client-b",
},
},
traffic: map[string]*mem.ProxyTrafficInfo{
"tcp-alice": {
Name: "tcp-alice",
TrafficIn: []int64{7, 6},
TrafficOut: []int64{70, 60},
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
@@ -400,19 +837,28 @@ func newV2TestController(t *testing.T) *Controller {
func newV2TestRouter(controller *Controller) *mux.Router {
router := mux.NewRouter()
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
encodedPathRouter := router.NewRoute().Subrouter()
encodedPathRouter.UseEncodedPath()
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
return router
}
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, target, nil)
return performRequestWithMethod(handler, http.MethodGet, target)
}
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
return resp
@@ -427,3 +873,36 @@ func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
}
return out
}
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
t.Helper()
if len(raw) != len(want) {
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
}
for _, key := range want {
if _, ok := raw[key]; !ok {
t.Fatalf("json key %q missing from %v", key, raw)
}
}
}
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
t.Helper()
var out map[string]json.RawMessage
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
}
assertRawJSONKeys(t, out, want...)
}
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
t.Helper()
out, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json failed: %v", err)
}
return out
}

View File

@@ -21,6 +21,41 @@ type V2PageResp[T any] struct {
Items []T `json:"items"`
}
type V2SystemInfoResp struct {
Version string `json:"version"`
Config V2SystemInfoConfigResp `json:"config"`
Status V2SystemInfoStatusResp `json:"status"`
}
type V2SystemInfoConfigResp struct {
BindPort int `json:"bindPort"`
VhostHTTPPort int `json:"vhostHTTPPort"`
VhostHTTPSPort int `json:"vhostHTTPSPort"`
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
KCPBindPort int `json:"kcpBindPort"`
QUICBindPort int `json:"quicBindPort"`
SubdomainHost string `json:"subdomainHost"`
MaxPoolCount int64 `json:"maxPoolCount"`
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
AllowPortsStr string `json:"allowPortsStr"`
TLSForce bool `json:"tlsForce"`
}
type V2SystemInfoStatusResp struct {
TotalTrafficIn int64 `json:"totalTrafficIn"`
TotalTrafficOut int64 `json:"totalTrafficOut"`
CurConns int64 `json:"curConns"`
ClientCounts int64 `json:"clientCounts"`
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
type V2SystemPruneResp struct {
Type string `json:"type"`
Cleared int `json:"cleared"`
Total int `json:"total"`
}
type V2UserResp struct {
User string `json:"user"`
ClientCount int `json:"clientCount"`
@@ -40,18 +75,105 @@ type V2ClientStatusResp struct {
type V2ProxyResp struct {
Name string `json:"name"`
Type string `json:"type"`
User string `json:"user"`
ClientID string `json:"clientID"`
Spec any `json:"spec"`
Spec V2ProxySpec `json:"spec"`
Status V2ProxyStatusResp `json:"status"`
}
type V2ProxySpec struct {
Type string `json:"type"`
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
UDP *V2UDPProxySpec `json:"udp,omitempty"`
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
}
type V2ProxyBaseSpec struct {
Annotations map[string]string `json:"annotations,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
}
type V2ProxyTransportSpec struct {
UseEncryption bool `json:"useEncryption"`
UseCompression bool `json:"useCompression"`
BandwidthLimit string `json:"bandwidthLimit"`
BandwidthLimitMode string `json:"bandwidthLimitMode"`
}
type V2ProxyLoadBalancerSpec struct {
Group string `json:"group"`
}
type V2TCPProxySpec struct {
V2ProxyBaseSpec
RemotePort *int `json:"remotePort,omitempty"`
}
type V2UDPProxySpec struct {
V2ProxyBaseSpec
RemotePort *int `json:"remotePort,omitempty"`
}
type V2HTTPProxySpec struct {
V2ProxyBaseSpec
CustomDomains []string `json:"customDomains,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
Locations []string `json:"locations,omitempty"`
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
}
type V2HTTPSProxySpec struct {
V2ProxyBaseSpec
CustomDomains []string `json:"customDomains,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
}
type V2TCPMuxProxySpec struct {
V2ProxyBaseSpec
CustomDomains []string `json:"customDomains,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
Multiplexer string `json:"multiplexer,omitempty"`
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
}
type V2STCPProxySpec struct {
V2ProxyBaseSpec
}
type V2SUDPProxySpec struct {
V2ProxyBaseSpec
}
type V2XTCPProxySpec struct {
V2ProxyBaseSpec
}
type V2ProxyStatusResp struct {
State string `json:"phase"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
LastStartAt int64 `json:"lastStartAt,omitempty"`
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
}
type V2ProxyTrafficResp struct {
Name string `json:"name"`
Unit string `json:"unit"`
Granularity string `json:"granularity"`
History []V2ProxyTrafficPointResp `json:"history"`
}
type V2ProxyTrafficPointResp struct {
Date string `json:"date"`
TrafficIn int64 `json:"trafficIn"`
TrafficOut int64 `json:"trafficOut"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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