mirror of
https://github.com/fatedier/frp.git
synced 2026-07-21 13:29:17 +08:00
Compare commits
11 Commits
v0.69.1
...
ae1c0504ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae1c0504ec | ||
|
|
393a533744 | ||
|
|
035889c360 | ||
|
|
940bde5c46 | ||
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec | ||
|
|
9bde0b07de | ||
|
|
c6c545289c | ||
|
|
503afe78b7 |
11
Release.md
11
Release.md
@@ -1,9 +1,10 @@
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
* `transport.wireProtocol = "v2"` now also applies to UDP-based proxy payloads, including ordinary UDP and SUDP, so their payload framing is consistent with the selected wire protocol.
|
* Added dashboard API v2 pagination endpoints for users, clients, and proxies.
|
||||||
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
|
* 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.
|
||||||
* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
|
|
||||||
|
|
||||||
## Compatibility Notes
|
## Fixes
|
||||||
|
|
||||||
* When enabling `transport.wireProtocol = "v2"` for SUDP, upgrade both the proxy and visitor frpc instances first, or keep them on `v1` until both sides are upgraded.
|
* WebSocket and WSS tunnel payloads are now sent as binary frames, avoiding disconnects through RFC-compliant intermediaries that validate text frames as UTF-8.
|
||||||
|
* The `tls2raw` client plugin now writes the proxy protocol header to the local raw connection when proxy protocol is enabled.
|
||||||
|
* frpc now rejects duplicate proxy and visitor names in config files instead of silently overwriting earlier entries.
|
||||||
|
|||||||
@@ -394,6 +394,10 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +421,31 @@ func LoadClientConfig(path string, strict bool) (
|
|||||||
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateNoDuplicateNames rejects proxies or visitors that share a name. They are
|
||||||
|
// keyed by name in the config sources, so a duplicate would otherwise be silently
|
||||||
|
// overwritten and never started, with no error or log.
|
||||||
|
func validateNoDuplicateNames(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
|
||||||
|
proxyNames := make(map[string]struct{}, len(proxies))
|
||||||
|
for _, p := range proxies {
|
||||||
|
name := p.GetBaseConfig().Name
|
||||||
|
if _, ok := proxyNames[name]; ok {
|
||||||
|
return fmt.Errorf("proxy name [%s] is duplicated", name)
|
||||||
|
}
|
||||||
|
proxyNames[name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
visitorNames := make(map[string]struct{}, len(visitors))
|
||||||
|
for _, v := range visitors {
|
||||||
|
name := v.GetBaseConfig().Name
|
||||||
|
if _, ok := visitorNames[name]; ok {
|
||||||
|
return fmt.Errorf("visitor name [%s] is duplicated", name)
|
||||||
|
}
|
||||||
|
visitorNames[name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
||||||
proxyCfgs := proxies
|
proxyCfgs := proxies
|
||||||
for _, c := range proxyCfgs {
|
for _, c := range proxyCfgs {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -462,6 +464,111 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
|
|||||||
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadClientConfigResult_DuplicateNames(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
errSubstr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "duplicate proxy names",
|
||||||
|
content: `
|
||||||
|
serverAddr = "127.0.0.1"
|
||||||
|
serverPort = 7000
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "dup"
|
||||||
|
type = "tcp"
|
||||||
|
localPort = 22
|
||||||
|
remotePort = 6000
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "dup"
|
||||||
|
type = "tcp"
|
||||||
|
localPort = 3306
|
||||||
|
remotePort = 6001
|
||||||
|
`,
|
||||||
|
errSubstr: "proxy name [dup] is duplicated",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate visitor names",
|
||||||
|
content: `
|
||||||
|
serverAddr = "127.0.0.1"
|
||||||
|
serverPort = 7000
|
||||||
|
|
||||||
|
[[visitors]]
|
||||||
|
name = "dup"
|
||||||
|
type = "stcp"
|
||||||
|
serverName = "a"
|
||||||
|
secretKey = "secret"
|
||||||
|
bindPort = 9001
|
||||||
|
|
||||||
|
[[visitors]]
|
||||||
|
name = "dup"
|
||||||
|
type = "stcp"
|
||||||
|
serverName = "b"
|
||||||
|
secretKey = "secret"
|
||||||
|
bindPort = 9002
|
||||||
|
`,
|
||||||
|
errSubstr: "visitor name [dup] is duplicated",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unique names",
|
||||||
|
content: `
|
||||||
|
serverAddr = "127.0.0.1"
|
||||||
|
serverPort = 7000
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "p1"
|
||||||
|
type = "tcp"
|
||||||
|
localPort = 22
|
||||||
|
remotePort = 6000
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "p2"
|
||||||
|
type = "tcp"
|
||||||
|
localPort = 3306
|
||||||
|
remotePort = 6001
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same name across proxy and visitor",
|
||||||
|
content: `
|
||||||
|
serverAddr = "127.0.0.1"
|
||||||
|
serverPort = 7000
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "same"
|
||||||
|
type = "tcp"
|
||||||
|
localPort = 22
|
||||||
|
remotePort = 6000
|
||||||
|
|
||||||
|
[[visitors]]
|
||||||
|
name = "same"
|
||||||
|
type = "stcp"
|
||||||
|
serverName = "a"
|
||||||
|
secretKey = "secret"
|
||||||
|
bindPort = 9001
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
path := filepath.Join(t.TempDir(), "frpc.toml")
|
||||||
|
require.NoError(os.WriteFile(path, []byte(tc.content), 0o600))
|
||||||
|
|
||||||
|
_, err := LoadClientConfigResult(path, false)
|
||||||
|
if tc.errSubstr == "" {
|
||||||
|
require.NoError(err)
|
||||||
|
} else {
|
||||||
|
require.ErrorContains(err, tc.errSubstr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
||||||
func TestYAMLEdgeCases(t *testing.T) {
|
func TestYAMLEdgeCases(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if connInfo.ProxyProtocolHeader != nil {
|
||||||
|
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
|
||||||
|
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
|
||||||
|
rawConn.Close()
|
||||||
|
tlsConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
libio.Join(tlsConn, rawConn)
|
libio.Join(tlsConn, rawConn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ type GeneralResponse struct {
|
|||||||
Msg string
|
Msg string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type V2Response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
// APIHandler is a handler function that returns a response object or an error.
|
// APIHandler is a handler function that returns a response object or an error.
|
||||||
type APIHandler func(ctx *Context) (any, error)
|
type APIHandler func(ctx *Context) (any, error)
|
||||||
|
|
||||||
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
|
||||||
|
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := NewContext(w, r)
|
||||||
|
res, err := handler(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
|
||||||
|
code := http.StatusInternalServerError
|
||||||
|
if e, ok := err.(*Error); ok {
|
||||||
|
code = e.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||||
|
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||||
|
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||||
|
// frames and close the connection on invalid bytes.
|
||||||
|
conn.PayloadType = websocket.BinaryFrame
|
||||||
return ctx, conn, nil
|
return ctx, conn, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
|||||||
|
|
||||||
muxer := http.NewServeMux()
|
muxer := http.NewServeMux()
|
||||||
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
||||||
|
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||||
|
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||||
|
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||||
|
// frames and close the connection on invalid bytes.
|
||||||
|
c.PayloadType = websocket.BinaryFrame
|
||||||
notifyCh := make(chan struct{})
|
notifyCh := make(chan struct{})
|
||||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||||
close(notifyCh)
|
close(notifyCh)
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
||||||
|
|
||||||
|
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
||||||
|
subRouter.HandleFunc("/api/v2/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")
|
||||||
|
|
||||||
// view
|
// view
|
||||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||||
subRouter.PathPrefix("/static/").Handler(
|
subRouter.PathPrefix("/static/").Handler(
|
||||||
|
|||||||
438
server/http/controller_v2.go
Normal file
438
server/http/controller_v2.go
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
|
"github.com/fatedier/frp/server/http/model"
|
||||||
|
"github.com/fatedier/frp/server/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultV2Page = 1
|
||||||
|
defaultV2PageSize = 50
|
||||||
|
maxV2PageSize = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
var apiV2ProxyTypes = []string{
|
||||||
|
string(v1.ProxyTypeTCP),
|
||||||
|
string(v1.ProxyTypeUDP),
|
||||||
|
string(v1.ProxyTypeHTTP),
|
||||||
|
string(v1.ProxyTypeHTTPS),
|
||||||
|
string(v1.ProxyTypeTCPMUX),
|
||||||
|
string(v1.ProxyTypeSTCP),
|
||||||
|
string(v1.ProxyTypeXTCP),
|
||||||
|
string(v1.ProxyTypeSUDP),
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/users
|
||||||
|
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
||||||
|
page, pageSize, err := parseV2PageParams(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.clientRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("client registry unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
userStats := make(map[string]*model.V2UserResp)
|
||||||
|
for _, info := range c.clientRegistry.List() {
|
||||||
|
item := getOrCreateV2User(userStats, info.User)
|
||||||
|
item.ClientCount++
|
||||||
|
}
|
||||||
|
for _, proxyInfo := range c.listV2ProxyStats("") {
|
||||||
|
item := getOrCreateV2User(userStats, proxyInfo.User)
|
||||||
|
item.ProxyCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
q := strings.ToLower(ctx.Query("q"))
|
||||||
|
items := make([]model.V2UserResp, 0, len(userStats))
|
||||||
|
for _, item := range userStats {
|
||||||
|
if q != "" && !strings.Contains(strings.ToLower(item.User), q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, *item)
|
||||||
|
}
|
||||||
|
slices.SortFunc(items, func(a, b model.V2UserResp) int {
|
||||||
|
return cmp.Compare(a.User, b.User)
|
||||||
|
})
|
||||||
|
|
||||||
|
return buildV2PageResp(items, page, pageSize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/clients
|
||||||
|
func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
||||||
|
page, pageSize, err := parseV2PageParams(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.clientRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("client registry unavailable")
|
||||||
|
}
|
||||||
|
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userFilter, filterByUser := queryValue(ctx, "user")
|
||||||
|
clientIDFilter := ctx.Query("clientID")
|
||||||
|
runIDFilter := ctx.Query("runID")
|
||||||
|
q := strings.ToLower(ctx.Query("q"))
|
||||||
|
|
||||||
|
records := c.clientRegistry.List()
|
||||||
|
items := make([]model.ClientInfoResp, 0, len(records))
|
||||||
|
for _, info := range records {
|
||||||
|
if filterByUser && info.User != userFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runIDFilter != "" && info.RunID != runIDFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchV2StatusFilter(info.Online, statusFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp := buildClientInfoResp(info)
|
||||||
|
if q != "" && !matchV2ClientQuery(resp, q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
|
||||||
|
if v := cmp.Compare(a.User, b.User); v != 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Key, b.Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return buildV2PageResp(items, page, pageSize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/clients/{key}
|
||||||
|
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
||||||
|
key := ctx.Param("key")
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("missing client key")
|
||||||
|
}
|
||||||
|
decodedKey, err := url.PathUnescape(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid client key %q: %w", key, err)
|
||||||
|
}
|
||||||
|
key = decodedKey
|
||||||
|
|
||||||
|
if c.clientRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("client registry unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := c.clientRegistry.GetByKey(key)
|
||||||
|
if !ok {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildClientInfoResp(info)
|
||||||
|
status := c.buildV2ClientStatus(info)
|
||||||
|
return model.V2ClientDetailResp{
|
||||||
|
ClientInfoResp: resp,
|
||||||
|
Status: status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/proxies
|
||||||
|
func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||||
|
page, pageSize, err := parseV2PageParams(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userFilter, filterByUser := queryValue(ctx, "user")
|
||||||
|
clientIDFilter := ctx.Query("clientID")
|
||||||
|
q := strings.ToLower(ctx.Query("q"))
|
||||||
|
|
||||||
|
stats := c.listV2ProxyStats(proxyType)
|
||||||
|
items := make([]model.V2ProxyResp, 0, len(stats))
|
||||||
|
for _, ps := range stats {
|
||||||
|
resp := c.buildV2ProxyResp(ps)
|
||||||
|
if filterByUser && resp.User != userFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if clientIDFilter != "" && resp.ClientID != clientIDFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q != "" && !matchV2ProxyQuery(resp, q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
||||||
|
if v := cmp.Compare(a.Type, b.Type); v != 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return buildV2PageResp(items, page, pageSize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/proxies/{name}
|
||||||
|
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||||
|
name := ctx.Param("name")
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("missing proxy name")
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := mem.StatsCollector.GetProxyByName(name)
|
||||||
|
if ps == nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||||
|
}
|
||||||
|
return c.buildV2ProxyResp(ps), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
||||||
|
item, ok := items[user]
|
||||||
|
if !ok {
|
||||||
|
item = &model.V2UserResp{User: user}
|
||||||
|
items[user] = item
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseV2PageParams(ctx *httppkg.Context) (int, int, error) {
|
||||||
|
page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if pageSize > maxV2PageSize {
|
||||||
|
return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize))
|
||||||
|
}
|
||||||
|
if page > math.MaxInt/pageSize {
|
||||||
|
return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large")
|
||||||
|
}
|
||||||
|
return page, pageSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || value < 1 {
|
||||||
|
return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name))
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseV2StatusFilter(raw string) (string, error) {
|
||||||
|
status := strings.ToLower(raw)
|
||||||
|
switch status {
|
||||||
|
case "", "all", "online", "offline":
|
||||||
|
return status, nil
|
||||||
|
default:
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseV2ProxyTypeFilter(raw string) (string, error) {
|
||||||
|
proxyType := strings.ToLower(raw)
|
||||||
|
if proxyType == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if slices.Contains(apiV2ProxyTypes, proxyType) {
|
||||||
|
return proxyType, nil
|
||||||
|
}
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchV2StatusFilter(online bool, filter string) bool {
|
||||||
|
switch filter {
|
||||||
|
case "", "all":
|
||||||
|
return true
|
||||||
|
case "online":
|
||||||
|
return online
|
||||||
|
case "offline":
|
||||||
|
return !online
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] {
|
||||||
|
total := len(items)
|
||||||
|
return model.V2PageResp[T]{
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Items: paginateV2Items(items, page, pageSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paginateV2Items[T any](items []T, page, pageSize int) []T {
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
if start >= len(items) {
|
||||||
|
return []T{}
|
||||||
|
}
|
||||||
|
end := min(start+pageSize, len(items))
|
||||||
|
return items[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryValue(ctx *httppkg.Context, key string) (string, bool) {
|
||||||
|
values, ok := ctx.Req.URL.Query()[key]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
return values[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
|
||||||
|
return containsV2Query(q,
|
||||||
|
item.Key,
|
||||||
|
item.User,
|
||||||
|
item.ClientID,
|
||||||
|
item.RunID,
|
||||||
|
item.Version,
|
||||||
|
item.WireProtocol,
|
||||||
|
item.Hostname,
|
||||||
|
item.ClientIP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
||||||
|
values := []string{
|
||||||
|
item.Name,
|
||||||
|
item.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return containsV2Query(q, values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsV2Query(q string, values ...string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.Contains(strings.ToLower(value), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
||||||
|
if proxyType != "" {
|
||||||
|
return mem.StatsCollector.GetProxiesByType(proxyType)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*mem.ProxyStats, 0)
|
||||||
|
for _, t := range apiV2ProxyTypes {
|
||||||
|
items = append(items, mem.StatsCollector.GetProxiesByType(t)...)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
||||||
|
status := model.V2ClientStatusResp{State: "offline"}
|
||||||
|
if info.Online {
|
||||||
|
status.State = "online"
|
||||||
|
}
|
||||||
|
|
||||||
|
user := info.User
|
||||||
|
clientID := info.ClientID()
|
||||||
|
for _, ps := range c.listV2ProxyStats("") {
|
||||||
|
if ps.User != user || ps.ClientID != clientID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status.CurConns += ps.CurConns
|
||||||
|
status.ProxyCount++
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
||||||
|
state := "offline"
|
||||||
|
var spec any
|
||||||
|
if c.pxyManager != nil {
|
||||||
|
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||||
|
state = "online"
|
||||||
|
spec = getConfFromConfigurer(pxy.GetConfigurer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.V2ProxyResp{
|
||||||
|
Name: ps.Name,
|
||||||
|
Type: ps.Type,
|
||||||
|
User: ps.User,
|
||||||
|
ClientID: ps.ClientID,
|
||||||
|
Spec: spec,
|
||||||
|
Status: model.V2ProxyStatusResp{
|
||||||
|
State: state,
|
||||||
|
TodayTrafficIn: ps.TodayTrafficIn,
|
||||||
|
TodayTrafficOut: ps.TodayTrafficOut,
|
||||||
|
CurConns: ps.CurConns,
|
||||||
|
LastStartTime: ps.LastStartTime,
|
||||||
|
LastCloseTime: ps.LastCloseTime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
429
server/http/controller_v2_test.go
Normal file
429
server/http/controller_v2_test.go
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
|
"github.com/fatedier/frp/server/http/model"
|
||||||
|
serverproxy "github.com/fatedier/frp/server/proxy"
|
||||||
|
"github.com/fatedier/frp/server/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type v2EnvelopeForTest[T any] struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data T `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeStatsCollector struct {
|
||||||
|
proxies map[string]*mem.ProxyStats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||||
|
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
|
||||||
|
items := make([]*mem.ProxyStats, 0)
|
||||||
|
for _, ps := range f.proxies {
|
||||||
|
if ps.Type == proxyType {
|
||||||
|
items = append(items, ps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
|
||||||
|
ps := f.proxies[proxyName]
|
||||||
|
if ps != nil && ps.Type == proxyType {
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
||||||
|
return f.proxies[proxyName]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||||
|
return 0, len(f.proxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||||
|
if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
|
||||||
|
t.Fatalf("envelope mismatch: %#v", pageResp)
|
||||||
|
}
|
||||||
|
if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
|
||||||
|
t.Fatalf("page data mismatch: %#v", pageResp.Data)
|
||||||
|
}
|
||||||
|
if got := pageResp.Data.Items[0].User; got != "" {
|
||||||
|
t.Fatalf("first sorted user mismatch, want empty got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
|
||||||
|
emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||||
|
if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
|
||||||
|
t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
|
||||||
|
aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||||
|
if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
|
||||||
|
t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/clients?status=offline")
|
||||||
|
offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
||||||
|
if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
|
||||||
|
t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/clients?page=0")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
||||||
|
t.Fatalf("error envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/clients?pageSize=201")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/clients/alice.client-a")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||||
|
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
|
||||||
|
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
|
||||||
|
}
|
||||||
|
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
|
||||||
|
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
"tcp-url": {
|
||||||
|
Name: "tcp-url",
|
||||||
|
Type: "tcp",
|
||||||
|
User: "url",
|
||||||
|
ClientID: "client/a?b#c",
|
||||||
|
CurConns: 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
|
||||||
|
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
encodedKey := url.PathEscape("url.client/a?b#c")
|
||||||
|
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||||
|
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
|
||||||
|
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
|
||||||
|
}
|
||||||
|
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
|
||||||
|
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/proxies?type=invalid")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
||||||
|
t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
|
||||||
|
proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
|
||||||
|
if proxyResp.Data.Total != 1 {
|
||||||
|
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
||||||
|
}
|
||||||
|
proxyItem := proxyResp.Data.Items[0]
|
||||||
|
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||||
|
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
||||||
|
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
||||||
|
if userResp.Data.Total != 3 {
|
||||||
|
t.Fatalf("user total mismatch: %#v", userResp.Data)
|
||||||
|
}
|
||||||
|
expectedProxyCounts := map[string]int{
|
||||||
|
"": 1,
|
||||||
|
"alice": 2,
|
||||||
|
"bob": 1,
|
||||||
|
}
|
||||||
|
for _, item := range userResp.Data.Items {
|
||||||
|
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
|
||||||
|
t.Fatalf("user counts mismatch: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
item model.V2ProxyResp
|
||||||
|
q string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "tcp remote port",
|
||||||
|
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||||
|
RemotePort: 6000,
|
||||||
|
}},
|
||||||
|
q: "6000",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "udp remote port",
|
||||||
|
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
|
||||||
|
RemotePort: 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,
|
||||||
|
}},
|
||||||
|
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"}},
|
||||||
|
}},
|
||||||
|
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"},
|
||||||
|
}},
|
||||||
|
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"},
|
||||||
|
}},
|
||||||
|
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"}},
|
||||||
|
}},
|
||||||
|
q: "mux.example.com",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil spec does not match spec fields",
|
||||||
|
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
||||||
|
q: "6000",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
|
||||||
|
t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/clients")
|
||||||
|
var clients []model.ClientInfoResp
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
|
||||||
|
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
|
||||||
|
}
|
||||||
|
if len(clients) != 3 {
|
||||||
|
t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/proxy/tcp")
|
||||||
|
var proxies model.GetProxyInfoResp
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
|
||||||
|
t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
|
||||||
|
}
|
||||||
|
if len(proxies.Proxies) != 2 {
|
||||||
|
t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
|
||||||
|
}
|
||||||
|
var envelope httppkg.V2Response
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
||||||
|
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newV2TestController(t *testing.T) *Controller {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
"tcp-empty": {
|
||||||
|
Name: "tcp-empty",
|
||||||
|
Type: "tcp",
|
||||||
|
User: "",
|
||||||
|
ClientID: "legacy-client",
|
||||||
|
TodayTrafficIn: 10,
|
||||||
|
TodayTrafficOut: 20,
|
||||||
|
CurConns: 1,
|
||||||
|
},
|
||||||
|
"tcp-alice": {
|
||||||
|
Name: "tcp-alice",
|
||||||
|
Type: "tcp",
|
||||||
|
User: "alice",
|
||||||
|
ClientID: "client-a",
|
||||||
|
TodayTrafficIn: 30,
|
||||||
|
TodayTrafficOut: 40,
|
||||||
|
CurConns: 2,
|
||||||
|
},
|
||||||
|
"http-alice": {
|
||||||
|
Name: "http-alice",
|
||||||
|
Type: "http",
|
||||||
|
User: "alice",
|
||||||
|
ClientID: "client-a",
|
||||||
|
CurConns: 3,
|
||||||
|
},
|
||||||
|
"udp-bob": {
|
||||||
|
Name: "udp-bob",
|
||||||
|
Type: "udp",
|
||||||
|
User: "bob",
|
||||||
|
ClientID: "client-b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
clientRegistry := registry.NewClientRegistry()
|
||||||
|
clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
|
||||||
|
clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
|
||||||
|
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
|
||||||
|
clientRegistry.MarkOfflineByRunID("run-b")
|
||||||
|
|
||||||
|
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newV2TestRouter(controller *Controller) *mux.Router {
|
||||||
|
router := mux.NewRouter()
|
||||||
|
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/v2/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)
|
||||||
|
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(resp, req)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out T
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
57
server/http/model/v2.go
Normal file
57
server/http/model/v2.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
type V2PageResp[T any] struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
Items []T `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2UserResp struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
ClientCount int `json:"clientCount"`
|
||||||
|
ProxyCount int `json:"proxyCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ClientDetailResp struct {
|
||||||
|
ClientInfoResp
|
||||||
|
Status V2ClientStatusResp `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ClientStatusResp struct {
|
||||||
|
State string `json:"phase"`
|
||||||
|
CurConns int64 `json:"curConns"`
|
||||||
|
ProxyCount int64 `json:"proxyCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyResp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
User string `json:"user"`
|
||||||
|
ClientID string `json:"clientID"`
|
||||||
|
Spec any `json:"spec"`
|
||||||
|
Status V2ProxyStatusResp `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
"github.com/onsi/ginkgo/v2"
|
||||||
|
pp "github.com/pires/go-proxyproto"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
"github.com/fatedier/frp/test/e2e/framework"
|
"github.com/fatedier/frp/test/e2e/framework"
|
||||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||||
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
||||||
|
"github.com/fatedier/frp/test/e2e/mock/server/streamserver"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/port"
|
"github.com/fatedier/frp/test/e2e/pkg/port"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||||
@@ -450,4 +456,85 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
|||||||
ExpectResp([]byte("test")).
|
ExpectResp([]byte("test")).
|
||||||
Ensure()
|
Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ginkgo.It("tls2raw with proxy protocol v2", func() {
|
||||||
|
generator := &cert.SelfSignedCertGenerator{}
|
||||||
|
artifacts, err := generator.Generate("example.com")
|
||||||
|
framework.ExpectNoError(err)
|
||||||
|
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
|
||||||
|
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
|
||||||
|
|
||||||
|
serverConf := consts.DefaultServerConfig
|
||||||
|
vhostHTTPSPort := f.AllocPort()
|
||||||
|
serverConf += fmt.Sprintf(`
|
||||||
|
vhostHTTPSPort = %d
|
||||||
|
`, vhostHTTPSPort)
|
||||||
|
|
||||||
|
localPort := f.AllocPort()
|
||||||
|
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||||
|
[[proxies]]
|
||||||
|
name = "tls2raw-proxy-protocol-test"
|
||||||
|
type = "https"
|
||||||
|
customDomains = ["example.com"]
|
||||||
|
transport.proxyProtocolVersion = "v2"
|
||||||
|
[proxies.plugin]
|
||||||
|
type = "tls2raw"
|
||||||
|
localAddr = "127.0.0.1:%d"
|
||||||
|
crtPath = "%s"
|
||||||
|
keyPath = "%s"
|
||||||
|
`, localPort, crtPath, keyPath)
|
||||||
|
|
||||||
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
|
|
||||||
|
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
|
||||||
|
streamserver.WithCustomHandler(func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
writeResp := func(body string) {
|
||||||
|
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rd := bufio.NewReader(c)
|
||||||
|
ppHeader, err := pp.Read(rd)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("read proxy protocol error: %v", err)
|
||||||
|
writeResp("missing proxy protocol")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ppHeader.Version != 2 {
|
||||||
|
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
|
||||||
|
writeResp("unexpected proxy protocol version")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
|
||||||
|
if !ok || srcAddr.IP.String() != "127.0.0.1" {
|
||||||
|
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
|
||||||
|
writeResp("unexpected proxy protocol source address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.ReadRequest(rd)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("read http request after proxy protocol error: %v", err)
|
||||||
|
writeResp("missing http request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, req.Body)
|
||||||
|
_ = req.Body.Close()
|
||||||
|
|
||||||
|
writeResp("test")
|
||||||
|
}))
|
||||||
|
f.RunServer("", localServer)
|
||||||
|
|
||||||
|
framework.NewRequestExpect(f).
|
||||||
|
Port(vhostHTTPSPort).
|
||||||
|
RequestModify(func(r *request.Request) {
|
||||||
|
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
|
||||||
|
ServerName: "example.com",
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
})
|
||||||
|
}).
|
||||||
|
ExpectResp([]byte("test")).
|
||||||
|
Ensure()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { http } from './http'
|
import { buildQueryString, http } from './http'
|
||||||
import type { ClientInfoData } from '../types/client'
|
import type { V2Page } from './http'
|
||||||
|
import type { ClientInfoData, ClientListV2Params } from '../types/client'
|
||||||
|
|
||||||
export const getClients = () => {
|
export const getClients = () => {
|
||||||
return http.get<ClientInfoData[]>('../api/clients')
|
return http.get<ClientInfoData[]>('../api/clients')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getClientsV2 = (params: ClientListV2Params = {}) => {
|
||||||
|
return http.getV2<V2Page<ClientInfoData>>(
|
||||||
|
`../api/v2/clients${buildQueryString({
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
status:
|
||||||
|
params.status && params.status !== 'all' ? params.status : undefined,
|
||||||
|
q: params.q || undefined,
|
||||||
|
user: params.user,
|
||||||
|
clientID: params.clientID || undefined,
|
||||||
|
runID: params.runID || undefined,
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const getClient = (key: string) => {
|
export const getClient = (key: string) => {
|
||||||
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getClientV2 = (key: string) => {
|
||||||
|
return http.getV2<ClientInfoData>(`../api/v2/clients/${encodeURIComponent(key)}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ class HTTPError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface V2Envelope<T> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface V2Page<T> {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
items: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryParamValue = string | number | boolean | null | undefined
|
||||||
|
|
||||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
const defaultOptions: RequestInit = {
|
const defaultOptions: RequestInit = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -34,9 +49,55 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestV2<T>(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const defaultOptions: RequestInit = {
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...defaultOptions, ...options })
|
||||||
|
const envelope = (await response.json().catch(() => null)) as
|
||||||
|
| V2Envelope<T>
|
||||||
|
| null
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HTTPError(
|
||||||
|
response.status,
|
||||||
|
response.statusText,
|
||||||
|
envelope?.msg || `HTTP ${response.status}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!envelope || typeof envelope.code !== 'number') {
|
||||||
|
throw new Error('Invalid API v2 response')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.code >= 400) {
|
||||||
|
throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildQueryString = (
|
||||||
|
params: Record<string, QueryParamValue>,
|
||||||
|
): string => {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === null || value === undefined) continue
|
||||||
|
query.append(key, String(value))
|
||||||
|
}
|
||||||
|
const text = query.toString()
|
||||||
|
return text ? `?${text}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
export const http = {
|
export const http = {
|
||||||
get: <T>(url: string, options?: RequestInit) =>
|
get: <T>(url: string, options?: RequestInit) =>
|
||||||
request<T>(url, { ...options, method: 'GET' }),
|
request<T>(url, { ...options, method: 'GET' }),
|
||||||
|
getV2: <T>(url: string, options?: RequestInit) =>
|
||||||
|
requestV2<T>(url, { ...options, method: 'GET' }),
|
||||||
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||||
request<T>(url, {
|
request<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { http } from './http'
|
import { buildQueryString, http } from './http'
|
||||||
|
import type { V2Page } from './http'
|
||||||
import type {
|
import type {
|
||||||
GetProxyResponse,
|
GetProxyResponse,
|
||||||
|
ProxyListV2Params,
|
||||||
ProxyStatsInfo,
|
ProxyStatsInfo,
|
||||||
|
ProxyV2Info,
|
||||||
TrafficResponse,
|
TrafficResponse,
|
||||||
} from '../types/proxy'
|
} from '../types/proxy'
|
||||||
|
|
||||||
@@ -9,6 +12,40 @@ export const getProxiesByType = (type: string) => {
|
|||||||
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
||||||
|
const page = await http.getV2<V2Page<ProxyV2Info>>(
|
||||||
|
`../api/v2/proxies${buildQueryString({
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
status:
|
||||||
|
params.status && params.status !== 'all' ? params.status : undefined,
|
||||||
|
q: params.q || undefined,
|
||||||
|
type: params.type || undefined,
|
||||||
|
user: params.user,
|
||||||
|
clientID: params.clientID || undefined,
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
items: page.items.map(toLegacyProxyStats),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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,
|
||||||
|
})
|
||||||
|
|
||||||
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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,21 @@ export interface ClientInfoData {
|
|||||||
lastConnectedAt: number
|
lastConnectedAt: number
|
||||||
disconnectedAt?: number
|
disconnectedAt?: number
|
||||||
online: boolean
|
online: boolean
|
||||||
|
status?: ClientStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientStatus {
|
||||||
|
phase: 'online' | 'offline'
|
||||||
|
curConns: number
|
||||||
|
proxyCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientListV2Params {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: 'all' | 'online' | 'offline'
|
||||||
|
q?: string
|
||||||
|
user?: string
|
||||||
|
clientID?: string
|
||||||
|
runID?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface ProxyStatsInfo {
|
export interface ProxyStatsInfo {
|
||||||
name: string
|
name: string
|
||||||
|
type?: string
|
||||||
conf: any
|
conf: any
|
||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
@@ -15,6 +16,34 @@ export interface GetProxyResponse {
|
|||||||
proxies: ProxyStatsInfo[]
|
proxies: ProxyStatsInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxyListV2Params {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: 'all' | 'online' | 'offline'
|
||||||
|
q?: string
|
||||||
|
type?: string
|
||||||
|
user?: string
|
||||||
|
clientID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2Info {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
user: string
|
||||||
|
clientID: string
|
||||||
|
spec: any
|
||||||
|
status: ProxyV2Status
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2Status {
|
||||||
|
phase: string
|
||||||
|
todayTrafficIn: number
|
||||||
|
todayTrafficOut: number
|
||||||
|
curConns: number
|
||||||
|
lastStartTime: string
|
||||||
|
lastCloseTime: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrafficResponse {
|
export interface TrafficResponse {
|
||||||
name: string
|
name: string
|
||||||
trafficIn: number[]
|
trafficIn: number[]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { formatDistanceToNow } from './format'
|
import { formatDistanceToNow } from './format'
|
||||||
import type { ClientInfoData } from '../types/client'
|
import type { ClientInfoData, ClientStatus } from '../types/client'
|
||||||
|
|
||||||
export class Client {
|
export class Client {
|
||||||
key: string
|
key: string
|
||||||
@@ -15,6 +15,7 @@ export class Client {
|
|||||||
lastConnectedAt: Date
|
lastConnectedAt: Date
|
||||||
disconnectedAt?: Date
|
disconnectedAt?: Date
|
||||||
online: boolean
|
online: boolean
|
||||||
|
status: ClientStatus
|
||||||
|
|
||||||
constructor(data: ClientInfoData) {
|
constructor(data: ClientInfoData) {
|
||||||
this.key = data.key
|
this.key = data.key
|
||||||
@@ -37,6 +38,11 @@ export class Client {
|
|||||||
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
|
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
|
||||||
}
|
}
|
||||||
this.online = data.online
|
this.online = data.online
|
||||||
|
this.status = data.status || {
|
||||||
|
phase: this.online ? 'online' : 'offline',
|
||||||
|
curConns: 0,
|
||||||
|
proxyCount: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get displayName(): string {
|
get displayName(): string {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">Connections</span>
|
<span class="info-label">Connections</span>
|
||||||
<span class="info-value">{{ totalConnections }}</span>
|
<span class="info-value">{{ client.status.curConns }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">Run ID</span>
|
<span class="info-label">Run ID</span>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<div class="proxies-header">
|
<div class="proxies-header">
|
||||||
<div class="proxies-title">
|
<div class="proxies-title">
|
||||||
<h2>Proxies</h2>
|
<h2>Proxies</h2>
|
||||||
<span class="proxies-count">{{ filteredProxies.length }}</span>
|
<span class="proxies-count">{{ total }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="proxySearch"
|
v-model="proxySearch"
|
||||||
@@ -100,21 +100,32 @@
|
|||||||
<el-icon class="is-loading"><Loading /></el-icon>
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
<span>Loading...</span>
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
|
<div v-else-if="proxies.length > 0" class="proxies-list">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
v-for="proxy in filteredProxies"
|
v-for="proxy in proxies"
|
||||||
:key="proxy.name"
|
:key="`${proxy.type}:${proxy.name}`"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
show-type
|
show-type
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="clientProxies.length > 0" class="empty-state">
|
<div v-else-if="proxySearch.trim()" class="empty-state">
|
||||||
<p>No proxies match "{{ proxySearch }}"</p>
|
<p>No proxies match "{{ proxySearch }}"</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<p>No proxies found</p>
|
<p>No proxies found</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="total > 0" class="pagination-section">
|
||||||
|
<ElPagination
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
@size-change="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -130,13 +141,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElPagination } from 'element-plus'
|
||||||
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
||||||
import { Client } from '../utils/client'
|
import { Client } from '../utils/client'
|
||||||
import { getClient } from '../api/client'
|
import { getClientV2 } from '../api/client'
|
||||||
import { getProxiesByType } from '../api/proxy'
|
import { getProxiesV2 } from '../api/proxy'
|
||||||
import {
|
import {
|
||||||
BaseProxy,
|
BaseProxy,
|
||||||
TCPProxy,
|
TCPProxy,
|
||||||
@@ -149,6 +160,7 @@ import {
|
|||||||
} from '../utils/proxy'
|
} from '../utils/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import ProxyCard from '../components/ProxyCard.vue'
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
|
import type { ProxyStatsInfo } from '../types/proxy'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -163,119 +175,190 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const proxiesLoading = ref(false)
|
const proxiesLoading = ref(false)
|
||||||
const allProxies = ref<BaseProxy[]>([])
|
const proxies = ref<BaseProxy[]>([])
|
||||||
const proxySearch = ref('')
|
const proxySearch = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
let requestSeq = 0
|
||||||
|
let searchDebounceTimer: number | null = null
|
||||||
|
|
||||||
let serverInfo: {
|
type ServerInfoLite = {
|
||||||
vhostHTTPPort: number
|
vhostHTTPPort: number
|
||||||
vhostHTTPSPort: number
|
vhostHTTPSPort: number
|
||||||
tcpmuxHTTPConnectPort: number
|
tcpmuxHTTPConnectPort: number
|
||||||
subdomainHost: string
|
subdomainHost: string
|
||||||
} | null = null
|
|
||||||
|
|
||||||
const clientProxies = computed(() => {
|
|
||||||
if (!client.value) return []
|
|
||||||
return allProxies.value.filter(
|
|
||||||
(p) =>
|
|
||||||
p.clientID === client.value!.clientID && p.user === client.value!.user,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredProxies = computed(() => {
|
|
||||||
if (!proxySearch.value) return clientProxies.value
|
|
||||||
const search = proxySearch.value.toLowerCase()
|
|
||||||
return clientProxies.value.filter(
|
|
||||||
(p) =>
|
|
||||||
p.name.toLowerCase().includes(search) ||
|
|
||||||
p.type.toLowerCase().includes(search),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalConnections = computed(() => {
|
|
||||||
return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchServerInfo = async () => {
|
|
||||||
if (serverInfo) return serverInfo
|
|
||||||
const res = await getServerInfo()
|
|
||||||
serverInfo = res
|
|
||||||
return serverInfo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchClient = async () => {
|
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
||||||
|
|
||||||
|
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
||||||
|
if (!serverInfoPromise) {
|
||||||
|
serverInfoPromise = getServerInfo().catch((err) => {
|
||||||
|
serverInfoPromise = null
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return serverInfoPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchClient = async (): Promise<boolean> => {
|
||||||
const key = route.params.key as string
|
const key = route.params.key as string
|
||||||
if (!key) {
|
if (!key) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await getClient(key)
|
const data = await getClientV2(key)
|
||||||
client.value = new Client(data)
|
client.value = new Client(data)
|
||||||
|
return true
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error('Failed to fetch client: ' + error.message)
|
ElMessage.error('Failed to fetch client: ' + error.message)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchProxies = async () => {
|
const convertProxy = async (
|
||||||
proxiesLoading.value = true
|
proxy: ProxyStatsInfo,
|
||||||
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
|
): Promise<BaseProxy | null> => {
|
||||||
const proxies: BaseProxy[] = []
|
const type = proxy.type || ''
|
||||||
try {
|
if (type === 'tcp') {
|
||||||
|
return new TCPProxy(proxy)
|
||||||
|
}
|
||||||
|
if (type === 'udp') {
|
||||||
|
return new UDPProxy(proxy)
|
||||||
|
}
|
||||||
|
if (type === 'http') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
for (const type of proxyTypes) {
|
if (info && info.vhostHTTPPort) {
|
||||||
try {
|
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
||||||
const json = await getProxiesByType(type)
|
|
||||||
if (!json.proxies) continue
|
|
||||||
if (type === 'tcp') {
|
|
||||||
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
|
|
||||||
} else if (type === 'udp') {
|
|
||||||
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
|
|
||||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
|
||||||
proxies.push(
|
|
||||||
...json.proxies.map(
|
|
||||||
(p: any) =>
|
|
||||||
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
|
||||||
proxies.push(
|
|
||||||
...json.proxies.map(
|
|
||||||
(p: any) =>
|
|
||||||
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
|
||||||
proxies.push(
|
|
||||||
...json.proxies.map(
|
|
||||||
(p: any) =>
|
|
||||||
new TCPMuxProxy(
|
|
||||||
p,
|
|
||||||
info.tcpmuxHTTPConnectPort,
|
|
||||||
info.subdomainHost,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if (type === 'stcp') {
|
|
||||||
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
|
|
||||||
} else if (type === 'sudp') {
|
|
||||||
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
allProxies.value = proxies
|
return null
|
||||||
} catch {
|
}
|
||||||
// Ignore
|
if (type === 'https') {
|
||||||
|
const info = await fetchServerInfo()
|
||||||
|
if (info && info.vhostHTTPSPort) {
|
||||||
|
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (type === 'tcpmux') {
|
||||||
|
const info = await fetchServerInfo()
|
||||||
|
if (info && info.tcpmuxHTTPConnectPort) {
|
||||||
|
return new TCPMuxProxy(
|
||||||
|
proxy,
|
||||||
|
info.tcpmuxHTTPConnectPort,
|
||||||
|
info.subdomainHost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (type === 'stcp') {
|
||||||
|
return new STCPProxy(proxy)
|
||||||
|
}
|
||||||
|
if (type === 'sudp') {
|
||||||
|
return new SUDPProxy(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bp = new BaseProxy(proxy)
|
||||||
|
bp.type = type
|
||||||
|
return bp
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
||||||
|
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
||||||
|
return converted.filter((item): item is BaseProxy => item !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchProxies = async () => {
|
||||||
|
if (!client.value) return
|
||||||
|
const seq = ++requestSeq
|
||||||
|
proxiesLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const q = proxySearch.value.trim()
|
||||||
|
const data = await getProxiesV2({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
q: q || undefined,
|
||||||
|
clientID: client.value.clientID,
|
||||||
|
user: client.value.user,
|
||||||
|
})
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
|
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
||||||
|
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
||||||
|
page.value = maxPage
|
||||||
|
await fetchProxies()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const converted = await convertProxies(data.items)
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
|
proxies.value = converted
|
||||||
|
total.value = data.total
|
||||||
|
page.value = data.page
|
||||||
|
pageSize.value = data.pageSize
|
||||||
|
} catch (error: any) {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
ElMessage.error('Failed to fetch proxies: ' + error.message)
|
||||||
} finally {
|
} finally {
|
||||||
proxiesLoading.value = false
|
if (seq === requestSeq) {
|
||||||
|
proxiesLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const clearSearchDebounce = () => {
|
||||||
fetchClient()
|
if (searchDebounceTimer !== null) {
|
||||||
|
window.clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidateProxyRequests = () => {
|
||||||
|
requestSeq++
|
||||||
|
proxiesLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPageAndFetch = () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
fetchProxies()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (value: number) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = value
|
||||||
|
fetchProxies()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageSizeChange = (value: number) => {
|
||||||
|
pageSize.value = value
|
||||||
|
resetPageAndFetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(proxySearch, () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
invalidateProxyRequests()
|
||||||
|
page.value = 1
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
searchDebounceTimer = null
|
||||||
|
fetchProxies()
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const ok = await fetchClient()
|
||||||
|
if (!ok || !client.value) return
|
||||||
|
|
||||||
fetchProxies()
|
fetchProxies()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -483,6 +566,12 @@ html.dark .status-badge.online {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.proxies-list {
|
.proxies-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
>
|
>
|
||||||
<span class="status-dot" :class="tab.value"></span>
|
<span class="status-dot" :class="tab.value"></span>
|
||||||
<span class="tab-label">{{ tab.label }}</span>
|
<span class="tab-label">{{ tab.label }}</span>
|
||||||
<span class="tab-count">{{ tab.count }}</span>
|
<span v-if="tab.count !== null" class="tab-count">{{
|
||||||
|
tab.count
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,9 +35,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="clients-content">
|
<div v-loading="loading" class="clients-content">
|
||||||
<div v-if="filteredClients.length > 0" class="clients-list">
|
<div v-if="clients.length > 0" class="clients-list">
|
||||||
<ClientCard
|
<ClientCard
|
||||||
v-for="client in filteredClients"
|
v-for="client in clients"
|
||||||
:key="client.key"
|
:key="client.key"
|
||||||
:client="client"
|
:client="client"
|
||||||
/>
|
/>
|
||||||
@@ -44,82 +46,123 @@
|
|||||||
<el-empty description="No clients found" />
|
<el-empty description="No clients found" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="total > 0" class="pagination-section">
|
||||||
|
<ElPagination
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
@size-change="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElPagination } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { Client } from '../utils/client'
|
import { Client } from '../utils/client'
|
||||||
import ClientCard from '../components/ClientCard.vue'
|
import ClientCard from '../components/ClientCard.vue'
|
||||||
import { getClients } from '../api/client'
|
import { getClientsV2 } from '../api/client'
|
||||||
|
|
||||||
const clients = ref<Client[]>([])
|
const clients = ref<Client[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
|
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
let refreshTimer: number | null = null
|
let refreshTimer: number | null = null
|
||||||
|
let searchDebounceTimer: number | null = null
|
||||||
const stats = computed(() => {
|
let requestSeq = 0
|
||||||
const total = clients.value.length
|
|
||||||
const online = clients.value.filter((c) => c.online).length
|
|
||||||
const offline = total - online
|
|
||||||
return { total, online, offline }
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusTabs = computed(() => [
|
const statusTabs = computed(() => [
|
||||||
{ value: 'all' as const, label: 'All', count: stats.value.total },
|
{
|
||||||
{ value: 'online' as const, label: 'Online', count: stats.value.online },
|
value: 'all' as const,
|
||||||
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
|
label: 'All',
|
||||||
|
count: statusFilter.value === 'all' ? total.value : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'online' as const,
|
||||||
|
label: 'Online',
|
||||||
|
count: statusFilter.value === 'online' ? total.value : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'offline' as const,
|
||||||
|
label: 'Offline',
|
||||||
|
count: statusFilter.value === 'offline' ? total.value : null,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const filteredClients = computed(() => {
|
const fetchData = async (silent = false) => {
|
||||||
let result = clients.value
|
const seq = ++requestSeq
|
||||||
|
if (!silent) loading.value = true
|
||||||
// Filter by status
|
|
||||||
if (statusFilter.value === 'online') {
|
|
||||||
result = result.filter((c) => c.online)
|
|
||||||
} else if (statusFilter.value === 'offline') {
|
|
||||||
result = result.filter((c) => !c.online)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search text
|
|
||||||
if (searchText.value) {
|
|
||||||
result = result.filter((c) => c.matchesFilter(searchText.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: online first, then by display name
|
|
||||||
result.sort((a, b) => {
|
|
||||||
if (a.online !== b.online) {
|
|
||||||
return a.online ? -1 : 1
|
|
||||||
}
|
|
||||||
return a.displayName.localeCompare(b.displayName)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const json = await getClients()
|
const data = await getClientsV2({
|
||||||
clients.value = json.map((data) => new Client(data))
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
status: statusFilter.value,
|
||||||
|
q: searchText.value.trim(),
|
||||||
|
})
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
|
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
||||||
|
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
||||||
|
page.value = maxPage
|
||||||
|
await fetchData(silent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.value = data.items.map((item) => new Client(item))
|
||||||
|
total.value = data.total
|
||||||
|
page.value = data.page
|
||||||
|
pageSize.value = data.pageSize
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
message: 'Failed to fetch clients: ' + error.message,
|
message: 'Failed to fetch clients: ' + error.message,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === requestSeq) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSearchDebounce = () => {
|
||||||
|
if (searchDebounceTimer !== null) {
|
||||||
|
window.clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPageAndFetch = () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (value: number) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = value
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageSizeChange = (value: number) => {
|
||||||
|
pageSize.value = value
|
||||||
|
resetPageAndFetch()
|
||||||
|
}
|
||||||
|
|
||||||
const startAutoRefresh = () => {
|
const startAutoRefresh = () => {
|
||||||
refreshTimer = window.setInterval(() => {
|
refreshTimer = window.setInterval(() => {
|
||||||
fetchData()
|
fetchData(true)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +173,19 @@ const stopAutoRefresh = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(statusFilter, () => {
|
||||||
|
resetPageAndFetch()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(searchText, () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
searchDebounceTimer = null
|
||||||
|
fetchData()
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
@@ -137,6 +193,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
|
clearSearchDebounce()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -274,6 +331,11 @@ onUnmounted(() => {
|
|||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode adjustments */
|
/* Dark mode adjustments */
|
||||||
html.dark .status-tab {
|
html.dark .status-tab {
|
||||||
background: var(--el-bg-color-overlay);
|
background: var(--el-bg-color-overlay);
|
||||||
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
|
|||||||
.status-tab {
|
.status-tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions-section">
|
<div class="actions-section">
|
||||||
<ActionButton variant="outline" size="small" @click="fetchData">
|
<ActionButton variant="outline" size="small" @click="refreshData">
|
||||||
Refresh
|
Refresh
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
@@ -27,36 +27,6 @@
|
|||||||
clearable
|
clearable
|
||||||
class="main-search"
|
class="main-search"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PopoverMenu
|
|
||||||
:model-value="selectedClientKey"
|
|
||||||
:width="220"
|
|
||||||
placement="bottom-end"
|
|
||||||
selectable
|
|
||||||
filterable
|
|
||||||
filter-placeholder="Search clients..."
|
|
||||||
:display-value="selectedClientLabel"
|
|
||||||
clearable
|
|
||||||
class="client-filter"
|
|
||||||
@update:model-value="onClientFilterChange($event as string)"
|
|
||||||
>
|
|
||||||
<template #default="{ filterText }">
|
|
||||||
<PopoverMenuItem value="">All Clients</PopoverMenuItem>
|
|
||||||
<PopoverMenuItem
|
|
||||||
v-if="clientIDFilter && !selectedClientInList"
|
|
||||||
:value="selectedClientKey"
|
|
||||||
>
|
|
||||||
{{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)
|
|
||||||
</PopoverMenuItem>
|
|
||||||
<PopoverMenuItem
|
|
||||||
v-for="client in filteredClientOptions(filterText)"
|
|
||||||
:key="client.key"
|
|
||||||
:value="client.key"
|
|
||||||
>
|
|
||||||
{{ client.label }}
|
|
||||||
</PopoverMenuItem>
|
|
||||||
</template>
|
|
||||||
</PopoverMenu>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="type-tabs">
|
<div class="type-tabs">
|
||||||
@@ -74,9 +44,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="proxies-content">
|
<div v-loading="loading" class="proxies-content">
|
||||||
<div v-if="filteredProxies.length > 0" class="proxies-list">
|
<div v-if="proxies.length > 0" class="proxies-list">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
v-for="proxy in filteredProxies"
|
v-for="proxy in proxies"
|
||||||
:key="`${proxy.type}:${proxy.name}`"
|
:key="`${proxy.type}:${proxy.name}`"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
:show-type="activeType === 'all'"
|
:show-type="activeType === 'all'"
|
||||||
@@ -87,6 +57,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="total > 0" class="pagination-section">
|
||||||
|
<ElPagination
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
@size-change="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
v-model="showClearDialog"
|
v-model="showClearDialog"
|
||||||
title="Clear Offline"
|
title="Clear Offline"
|
||||||
@@ -99,9 +81,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElPagination } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import ActionButton from '@shared/components/ActionButton.vue'
|
import ActionButton from '@shared/components/ActionButton.vue'
|
||||||
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
||||||
@@ -116,15 +98,12 @@ import {
|
|||||||
SUDPProxy,
|
SUDPProxy,
|
||||||
} from '../utils/proxy'
|
} from '../utils/proxy'
|
||||||
import ProxyCard from '../components/ProxyCard.vue'
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
|
||||||
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
|
||||||
import {
|
import {
|
||||||
getProxiesByType,
|
getProxiesV2,
|
||||||
clearOfflineProxies as apiClearOfflineProxies,
|
clearOfflineProxies as apiClearOfflineProxies,
|
||||||
} from '../api/proxy'
|
} from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import { getClients } from '../api/client'
|
import type { ProxyStatsInfo } from '../types/proxy'
|
||||||
import { Client } from '../utils/client'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -143,242 +122,159 @@ const proxyTypes = [
|
|||||||
|
|
||||||
const activeType = ref((route.params.type as string) || 'tcp')
|
const activeType = ref((route.params.type as string) || 'tcp')
|
||||||
const proxies = ref<BaseProxy[]>([])
|
const proxies = ref<BaseProxy[]>([])
|
||||||
const clients = ref<Client[]>([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const showClearDialog = ref(false)
|
const showClearDialog = ref(false)
|
||||||
const clientIDFilter = ref((route.query.clientID as string) || '')
|
const page = ref(1)
|
||||||
const userFilter = ref((route.query.user as string) || '')
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
let requestSeq = 0
|
||||||
|
let searchDebounceTimer: number | null = null
|
||||||
|
|
||||||
const clientOptions = computed(() => {
|
// Server info cache - cache the Promise itself so concurrent first calls
|
||||||
return clients.value
|
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||||
.map((c) => ({
|
type ServerInfoLite = {
|
||||||
key: c.key,
|
|
||||||
clientID: c.clientID,
|
|
||||||
user: c.user,
|
|
||||||
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Compute selected client key for el-select v-model
|
|
||||||
const selectedClientKey = computed(() => {
|
|
||||||
if (!clientIDFilter.value) return ''
|
|
||||||
const client = clientOptions.value.find(
|
|
||||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
|
||||||
)
|
|
||||||
// Return a synthetic key even if not found, so the select shows the filter is active
|
|
||||||
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedClientLabel = computed(() => {
|
|
||||||
if (!clientIDFilter.value) return 'All Clients'
|
|
||||||
const client = clientOptions.value.find(
|
|
||||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
|
||||||
)
|
|
||||||
return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredClientOptions = (filterText: string) => {
|
|
||||||
if (!filterText) return clientOptions.value
|
|
||||||
const search = filterText.toLowerCase()
|
|
||||||
return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the filtered client exists in the client list
|
|
||||||
const selectedClientInList = computed(() => {
|
|
||||||
if (!clientIDFilter.value) return true
|
|
||||||
return clientOptions.value.some(
|
|
||||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredProxies = computed(() => {
|
|
||||||
let result = proxies.value
|
|
||||||
|
|
||||||
// Filter by clientID and user if specified
|
|
||||||
if (clientIDFilter.value) {
|
|
||||||
result = result.filter(
|
|
||||||
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search text across multiple fields
|
|
||||||
if (searchText.value) {
|
|
||||||
const search = searchText.value.toLowerCase()
|
|
||||||
result = result.filter((p) => {
|
|
||||||
const fields: unknown[] = [
|
|
||||||
p.name,
|
|
||||||
p.type,
|
|
||||||
p.clientID,
|
|
||||||
p.user,
|
|
||||||
p.addr,
|
|
||||||
p.port,
|
|
||||||
p.customDomains,
|
|
||||||
p.subdomain,
|
|
||||||
]
|
|
||||||
return fields.some((v) => matchesSearch(v, search))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// Normalize a field of unknown shape (string / number / array / null) to a
|
|
||||||
// lowercase string for case-insensitive substring matching. Arrays are joined
|
|
||||||
// so e.g. customDomains: ["A.com","B.com"] is searchable as one blob.
|
|
||||||
const matchesSearch = (value: unknown, needle: string): boolean => {
|
|
||||||
if (value === null || value === undefined) return false
|
|
||||||
let str: string
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
str = value
|
|
||||||
.filter((v) => v !== null && v !== undefined)
|
|
||||||
.map((v) => String(v))
|
|
||||||
.join(' ')
|
|
||||||
} else if (typeof value === 'number') {
|
|
||||||
if (value === 0) return false
|
|
||||||
str = String(value)
|
|
||||||
} else {
|
|
||||||
str = String(value)
|
|
||||||
}
|
|
||||||
if (!str) return false
|
|
||||||
return str.toLowerCase().includes(needle)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClientFilterChange = (key: string) => {
|
|
||||||
if (key) {
|
|
||||||
const client = clientOptions.value.find((c) => c.key === key)
|
|
||||||
if (client) {
|
|
||||||
router.replace({
|
|
||||||
query: { ...route.query, clientID: client.clientID, user: client.user },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const query = { ...route.query }
|
|
||||||
delete query.clientID
|
|
||||||
delete query.user
|
|
||||||
router.replace({ query })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchClients = async () => {
|
|
||||||
try {
|
|
||||||
const json = await getClients()
|
|
||||||
clients.value = json.map((data) => new Client(data))
|
|
||||||
} catch {
|
|
||||||
// Ignore errors when fetching clients
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server info cache
|
|
||||||
let serverInfo: {
|
|
||||||
vhostHTTPPort: number
|
vhostHTTPPort: number
|
||||||
vhostHTTPSPort: number
|
vhostHTTPSPort: number
|
||||||
tcpmuxHTTPConnectPort: number
|
tcpmuxHTTPConnectPort: number
|
||||||
subdomainHost: string
|
subdomainHost: string
|
||||||
} | null = null
|
}
|
||||||
|
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
||||||
|
|
||||||
const fetchServerInfo = async () => {
|
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
||||||
if (serverInfo) return serverInfo
|
if (!serverInfoPromise) {
|
||||||
const res = await getServerInfo()
|
serverInfoPromise = getServerInfo().catch((err) => {
|
||||||
serverInfo = res
|
// Allow retry after failure
|
||||||
return serverInfo
|
serverInfoPromise = null
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return serverInfoPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertProxies = async (
|
const convertProxy = async (
|
||||||
type: string,
|
proxy: ProxyStatsInfo,
|
||||||
json: any,
|
): Promise<BaseProxy | null> => {
|
||||||
): Promise<BaseProxy[]> => {
|
const type = proxy.type || activeType.value
|
||||||
if (type === 'tcp') {
|
if (type === 'tcp') {
|
||||||
return json.proxies.map((p: any) => new TCPProxy(p))
|
return new TCPProxy(proxy)
|
||||||
}
|
}
|
||||||
if (type === 'udp') {
|
if (type === 'udp') {
|
||||||
return json.proxies.map((p: any) => new UDPProxy(p))
|
return new UDPProxy(proxy)
|
||||||
}
|
}
|
||||||
if (type === 'http') {
|
if (type === 'http') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPPort) {
|
if (info && info.vhostHTTPPort) {
|
||||||
return json.proxies.map(
|
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
||||||
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'https') {
|
if (type === 'https') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPSPort) {
|
if (info && info.vhostHTTPSPort) {
|
||||||
return json.proxies.map(
|
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
||||||
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'tcpmux') {
|
if (type === 'tcpmux') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.tcpmuxHTTPConnectPort) {
|
if (info && info.tcpmuxHTTPConnectPort) {
|
||||||
return json.proxies.map(
|
return new TCPMuxProxy(
|
||||||
(p: any) =>
|
proxy,
|
||||||
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
|
info.tcpmuxHTTPConnectPort,
|
||||||
|
info.subdomainHost,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'stcp') {
|
if (type === 'stcp') {
|
||||||
return json.proxies.map((p: any) => new STCPProxy(p))
|
return new STCPProxy(proxy)
|
||||||
}
|
}
|
||||||
if (type === 'sudp') {
|
if (type === 'sudp') {
|
||||||
return json.proxies.map((p: any) => new SUDPProxy(p))
|
return new SUDPProxy(proxy)
|
||||||
}
|
}
|
||||||
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
||||||
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
||||||
return json.proxies.map((p: any) => {
|
const bp = new BaseProxy(proxy)
|
||||||
const bp = new BaseProxy(p)
|
bp.type = type
|
||||||
bp.type = type
|
return bp
|
||||||
return bp
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allProxyTypes = [
|
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
||||||
'tcp',
|
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
||||||
'udp',
|
return converted.filter((item): item is BaseProxy => item !== null)
|
||||||
'http',
|
}
|
||||||
'https',
|
|
||||||
'tcpmux',
|
|
||||||
'stcp',
|
|
||||||
'xtcp',
|
|
||||||
'sudp',
|
|
||||||
]
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async (silent = false) => {
|
||||||
loading.value = true
|
const seq = ++requestSeq
|
||||||
proxies.value = []
|
if (!silent) loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const type = activeType.value
|
const q = searchText.value.trim()
|
||||||
|
const data = await getProxiesV2({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
type: activeType.value === 'all' ? undefined : activeType.value,
|
||||||
|
q: q || undefined,
|
||||||
|
})
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
if (type === 'all') {
|
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
||||||
const results = await Promise.all(
|
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
||||||
allProxyTypes.map(async (t) => {
|
page.value = maxPage
|
||||||
const json = await getProxiesByType(t)
|
await fetchData(silent)
|
||||||
return convertProxies(t, json)
|
return
|
||||||
}),
|
|
||||||
)
|
|
||||||
proxies.value = results.flat()
|
|
||||||
} else {
|
|
||||||
const json = await getProxiesByType(type)
|
|
||||||
proxies.value = await convertProxies(type, json)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const converted = await convertProxies(data.items)
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
|
proxies.value = converted
|
||||||
|
total.value = data.total
|
||||||
|
page.value = data.page
|
||||||
|
pageSize.value = data.pageSize
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
message: 'Failed to fetch proxies: ' + error.message,
|
message: 'Failed to fetch proxies: ' + error.message,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === requestSeq) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSearchDebounce = () => {
|
||||||
|
if (searchDebounceTimer !== null) {
|
||||||
|
window.clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPageAndFetch = () => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshData = () => {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (value: number) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = value
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageSizeChange = (value: number) => {
|
||||||
|
pageSize.value = value
|
||||||
|
resetPageAndFetch()
|
||||||
|
}
|
||||||
|
|
||||||
const handleClearConfirm = async () => {
|
const handleClearConfirm = async () => {
|
||||||
showClearDialog.value = false
|
showClearDialog.value = false
|
||||||
await clearOfflineProxies()
|
await clearOfflineProxies()
|
||||||
@@ -400,25 +296,45 @@ const clearOfflineProxies = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeClientQuery = () => {
|
||||||
|
const hasClientQuery =
|
||||||
|
Object.prototype.hasOwnProperty.call(route.query, 'clientID') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(route.query, 'user')
|
||||||
|
if (!hasClientQuery) return
|
||||||
|
|
||||||
|
const query = { ...route.query }
|
||||||
|
delete query.clientID
|
||||||
|
delete query.user
|
||||||
|
router.replace({ query })
|
||||||
|
}
|
||||||
|
|
||||||
// Watch for type changes
|
// Watch for type changes
|
||||||
watch(activeType, (newType) => {
|
watch(activeType, (newType) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
page.value = 1
|
||||||
// Update route but preserve query params
|
// Update route but preserve query params
|
||||||
router.replace({ params: { type: newType }, query: route.query })
|
router.replace({ params: { type: newType }, query: route.query })
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for route query changes (client filter)
|
watch(searchText, () => {
|
||||||
watch(
|
clearSearchDebounce()
|
||||||
() => [route.query.clientID, route.query.user],
|
page.value = 1
|
||||||
([newClientID, newUser]) => {
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
clientIDFilter.value = (newClientID as string) || ''
|
searchDebounceTimer = null
|
||||||
userFilter.value = (newUser as string) || ''
|
fetchData()
|
||||||
},
|
}, 300)
|
||||||
)
|
})
|
||||||
|
|
||||||
|
watch(() => route.query, sanitizeClientQuery)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
})
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
|
sanitizeClientQuery()
|
||||||
fetchData()
|
fetchData()
|
||||||
fetchClients()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -485,16 +401,11 @@ fetchClients()
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-search :deep(.el-input__wrapper),
|
.main-search :deep(.el-input__wrapper) {
|
||||||
.client-filter :deep(.el-input__wrapper) {
|
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-filter {
|
|
||||||
width: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tabs {
|
.type-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -539,13 +450,18 @@ fetchClients()
|
|||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.search-row {
|
.search-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-filter {
|
.pagination-section {
|
||||||
width: 100%;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
70
web/package-lock.json
generated
70
web/package-lock.json
generated
@@ -954,76 +954,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nuxt/kit": {
|
|
||||||
"version": "3.21.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
|
|
||||||
"integrity": "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"c12": "^3.3.3",
|
|
||||||
"consola": "^3.4.2",
|
|
||||||
"defu": "^6.1.4",
|
|
||||||
"destr": "^2.0.5",
|
|
||||||
"errx": "^0.1.0",
|
|
||||||
"exsolve": "^1.0.8",
|
|
||||||
"ignore": "^7.0.5",
|
|
||||||
"jiti": "^2.6.1",
|
|
||||||
"klona": "^2.0.6",
|
|
||||||
"knitwork": "^1.3.0",
|
|
||||||
"mlly": "^1.8.1",
|
|
||||||
"ohash": "^2.0.11",
|
|
||||||
"pathe": "^2.0.3",
|
|
||||||
"pkg-types": "^2.3.0",
|
|
||||||
"rc9": "^3.0.0",
|
|
||||||
"scule": "^1.3.0",
|
|
||||||
"semver": "^7.7.4",
|
|
||||||
"tinyglobby": "^0.2.15",
|
|
||||||
"ufo": "^1.6.3",
|
|
||||||
"unctx": "^2.5.0",
|
|
||||||
"untyped": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/kit/node_modules/confbox": {
|
|
||||||
"version": "0.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
|
||||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/kit/node_modules/ignore": {
|
|
||||||
"version": "7.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
|
||||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/kit/node_modules/pkg-types": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"confbox": "^0.2.2",
|
|
||||||
"exsolve": "^1.0.7",
|
|
||||||
"pathe": "^2.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user