mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 09:19:17 +08:00
Compare commits
17 Commits
68509f5d44
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6e01f04f | ||
|
|
5396947c7c | ||
|
|
fe61093bc2 | ||
|
|
54aeb2a7b0 | ||
|
|
84be1938e4 | ||
|
|
becee40715 | ||
|
|
17e788d43b | ||
|
|
8dd26c6961 | ||
|
|
c8c1e5116c | ||
|
|
4ec8de973f | ||
|
|
5bfcea3d0c | ||
|
|
0a1b4ab21f | ||
|
|
5f575b8442 | ||
|
|
a1348cdf00 | ||
|
|
2f5e1f7945 | ||
|
|
22ae8166d3 | ||
|
|
af6bc6369d |
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
[](https://circleci.com/gh/fatedier/frp)
|
[](https://circleci.com/gh/fatedier/frp)
|
||||||
[](https://github.com/fatedier/frp/releases)
|
[](https://github.com/fatedier/frp/releases)
|
||||||
[](https://goreportcard.com/report/github.com/fatedier/frp)
|
|
||||||
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||||
|
|
||||||
[README](README.md) | [中文文档](README_zh.md)
|
[README](README.md) | [中文文档](README_zh.md)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -239,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ package http
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -255,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)
|
||||||
@@ -435,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...)
|
||||||
@@ -526,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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
393
server/http/controller_v2_proxy_spec_test.go
Normal file
393
server/http/controller_v2_proxy_spec_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -416,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)
|
||||||
@@ -586,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 {
|
||||||
@@ -721,6 +767,10 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func v2TestIntPtr(value int) *int {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func newV2TestController(t *testing.T) *Controller {
|
func newV2TestController(t *testing.T) *Controller {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -744,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",
|
||||||
|
|||||||
@@ -75,20 +75,94 @@ 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 {
|
type V2ProxyTrafficResp struct {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
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'
|
||||||
|
|
||||||
@@ -38,19 +42,53 @@ export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export 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.state || 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}`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -28,21 +28,91 @@ 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 {
|
||||||
state?: string
|
phase: 'online' | 'offline'
|
||||||
phase?: string
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user