mirror of
https://github.com/fatedier/frp.git
synced 2026-07-18 02:49:18 +08:00
Compare commits
13 Commits
7c343fc6e7
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe79598ee4 | ||
|
|
269a26c5d5 | ||
|
|
2886393f5b | ||
|
|
5396947c7c | ||
|
|
fe61093bc2 | ||
|
|
54aeb2a7b0 | ||
|
|
84be1938e4 | ||
|
|
becee40715 | ||
|
|
17e788d43b | ||
|
|
68509f5d44 | ||
|
|
5cd722b177 | ||
|
|
5876beceac | ||
|
|
7fe152e3aa |
4
.github/workflows/build-and-push-image.yml
vendored
4
.github/workflows/build-and-push-image.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./dockerfiles/Dockerfile-for-frpc
|
file: ./dockerfiles/Dockerfile-for-frpc
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.TAG_FRPC }}
|
${{ env.TAG_FRPC }}
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./dockerfiles/Dockerfile-for-frps
|
file: ./dockerfiles/Dockerfile-for-frps
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.TAG_FRPS }}
|
${{ env.TAG_FRPS }}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ all: build
|
|||||||
build: app
|
build: app
|
||||||
|
|
||||||
app:
|
app:
|
||||||
@$(foreach n, $(os-archs), \
|
@set -e; $(foreach n, $(os-archs), \
|
||||||
os=$(shell echo "$(n)" | cut -d : -f 1); \
|
os=$(shell echo "$(n)" | cut -d : -f 1); \
|
||||||
arch=$(shell echo "$(n)" | cut -d : -f 2); \
|
arch=$(shell echo "$(n)" | cut -d : -f 2); \
|
||||||
extra=$(shell echo "$(n)" | cut -d : -f 3); \
|
extra=$(shell echo "$(n)" | cut -d : -f 3); \
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
[](https://circleci.com/gh/fatedier/frp)
|
[](https://circleci.com/gh/fatedier/frp)
|
||||||
[](https://github.com/fatedier/frp/releases)
|
[](https://github.com/fatedier/frp/releases)
|
||||||
[](https://goreportcard.com/report/github.com/fatedier/frp)
|
|
||||||
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||||
|
|
||||||
[README](README.md) | [中文文档](README_zh.md)
|
[README](README.md) | [中文文档](README_zh.md)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Added dashboard API v2 pagination endpoints for users, clients, and proxies.
|
* Expanded the frps dashboard API v2 migration across Clients, Proxies, Server Overview, Client Detail, and Proxy Detail, covering paginated users/clients/proxies, detail data, proxy traffic history, server system info, offline proxy statistics pruning, server-side pagination, search, and proxy type filtering.
|
||||||
* The frps dashboard Clients and Proxies pages now use API v2 pagination and server-side search, including proxy type filtering and searchable proxy spec fields such as remote ports, custom domains, and subdomains.
|
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
|
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/coreos/go-oidc/v3 v3.14.1
|
||||||
github.com/fatedier/golib v0.7.0
|
github.com/fatedier/golib v0.7.2-0.20260715094513-2780ae1d8cdb
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
@@ -13,7 +13,6 @@ require (
|
|||||||
github.com/onsi/ginkgo/v2 v2.23.4
|
github.com/onsi/ginkgo/v2 v2.23.4
|
||||||
github.com/onsi/gomega v1.36.3
|
github.com/onsi/gomega v1.36.3
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0
|
github.com/pelletier/go-toml/v2 v2.2.0
|
||||||
github.com/pion/stun/v3 v3.1.1
|
|
||||||
github.com/pires/go-proxyproto v0.7.0
|
github.com/pires/go-proxyproto v0.7.0
|
||||||
github.com/prometheus/client_golang v1.19.1
|
github.com/prometheus/client_golang v1.19.1
|
||||||
github.com/quic-go/quic-go v0.55.0
|
github.com/quic-go/quic-go v0.55.0
|
||||||
@@ -53,9 +52,6 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
|
||||||
github.com/pion/logging v0.2.4 // indirect
|
|
||||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
github.com/prometheus/client_model v0.5.0 // indirect
|
||||||
@@ -67,7 +63,6 @@ require (
|
|||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
|||||||
14
go.sum
14
go.sum
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
github.com/fatedier/golib v0.7.2-0.20260715094513-2780ae1d8cdb h1:6gcaE769mFb1G/cNV6lgzmwIYkNXmhj4pmcddllfmB4=
|
||||||
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
github.com/fatedier/golib v0.7.2-0.20260715094513-2780ae1d8cdb/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
@@ -78,14 +78,6 @@ github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
|||||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
|
|
||||||
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
|
||||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
|
||||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
|
||||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
|
||||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
|
||||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
|
||||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
|
||||||
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
||||||
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -146,8 +138,6 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
|||||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
||||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||||
|
|||||||
@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
total = len(m.info.ProxyStatistics)
|
total = len(m.info.ProxyStatistics)
|
||||||
for name, data := range m.info.ProxyStatistics {
|
for name, data := range m.info.ProxyStatistics {
|
||||||
if !data.LastCloseTime.IsZero() &&
|
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
||||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
|
||||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
|
||||||
delete(m.info.ProxyStatistics, name)
|
delete(m.info.ProxyStatistics, name)
|
||||||
count++
|
count++
|
||||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||||
@@ -106,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
|||||||
return count, total
|
return count, total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
|
||||||
|
return !data.LastCloseTime.IsZero() &&
|
||||||
|
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||||
|
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
||||||
return m.clearUselessInfo(0)
|
return m.clearUselessInfo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
|
||||||
|
return m.clearUselessInfo(0)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) NewClient() {
|
func (m *serverMetrics) NewClient() {
|
||||||
m.info.ClientCounts.Inc(1)
|
m.info.ClientCounts.Inc(1)
|
||||||
}
|
}
|
||||||
@@ -231,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
|
|||||||
}
|
}
|
||||||
if !proxyStats.LastStartTime.IsZero() {
|
if !proxyStats.LastStartTime.IsZero() {
|
||||||
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||||
|
ps.LastStartAt = proxyStats.LastStartTime.Unix()
|
||||||
}
|
}
|
||||||
if !proxyStats.LastCloseTime.IsZero() {
|
if !proxyStats.LastCloseTime.IsZero() {
|
||||||
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||||
|
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
|
||||||
}
|
}
|
||||||
return ps
|
return ps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
|||||||
clk.SetTime(closedAt)
|
clk.SetTime(closedAt)
|
||||||
metrics.CloseProxy("proxy", "tcp")
|
metrics.CloseProxy("proxy", "tcp")
|
||||||
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
||||||
|
|
||||||
|
stats := metrics.GetProxyByName("proxy")
|
||||||
|
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
|
||||||
|
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
|
||||||
|
require.Equal(start.Unix(), stats.LastStartAt)
|
||||||
|
require.Equal(closedAt.Unix(), stats.LastCloseAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||||
@@ -43,6 +49,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
|||||||
require.Empty(metrics.info.ProxyStatistics)
|
require.Empty(metrics.info.ProxyStatistics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||||
|
Name: "offline",
|
||||||
|
LastStartTime: start.Add(-time.Hour),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||||
|
Name: "online",
|
||||||
|
LastStartTime: start,
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared, total := metrics.ClearOfflineProxies()
|
||||||
|
|
||||||
|
require.Equal(1, cleared)
|
||||||
|
require.Equal(2, total)
|
||||||
|
require.False(metrics.hasProxyStatistics("offline"))
|
||||||
|
require.True(metrics.hasProxyStatistics("online"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||||
|
Name: "offline",
|
||||||
|
LastStartTime: start.Add(-time.Hour),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||||
|
Name: "online",
|
||||||
|
LastStartTime: start,
|
||||||
|
}
|
||||||
|
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
|
||||||
|
Name: "restarted",
|
||||||
|
LastStartTime: start.Add(30 * time.Second),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
|
||||||
|
Name: "same-time",
|
||||||
|
LastStartTime: start,
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared, total := metrics.PruneOfflineProxies()
|
||||||
|
|
||||||
|
require.Equal(1, cleared)
|
||||||
|
require.Equal(4, total)
|
||||||
|
require.False(metrics.hasProxyStatistics("offline"))
|
||||||
|
require.True(metrics.hasProxyStatistics("online"))
|
||||||
|
require.True(metrics.hasProxyStatistics("restarted"))
|
||||||
|
require.True(metrics.hasProxyStatistics("same-time"))
|
||||||
|
|
||||||
|
cleared, total = metrics.PruneOfflineProxies()
|
||||||
|
require.Equal(0, cleared)
|
||||||
|
require.Equal(3, total)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ type ProxyStats struct {
|
|||||||
TodayTrafficOut int64
|
TodayTrafficOut int64
|
||||||
LastStartTime string
|
LastStartTime string
|
||||||
LastCloseTime string
|
LastCloseTime string
|
||||||
|
LastStartAt int64
|
||||||
|
LastCloseAt int64
|
||||||
CurConns int64
|
CurConns int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +87,5 @@ type Collector interface {
|
|||||||
GetProxyByName(proxyName string) *ProxyStats
|
GetProxyByName(proxyName string) *ProxyStats
|
||||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||||
ClearOfflineProxies() (int, int)
|
ClearOfflineProxies() (int, int)
|
||||||
|
PruneOfflineProxies() (int, int)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,31 +15,24 @@
|
|||||||
package nathole
|
package nathole
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pion/stun/v3"
|
"github.com/fatedier/golib/net/stun"
|
||||||
)
|
)
|
||||||
|
|
||||||
var responseTimeout = 3 * time.Second
|
var responseTimeout = 3 * time.Second
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
Body []byte
|
|
||||||
Addr string
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the localAddr is empty, it will listen on a random port.
|
// If the localAddr is empty, it will listen on a random port.
|
||||||
func Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) {
|
func Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) {
|
||||||
// create a discoverConn and get response from messageChan
|
|
||||||
discoverConn, err := listen(localAddr)
|
discoverConn, err := listen(localAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer discoverConn.Close()
|
defer discoverConn.Close()
|
||||||
|
|
||||||
go discoverConn.readLoop()
|
|
||||||
|
|
||||||
addresses := make([]string, 0, len(stunServers))
|
addresses := make([]string, 0, len(stunServers))
|
||||||
for _, addr := range stunServers {
|
for _, addr := range stunServers {
|
||||||
// get external address from stun server
|
// get external address from stun server
|
||||||
@@ -58,10 +51,9 @@ type stunResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type discoverConn struct {
|
type discoverConn struct {
|
||||||
conn *net.UDPConn
|
conn *net.UDPConn
|
||||||
|
client *stun.Client
|
||||||
localAddr net.Addr
|
localAddr net.Addr
|
||||||
messageChan chan *Message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func listen(localAddr string) (*discoverConn, error) {
|
func listen(localAddr string) (*discoverConn, error) {
|
||||||
@@ -77,82 +69,50 @@ func listen(localAddr string) (*discoverConn, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
client, err := stun.NewClient(conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &discoverConn{
|
return &discoverConn{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
localAddr: conn.LocalAddr(),
|
client: client,
|
||||||
messageChan: make(chan *Message, 10),
|
localAddr: conn.LocalAddr(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *discoverConn) Close() error {
|
func (c *discoverConn) Close() error {
|
||||||
if c.messageChan != nil {
|
|
||||||
close(c.messageChan)
|
|
||||||
c.messageChan = nil
|
|
||||||
}
|
|
||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *discoverConn) readLoop() {
|
|
||||||
for {
|
|
||||||
buf := make([]byte, 1024)
|
|
||||||
n, addr, err := c.conn.ReadFromUDP(buf)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
buf = buf[:n]
|
|
||||||
|
|
||||||
c.messageChan <- &Message{
|
|
||||||
Body: buf,
|
|
||||||
Addr: addr.String(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoverConn) doSTUNRequest(addr string) (*stunResponse, error) {
|
func (c *discoverConn) doSTUNRequest(addr string) (*stunResponse, error) {
|
||||||
serverAddr, err := net.ResolveUDPAddr("udp4", addr)
|
serverAddr, err := net.ResolveUDPAddr("udp4", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
request, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
transaction, err := stun.NewBindingTransaction(serverAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := c.conn.SetReadDeadline(time.Now().Add(responseTimeout)); err != nil {
|
||||||
if err = request.NewTransactionID(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := c.conn.WriteTo(request.Raw, serverAddr); err != nil {
|
response, err := c.client.Do(transaction)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||||
var m stun.Message
|
return nil, fmt.Errorf("wait response from stun server timeout")
|
||||||
select {
|
|
||||||
case msg := <-c.messageChan:
|
|
||||||
m.Raw = msg.Body
|
|
||||||
if err := m.Decode(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
case <-time.After(responseTimeout):
|
return nil, err
|
||||||
return nil, fmt.Errorf("wait response from stun server timeout")
|
|
||||||
}
|
}
|
||||||
xorAddrGetter := &stun.XORMappedAddress{}
|
|
||||||
mappedAddrGetter := &stun.MappedAddress{}
|
|
||||||
changedAddrGetter := ChangedAddress{}
|
|
||||||
otherAddrGetter := &stun.OtherAddress{}
|
|
||||||
|
|
||||||
resp := &stunResponse{}
|
resp := &stunResponse{}
|
||||||
if err := mappedAddrGetter.GetFrom(&m); err == nil {
|
if response.MappedAddr != nil {
|
||||||
resp.externalAddr = mappedAddrGetter.String()
|
resp.externalAddr = response.MappedAddr.String()
|
||||||
}
|
}
|
||||||
if err := xorAddrGetter.GetFrom(&m); err == nil {
|
if response.OtherAddr != nil {
|
||||||
resp.externalAddr = xorAddrGetter.String()
|
resp.otherAddr = response.OtherAddr.String()
|
||||||
}
|
|
||||||
if err := changedAddrGetter.GetFrom(&m); err == nil {
|
|
||||||
resp.otherAddr = changedAddrGetter.String()
|
|
||||||
}
|
|
||||||
if err := otherAddrGetter.GetFrom(&m); err == nil {
|
|
||||||
resp.otherAddr = otherAddrGetter.String()
|
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
382
pkg/nathole/discovery_test.go
Normal file
382
pkg/nathole/discovery_test.go
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
package nathole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatedier/golib/net/stun"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testBindingRequest = 0x0001
|
||||||
|
testBindingSuccess = 0x0101
|
||||||
|
testBindingError = 0x0111
|
||||||
|
testMagicCookie = 0x2112a442
|
||||||
|
testAttrMapped = 0x0001
|
||||||
|
testAttrChanged = 0x0005
|
||||||
|
testAttrErrorCode = 0x0009
|
||||||
|
testAttrXORMapped = 0x0020
|
||||||
|
testAttrOther = 0x802c
|
||||||
|
testSTUNHeaderSize = 20
|
||||||
|
testSTUNServerLimit = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type testSTUNAttribute struct {
|
||||||
|
typ uint16
|
||||||
|
value []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type testSTUNExchange struct {
|
||||||
|
source *net.UDPAddr
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenTestUDP4(t *testing.T) *net.UDPConn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveOneSTUNRequest(
|
||||||
|
server *net.UDPConn,
|
||||||
|
buildResponse func([]byte, *net.UDPAddr) ([]byte, error),
|
||||||
|
) <-chan testSTUNExchange {
|
||||||
|
done := make(chan testSTUNExchange, 1)
|
||||||
|
go func() {
|
||||||
|
if err := server.SetDeadline(time.Now().Add(testSTUNServerLimit)); err != nil {
|
||||||
|
done <- testSTUNExchange{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buffer := make([]byte, 1024)
|
||||||
|
n, source, err := server.ReadFromUDP(buffer)
|
||||||
|
if err == nil && buildResponse != nil {
|
||||||
|
var response []byte
|
||||||
|
response, err = buildResponse(buffer[:n], source)
|
||||||
|
if err == nil && response != nil {
|
||||||
|
_, err = server.WriteToUDP(response, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done <- testSTUNExchange{source: source, err: err}
|
||||||
|
}()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitSTUNExchange(t *testing.T, done <-chan testSTUNExchange) *net.UDPAddr {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case exchange := <-done:
|
||||||
|
require.NoError(t, exchange.err)
|
||||||
|
return exchange.source
|
||||||
|
case <-time.After(testSTUNServerLimit):
|
||||||
|
t.Fatal("timed out waiting for local STUN server")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTestSTUNResponse(request []byte, typ uint16, attributes ...testSTUNAttribute) ([]byte, error) {
|
||||||
|
if len(request) != testSTUNHeaderSize || binary.BigEndian.Uint16(request[0:2]) != testBindingRequest ||
|
||||||
|
binary.BigEndian.Uint32(request[4:8]) != testMagicCookie {
|
||||||
|
return nil, fmt.Errorf("invalid Binding request")
|
||||||
|
}
|
||||||
|
|
||||||
|
length := 0
|
||||||
|
for _, attribute := range attributes {
|
||||||
|
length += 4 + (len(attribute.value)+3)&^3
|
||||||
|
}
|
||||||
|
response := make([]byte, testSTUNHeaderSize, testSTUNHeaderSize+length)
|
||||||
|
binary.BigEndian.PutUint16(response[0:2], typ)
|
||||||
|
binary.BigEndian.PutUint16(response[2:4], uint16(length))
|
||||||
|
binary.BigEndian.PutUint32(response[4:8], testMagicCookie)
|
||||||
|
copy(response[8:20], request[8:20])
|
||||||
|
|
||||||
|
for _, attribute := range attributes {
|
||||||
|
start := len(response)
|
||||||
|
paddedLength := (len(attribute.value) + 3) &^ 3
|
||||||
|
response = append(response, make([]byte, 4+paddedLength)...)
|
||||||
|
binary.BigEndian.PutUint16(response[start:start+2], attribute.typ)
|
||||||
|
binary.BigEndian.PutUint16(response[start+2:start+4], uint16(len(attribute.value)))
|
||||||
|
copy(response[start+4:], attribute.value)
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIPv4AddressValue(ip net.IP, port int, xor bool) []byte {
|
||||||
|
value := make([]byte, 8)
|
||||||
|
value[1] = 0x01
|
||||||
|
binary.BigEndian.PutUint16(value[2:4], uint16(port))
|
||||||
|
copy(value[4:], ip.To4())
|
||||||
|
if xor {
|
||||||
|
binary.BigEndian.PutUint16(value[2:4], binary.BigEndian.Uint16(value[2:4])^uint16(testMagicCookie>>16))
|
||||||
|
for i := range 4 {
|
||||||
|
value[4+i] ^= byte(uint32(testMagicCookie) >> uint(24-8*i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverReusesLocalPortAndPreservesNATClassification(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
secondMapped string
|
||||||
|
secondMappedPort int
|
||||||
|
wantNATType string
|
||||||
|
wantBehavior string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "same mapped address",
|
||||||
|
secondMapped: "198.51.100.10:40000",
|
||||||
|
secondMappedPort: 40000,
|
||||||
|
wantNATType: EasyNAT,
|
||||||
|
wantBehavior: BehaviorNoChange,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different mapped port",
|
||||||
|
secondMapped: "198.51.100.10:40001",
|
||||||
|
secondMappedPort: 40001,
|
||||||
|
wantNATType: HardNAT,
|
||||||
|
wantBehavior: BehaviorPortChanged,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
primary := listenTestUDP4(t)
|
||||||
|
alternate := listenTestUDP4(t)
|
||||||
|
alternateAddr := alternate.LocalAddr().(*net.UDPAddr)
|
||||||
|
|
||||||
|
primaryDone := serveOneSTUNRequest(primary, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), 40000, true)},
|
||||||
|
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(alternateAddr.IP, alternateAddr.Port, false)},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
alternateDone := serveOneSTUNRequest(alternate, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), tt.secondMappedPort, true)},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
addresses, localAddr, err := Discover([]string{primary.LocalAddr().String()}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"198.51.100.10:40000", tt.secondMapped}, addresses)
|
||||||
|
|
||||||
|
primarySource := waitSTUNExchange(t, primaryDone)
|
||||||
|
alternateSource := waitSTUNExchange(t, alternateDone)
|
||||||
|
require.Equal(t, primarySource.Port, alternateSource.Port)
|
||||||
|
require.Equal(t, localAddr.(*net.UDPAddr).Port, primarySource.Port)
|
||||||
|
|
||||||
|
feature, err := ClassifyNATFeature(addresses, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tt.wantNATType, feature.NatType)
|
||||||
|
require.Equal(t, tt.wantBehavior, feature.Behavior)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoSTUNRequestMapsLegacyAndModernAddresses(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
attributes []testSTUNAttribute
|
||||||
|
wantExternal string
|
||||||
|
wantOther string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "legacy",
|
||||||
|
attributes: []testSTUNAttribute{
|
||||||
|
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||||
|
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||||
|
},
|
||||||
|
wantExternal: "192.0.2.1:1000",
|
||||||
|
wantOther: "192.0.2.2:2000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modern takes precedence",
|
||||||
|
attributes: []testSTUNAttribute{
|
||||||
|
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||||
|
{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||||
|
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||||
|
{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("198.51.100.2"), 4000, false)},
|
||||||
|
},
|
||||||
|
wantExternal: "198.51.100.1:3000",
|
||||||
|
wantOther: "198.51.100.2:4000",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess, tt.attributes...)
|
||||||
|
})
|
||||||
|
conn, err := listen("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tt.wantExternal, response.externalAddr)
|
||||||
|
require.Equal(t, tt.wantOther, response.otherAddr)
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSTUNResponseErrorsAndMissingAddresses(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
buildResponse func([]byte, *net.UDPAddr) ([]byte, error)
|
||||||
|
request func(*discoverConn, string) error
|
||||||
|
checkError func(*testing.T, error)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "correlated malformed response",
|
||||||
|
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
response, err := makeTestSTUNResponse(request, testBindingSuccess)
|
||||||
|
if err == nil {
|
||||||
|
binary.BigEndian.PutUint16(response[2:4], 4)
|
||||||
|
}
|
||||||
|
return response, err
|
||||||
|
},
|
||||||
|
request: func(conn *discoverConn, server string) error {
|
||||||
|
_, err := conn.doSTUNRequest(server)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
checkError: func(t *testing.T, err error) {
|
||||||
|
require.ErrorIs(t, err, stun.ErrMalformedResponse)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Binding error response",
|
||||||
|
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingError, testSTUNAttribute{
|
||||||
|
typ: testAttrErrorCode,
|
||||||
|
value: []byte{0, 0, 4, 20, 'U', 'n', 'k', 'n', 'o', 'w', 'n'},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
request: func(conn *discoverConn, server string) error {
|
||||||
|
_, err := conn.doSTUNRequest(server)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
checkError: func(t *testing.T, err error) {
|
||||||
|
var responseErr *stun.ResponseError
|
||||||
|
require.ErrorAs(t, err, &responseErr)
|
||||||
|
require.Equal(t, 420, responseErr.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing mapped address",
|
||||||
|
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
request: func(conn *discoverConn, server string) error {
|
||||||
|
_, err := conn.discoverFromStunServer(server)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
checkError: func(t *testing.T, err error) {
|
||||||
|
require.EqualError(t, err, "no external address found")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, tt.buildResponse)
|
||||||
|
conn, err := listen("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
err = tt.request(conn, server.LocalAddr().String())
|
||||||
|
tt.checkError(t, err)
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("missing other address", func(t *testing.T) {
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||||
|
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := Prepare([]string{server.LocalAddr().String()}, PrepareOptions{})
|
||||||
|
require.EqualError(t, err, "discover error: not enough addresses")
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSTUNTimeoutUsesCallerDeadlineWithoutRetry(t *testing.T) {
|
||||||
|
originalTimeout := responseTimeout
|
||||||
|
responseTimeout = 50 * time.Millisecond
|
||||||
|
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||||
|
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, nil)
|
||||||
|
conn, err := listen("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
_, err = conn.doSTUNRequest(server.LocalAddr().String())
|
||||||
|
require.EqualError(t, err, "wait response from stun server timeout")
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
|
||||||
|
require.NoError(t, server.SetReadDeadline(time.Now().Add(50*time.Millisecond)))
|
||||||
|
_, _, err = server.ReadFromUDP(make([]byte, 1))
|
||||||
|
var netErr net.Error
|
||||||
|
require.ErrorAs(t, err, &netErr)
|
||||||
|
require.True(t, netErr.Timeout())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSTUNClientLeavesSocketAndDeadlineWithCaller(t *testing.T) {
|
||||||
|
originalTimeout := responseTimeout
|
||||||
|
responseTimeout = 100 * time.Millisecond
|
||||||
|
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||||
|
|
||||||
|
server := listenTestUDP4(t)
|
||||||
|
unrelated := listenTestUDP4(t)
|
||||||
|
done := serveOneSTUNRequest(server, func(request []byte, source *net.UDPAddr) ([]byte, error) {
|
||||||
|
response, err := makeTestSTUNResponse(request, testBindingSuccess,
|
||||||
|
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.5"), 5000, true)},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := unrelated.WriteToUDP(response, source); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
})
|
||||||
|
conn, err := listen("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "198.51.100.5:5000", response.externalAddr)
|
||||||
|
waitSTUNExchange(t, done)
|
||||||
|
|
||||||
|
_, _, err = conn.conn.ReadFromUDP(make([]byte, 1))
|
||||||
|
var netErr net.Error
|
||||||
|
require.True(t, errors.As(err, &netErr))
|
||||||
|
require.True(t, netErr.Timeout())
|
||||||
|
|
||||||
|
require.NoError(t, conn.conn.SetDeadline(time.Time{}))
|
||||||
|
require.NoError(t, server.SetReadDeadline(time.Now().Add(testSTUNServerLimit)))
|
||||||
|
_, err = conn.conn.WriteToUDP([]byte{1}, server.LocalAddr().(*net.UDPAddr))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, source, err := server.ReadFromUDP(make([]byte, 1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, conn.localAddr.(*net.UDPAddr).Port, source.Port)
|
||||||
|
}
|
||||||
@@ -18,10 +18,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/fatedier/golib/crypto"
|
"github.com/fatedier/golib/crypto"
|
||||||
"github.com/pion/stun/v3"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
)
|
)
|
||||||
@@ -48,20 +46,6 @@ func DecodeMessageInto(data, key []byte, m msg.Message) error {
|
|||||||
return msg.ReadMsgInto(bytes.NewReader(buf), m)
|
return msg.ReadMsgInto(bytes.NewReader(buf), m)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangedAddress struct {
|
|
||||||
IP net.IP
|
|
||||||
Port int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChangedAddress) GetFrom(m *stun.Message) error {
|
|
||||||
a := (*stun.MappedAddress)(s)
|
|
||||||
return a.GetFromAs(m, stun.AttrChangedAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ChangedAddress) String() string {
|
|
||||||
return net.JoinHostPort(s.IP.String(), strconv.Itoa(s.Port))
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListAllLocalIPs() ([]net.IP, error) {
|
func ListAllLocalIPs() ([]net.IP, error) {
|
||||||
addrs, err := net.InterfaceAddrs()
|
addrs, err := net.InterfaceAddrs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "0.69.1"
|
var version = "0.70.0"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -49,12 +49,15 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
||||||
|
|
||||||
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
||||||
|
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
|
||||||
|
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
|
||||||
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
||||||
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
|
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
|
||||||
v2EncodedPathRouter.UseEncodedPath()
|
v2EncodedPathRouter.UseEncodedPath()
|
||||||
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
|
||||||
|
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||||
|
|
||||||
// view
|
// view
|
||||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ func NewController(
|
|||||||
|
|
||||||
// /api/serverinfo
|
// /api/serverinfo
|
||||||
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||||
|
return c.buildServerInfoResp(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||||
serverStats := mem.StatsCollector.GetServer()
|
serverStats := mem.StatsCollector.GetServer()
|
||||||
svrResp := model.ServerInfoResp{
|
return model.ServerInfoResp{
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
BindPort: c.serverCfg.BindPort,
|
BindPort: c.serverCfg.BindPort,
|
||||||
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||||
@@ -80,8 +84,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
|||||||
ClientCounts: serverStats.ClientCounts,
|
ClientCounts: serverStats.ClientCounts,
|
||||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||||
}
|
}
|
||||||
|
|
||||||
return svrResp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// /api/clients
|
// /api/clients
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ package http
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
@@ -35,6 +37,11 @@ const (
|
|||||||
defaultV2Page = 1
|
defaultV2Page = 1
|
||||||
defaultV2PageSize = 50
|
defaultV2PageSize = 50
|
||||||
maxV2PageSize = 200
|
maxV2PageSize = 200
|
||||||
|
|
||||||
|
v2SystemPruneTypeOfflineProxies = "offline_proxies"
|
||||||
|
v2ProxyTrafficDefaultDays = 7
|
||||||
|
v2ProxyTrafficUnit = "bytes"
|
||||||
|
v2ProxyTrafficGranularity = "day"
|
||||||
)
|
)
|
||||||
|
|
||||||
var apiV2ProxyTypes = []string{
|
var apiV2ProxyTypes = []string{
|
||||||
@@ -48,6 +55,55 @@ var apiV2ProxyTypes = []string{
|
|||||||
string(v1.ProxyTypeSUDP),
|
string(v1.ProxyTypeSUDP),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /api/v2/system/info
|
||||||
|
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
|
||||||
|
info := c.buildServerInfoResp()
|
||||||
|
proxyTypeCounts := info.ProxyTypeCounts
|
||||||
|
if proxyTypeCounts == nil {
|
||||||
|
proxyTypeCounts = map[string]int64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.V2SystemInfoResp{
|
||||||
|
Version: info.Version,
|
||||||
|
Config: model.V2SystemInfoConfigResp{
|
||||||
|
BindPort: info.BindPort,
|
||||||
|
VhostHTTPPort: info.VhostHTTPPort,
|
||||||
|
VhostHTTPSPort: info.VhostHTTPSPort,
|
||||||
|
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
|
||||||
|
KCPBindPort: info.KCPBindPort,
|
||||||
|
QUICBindPort: info.QUICBindPort,
|
||||||
|
SubdomainHost: info.SubdomainHost,
|
||||||
|
MaxPoolCount: info.MaxPoolCount,
|
||||||
|
MaxPortsPerClient: info.MaxPortsPerClient,
|
||||||
|
HeartbeatTimeout: info.HeartBeatTimeout,
|
||||||
|
AllowPortsStr: info.AllowPortsStr,
|
||||||
|
TLSForce: info.TLSForce,
|
||||||
|
},
|
||||||
|
Status: model.V2SystemInfoStatusResp{
|
||||||
|
TotalTrafficIn: info.TotalTrafficIn,
|
||||||
|
TotalTrafficOut: info.TotalTrafficOut,
|
||||||
|
CurConns: info.CurConns,
|
||||||
|
ClientCounts: info.ClientCounts,
|
||||||
|
ProxyTypeCounts: proxyTypeCounts,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v2/system/prune
|
||||||
|
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
|
||||||
|
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared, total := mem.StatsCollector.PruneOfflineProxies()
|
||||||
|
return model.V2SystemPruneResp{
|
||||||
|
Type: pruneType,
|
||||||
|
Cleared: cleared,
|
||||||
|
Total: total,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// /api/v2/users
|
// /api/v2/users
|
||||||
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
||||||
page, pageSize, err := parseV2PageParams(ctx)
|
page, pageSize, err := parseV2PageParams(ctx)
|
||||||
@@ -139,15 +195,10 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
|||||||
|
|
||||||
// /api/v2/clients/{key}
|
// /api/v2/clients/{key}
|
||||||
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
||||||
key := ctx.Param("key")
|
key, err := decodeV2PathParam(ctx, "key", "client key")
|
||||||
if key == "" {
|
|
||||||
return nil, fmt.Errorf("missing client key")
|
|
||||||
}
|
|
||||||
decodedKey, err := url.PathUnescape(key)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid client key %q: %w", key, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
key = decodedKey
|
|
||||||
|
|
||||||
if c.clientRegistry == nil {
|
if c.clientRegistry == nil {
|
||||||
return nil, fmt.Errorf("client registry unavailable")
|
return nil, fmt.Errorf("client registry unavailable")
|
||||||
@@ -205,7 +256,7 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
||||||
if v := cmp.Compare(a.Type, b.Type); v != 0 {
|
if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return cmp.Compare(a.Name, b.Name)
|
return cmp.Compare(a.Name, b.Name)
|
||||||
@@ -216,9 +267,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
|||||||
|
|
||||||
// /api/v2/proxies/{name}
|
// /api/v2/proxies/{name}
|
||||||
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||||
name := ctx.Param("name")
|
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||||
if name == "" {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("missing proxy name")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := mem.StatsCollector.GetProxyByName(name)
|
ps := mem.StatsCollector.GetProxyByName(name)
|
||||||
@@ -228,6 +279,33 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
|||||||
return c.buildV2ProxyResp(ps), nil
|
return c.buildV2ProxyResp(ps), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /api/v2/proxies/{name}/traffic
|
||||||
|
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
|
||||||
|
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
|
||||||
|
if proxyTrafficInfo == nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
|
||||||
|
raw := ctx.Param(key)
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("missing %s", label)
|
||||||
|
}
|
||||||
|
decoded, err := url.PathUnescape(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
||||||
item, ok := items[user]
|
item, ok := items[user]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -287,6 +365,18 @@ func parseV2ProxyTypeFilter(raw string) (string, error) {
|
|||||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseV2SystemPruneType(raw string) (string, error) {
|
||||||
|
pruneType := strings.ToLower(raw)
|
||||||
|
switch pruneType {
|
||||||
|
case "":
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
|
||||||
|
case v2SystemPruneTypeOfflineProxies:
|
||||||
|
return pruneType, nil
|
||||||
|
default:
|
||||||
|
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func matchV2StatusFilter(online bool, filter string) bool {
|
func matchV2StatusFilter(online bool, filter string) bool {
|
||||||
switch filter {
|
switch filter {
|
||||||
case "", "all":
|
case "", "all":
|
||||||
@@ -346,26 +436,36 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
|
|||||||
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
||||||
values := []string{
|
values := []string{
|
||||||
item.Name,
|
item.Name,
|
||||||
item.Type,
|
item.Spec.Type,
|
||||||
item.User,
|
item.User,
|
||||||
item.ClientID,
|
item.ClientID,
|
||||||
item.Status.State,
|
item.Status.State,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch spec := item.Spec.(type) {
|
switch item.Spec.Type {
|
||||||
case *model.TCPOutConf:
|
case string(v1.ProxyTypeTCP):
|
||||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
|
||||||
case *model.UDPOutConf:
|
values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
|
||||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
}
|
||||||
case *model.HTTPOutConf:
|
case string(v1.ProxyTypeUDP):
|
||||||
values = append(values, spec.CustomDomains...)
|
if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
|
||||||
values = append(values, spec.SubDomain)
|
values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
|
||||||
case *model.HTTPSOutConf:
|
}
|
||||||
values = append(values, spec.CustomDomains...)
|
case string(v1.ProxyTypeHTTP):
|
||||||
values = append(values, spec.SubDomain)
|
if item.Spec.HTTP != nil {
|
||||||
case *model.TCPMuxOutConf:
|
values = append(values, item.Spec.HTTP.CustomDomains...)
|
||||||
values = append(values, spec.CustomDomains...)
|
values = append(values, item.Spec.HTTP.Subdomain)
|
||||||
values = append(values, spec.SubDomain)
|
}
|
||||||
|
case string(v1.ProxyTypeHTTPS):
|
||||||
|
if item.Spec.HTTPS != nil {
|
||||||
|
values = append(values, item.Spec.HTTPS.CustomDomains...)
|
||||||
|
values = append(values, item.Spec.HTTPS.Subdomain)
|
||||||
|
}
|
||||||
|
case string(v1.ProxyTypeTCPMUX):
|
||||||
|
if item.Spec.TCPMux != nil {
|
||||||
|
values = append(values, item.Spec.TCPMux.CustomDomains...)
|
||||||
|
values = append(values, item.Spec.TCPMux.Subdomain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return containsV2Query(q, values...)
|
return containsV2Query(q, values...)
|
||||||
@@ -392,6 +492,31 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
|
||||||
|
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
|
||||||
|
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
|
||||||
|
history = append(history, model.V2ProxyTrafficPointResp{
|
||||||
|
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
|
||||||
|
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
|
||||||
|
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.V2ProxyTrafficResp{
|
||||||
|
Name: name,
|
||||||
|
Unit: v2ProxyTrafficUnit,
|
||||||
|
Granularity: v2ProxyTrafficGranularity,
|
||||||
|
History: history,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
|
||||||
|
if todayFirstIndex >= len(values) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return values[todayFirstIndex]
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
||||||
status := model.V2ClientStatusResp{State: "offline"}
|
status := model.V2ClientStatusResp{State: "offline"}
|
||||||
if info.Online {
|
if info.Online {
|
||||||
@@ -412,27 +537,111 @@ func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2Clien
|
|||||||
|
|
||||||
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
||||||
state := "offline"
|
state := "offline"
|
||||||
var spec any
|
var cfg v1.ProxyConfigurer
|
||||||
if c.pxyManager != nil {
|
if c.pxyManager != nil {
|
||||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||||
state = "online"
|
state = "online"
|
||||||
spec = getConfFromConfigurer(pxy.GetConfigurer())
|
cfg = pxy.GetConfigurer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return model.V2ProxyResp{
|
return model.V2ProxyResp{
|
||||||
Name: ps.Name,
|
Name: ps.Name,
|
||||||
Type: ps.Type,
|
|
||||||
User: ps.User,
|
User: ps.User,
|
||||||
ClientID: ps.ClientID,
|
ClientID: ps.ClientID,
|
||||||
Spec: spec,
|
Spec: buildV2ProxySpec(ps.Type, cfg),
|
||||||
Status: model.V2ProxyStatusResp{
|
Status: model.V2ProxyStatusResp{
|
||||||
State: state,
|
State: state,
|
||||||
TodayTrafficIn: ps.TodayTrafficIn,
|
TodayTrafficIn: ps.TodayTrafficIn,
|
||||||
TodayTrafficOut: ps.TodayTrafficOut,
|
TodayTrafficOut: ps.TodayTrafficOut,
|
||||||
CurConns: ps.CurConns,
|
CurConns: ps.CurConns,
|
||||||
LastStartTime: ps.LastStartTime,
|
LastStartAt: ps.LastStartAt,
|
||||||
LastCloseTime: ps.LastCloseTime,
|
LastCloseAt: ps.LastCloseAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
|
||||||
|
spec := model.V2ProxySpec{Type: proxyType}
|
||||||
|
|
||||||
|
switch proxyType {
|
||||||
|
case string(v1.ProxyTypeTCP):
|
||||||
|
block := &model.V2TCPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.RemotePort = &c.RemotePort
|
||||||
|
}
|
||||||
|
spec.TCP = block
|
||||||
|
case string(v1.ProxyTypeUDP):
|
||||||
|
block := &model.V2UDPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.RemotePort = &c.RemotePort
|
||||||
|
}
|
||||||
|
spec.UDP = block
|
||||||
|
case string(v1.ProxyTypeHTTP):
|
||||||
|
block := &model.V2HTTPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||||
|
block.Subdomain = c.SubDomain
|
||||||
|
block.Locations = slices.Clone(c.Locations)
|
||||||
|
block.HostHeaderRewrite = c.HostHeaderRewrite
|
||||||
|
}
|
||||||
|
spec.HTTP = block
|
||||||
|
case string(v1.ProxyTypeHTTPS):
|
||||||
|
block := &model.V2HTTPSProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||||
|
block.Subdomain = c.SubDomain
|
||||||
|
}
|
||||||
|
spec.HTTPS = block
|
||||||
|
case string(v1.ProxyTypeTCPMUX):
|
||||||
|
block := &model.V2TCPMuxProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||||
|
block.Subdomain = c.SubDomain
|
||||||
|
block.Multiplexer = c.Multiplexer
|
||||||
|
block.RouteByHTTPUser = c.RouteByHTTPUser
|
||||||
|
}
|
||||||
|
spec.TCPMux = block
|
||||||
|
case string(v1.ProxyTypeSTCP):
|
||||||
|
block := &model.V2STCPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
}
|
||||||
|
spec.STCP = block
|
||||||
|
case string(v1.ProxyTypeSUDP):
|
||||||
|
block := &model.V2SUDPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
}
|
||||||
|
spec.SUDP = block
|
||||||
|
case string(v1.ProxyTypeXTCP):
|
||||||
|
block := &model.V2XTCPProxySpec{}
|
||||||
|
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
|
||||||
|
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||||
|
}
|
||||||
|
spec.XTCP = block
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
|
||||||
|
return model.V2ProxyBaseSpec{
|
||||||
|
Annotations: maps.Clone(base.Annotations),
|
||||||
|
Metadatas: maps.Clone(base.Metadatas),
|
||||||
|
Transport: &model.V2ProxyTransportSpec{
|
||||||
|
UseEncryption: base.Transport.UseEncryption,
|
||||||
|
UseCompression: base.Transport.UseCompression,
|
||||||
|
BandwidthLimit: base.Transport.BandwidthLimit.String(),
|
||||||
|
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
|
||||||
|
},
|
||||||
|
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
|
||||||
|
Group: base.LoadBalancer.Group,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
393
server/http/controller_v2_proxy_spec_test.go
Normal file
393
server/http/controller_v2_proxy_spec_test.go
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configtypes "github.com/fatedier/frp/pkg/config/types"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
|
"github.com/fatedier/frp/server/http/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
proxyType string
|
||||||
|
cfg v1.ProxyConfigurer
|
||||||
|
blockKeys []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
proxyType: "tcp",
|
||||||
|
cfg: &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
|
||||||
|
RemotePort: 6000,
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "udp",
|
||||||
|
cfg: &v1.UDPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||||
|
RemotePort: 7000,
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "http",
|
||||||
|
cfg: &v1.HTTPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
|
||||||
|
Locations: []string{"/api"},
|
||||||
|
HTTPUser: "secret-http-user",
|
||||||
|
HTTPPassword: "secret-http-password",
|
||||||
|
HostHeaderRewrite: "backend.example.com",
|
||||||
|
RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
|
||||||
|
ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
|
||||||
|
RouteByHTTPUser: "secret-http-route-user",
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "https",
|
||||||
|
cfg: &v1.HTTPSProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "tcpmux",
|
||||||
|
cfg: &v1.TCPMuxProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
|
||||||
|
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
|
||||||
|
HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
|
||||||
|
HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
|
||||||
|
RouteByHTTPUser: "displayed-mux-user",
|
||||||
|
Multiplexer: "httpconnect",
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "stcp",
|
||||||
|
cfg: &v1.STCPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
|
||||||
|
Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
|
||||||
|
AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "sudp",
|
||||||
|
cfg: &v1.SUDPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
|
||||||
|
Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
|
||||||
|
AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyType: "xtcp",
|
||||||
|
cfg: &v1.XTCPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
|
||||||
|
Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
|
||||||
|
AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
|
||||||
|
},
|
||||||
|
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.proxyType, func(t *testing.T) {
|
||||||
|
spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
|
||||||
|
raw := mustMarshalJSON(t, spec)
|
||||||
|
|
||||||
|
var specObject map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &specObject); err != nil {
|
||||||
|
t.Fatalf("unmarshal spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, specObject, tt.proxyType, "type")
|
||||||
|
|
||||||
|
var gotType string
|
||||||
|
if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
|
||||||
|
t.Fatalf("unmarshal spec type failed: %v", err)
|
||||||
|
}
|
||||||
|
if gotType != tt.proxyType {
|
||||||
|
t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var block map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal active block failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, block, tt.blockKeys...)
|
||||||
|
assertV2ProxyCommonSpec(t, block)
|
||||||
|
assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
|
||||||
|
assertNoV2ProxySensitiveFields(t, block)
|
||||||
|
|
||||||
|
content := string(raw)
|
||||||
|
for _, secret := range []string{
|
||||||
|
"secret-proxy-name",
|
||||||
|
"secret-group-key",
|
||||||
|
"secret-local-host",
|
||||||
|
"secret-plugin-user",
|
||||||
|
"secret-plugin-password",
|
||||||
|
"secret-health-path",
|
||||||
|
"secret-http-user",
|
||||||
|
"secret-http-password",
|
||||||
|
"secret-request-header",
|
||||||
|
"secret-response-header",
|
||||||
|
"secret-http-route-user",
|
||||||
|
"secret-mux-http-user",
|
||||||
|
"secret-mux-http-password",
|
||||||
|
"secret-stcp-key",
|
||||||
|
"secret-stcp-user",
|
||||||
|
"secret-sudp-key",
|
||||||
|
"secret-sudp-user",
|
||||||
|
"secret-xtcp-key",
|
||||||
|
"secret-xtcp-user",
|
||||||
|
} {
|
||||||
|
if strings.Contains(content, secret) {
|
||||||
|
t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
switch proxyType {
|
||||||
|
case "tcp":
|
||||||
|
var block model.V2TCPProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal tcp block failed: %v", err)
|
||||||
|
}
|
||||||
|
if block.RemotePort == nil || *block.RemotePort != 6000 {
|
||||||
|
t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
|
||||||
|
}
|
||||||
|
case "udp":
|
||||||
|
var block model.V2UDPProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal udp block failed: %v", err)
|
||||||
|
}
|
||||||
|
if block.RemotePort == nil || *block.RemotePort != 7000 {
|
||||||
|
t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
|
||||||
|
}
|
||||||
|
case "http":
|
||||||
|
var block model.V2HTTPProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal http block failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
|
||||||
|
block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
|
||||||
|
block.HostHeaderRewrite != "backend.example.com" {
|
||||||
|
t.Fatalf("http fields mismatch: %#v", block)
|
||||||
|
}
|
||||||
|
case "https":
|
||||||
|
var block model.V2HTTPSProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal https block failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
|
||||||
|
t.Fatalf("https fields mismatch: %#v", block)
|
||||||
|
}
|
||||||
|
case "tcpmux":
|
||||||
|
var block model.V2TCPMuxProxySpec
|
||||||
|
if err := json.Unmarshal(raw, &block); err != nil {
|
||||||
|
t.Fatalf("unmarshal tcpmux block failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
|
||||||
|
block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
|
||||||
|
t.Fatalf("tcpmux fields mismatch: %#v", block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
|
||||||
|
for _, proxyType := range apiV2ProxyTypes {
|
||||||
|
t.Run(proxyType, func(t *testing.T) {
|
||||||
|
resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
|
||||||
|
Name: "offline-" + proxyType,
|
||||||
|
Type: proxyType,
|
||||||
|
})
|
||||||
|
if resp.Status.State != "offline" {
|
||||||
|
t.Fatalf("offline phase mismatch: %#v", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var specObject map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
|
||||||
|
t.Fatalf("unmarshal offline spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, specObject, proxyType, "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, specObject[proxyType])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
|
||||||
|
spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
|
||||||
|
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||||
|
RemotePort: 7000,
|
||||||
|
})
|
||||||
|
|
||||||
|
var specObject map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
|
||||||
|
t.Fatalf("unmarshal mismatched spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, specObject, "tcp", "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, specObject["tcp"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create bandwidth limit failed: %v", err)
|
||||||
|
}
|
||||||
|
enabled := false
|
||||||
|
return v1.ProxyBaseConfig{
|
||||||
|
Name: "secret-proxy-name",
|
||||||
|
Type: proxyType,
|
||||||
|
Enabled: &enabled,
|
||||||
|
Annotations: map[string]string{"annotation-key": "annotation-value"},
|
||||||
|
Metadatas: map[string]string{"metadata-key": "metadata-value"},
|
||||||
|
Transport: v1.ProxyTransport{
|
||||||
|
UseEncryption: true,
|
||||||
|
UseCompression: true,
|
||||||
|
BandwidthLimit: bandwidthLimit,
|
||||||
|
BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
|
||||||
|
ProxyProtocolVersion: "v2",
|
||||||
|
},
|
||||||
|
LoadBalancer: v1.LoadBalancerConfig{
|
||||||
|
Group: "public-group",
|
||||||
|
GroupKey: "secret-group-key",
|
||||||
|
},
|
||||||
|
HealthCheck: v1.HealthCheckConfig{
|
||||||
|
Type: "http",
|
||||||
|
Path: "secret-health-path",
|
||||||
|
},
|
||||||
|
ProxyBackend: v1.ProxyBackend{
|
||||||
|
LocalIP: "secret-local-host",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Plugin: v1.TypedClientPluginOptions{
|
||||||
|
Type: v1.PluginHTTPProxy,
|
||||||
|
ClientPluginOptions: &v1.HTTPProxyPluginOptions{
|
||||||
|
Type: v1.PluginHTTPProxy,
|
||||||
|
HTTPUser: "secret-plugin-user",
|
||||||
|
HTTPPassword: "secret-plugin-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var annotations map[string]string
|
||||||
|
if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
|
||||||
|
t.Fatalf("unmarshal annotations failed: %v", err)
|
||||||
|
}
|
||||||
|
if annotations["annotation-key"] != "annotation-value" {
|
||||||
|
t.Fatalf("annotations mismatch: %#v", annotations)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadatas map[string]string
|
||||||
|
if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
|
||||||
|
t.Fatalf("unmarshal metadatas failed: %v", err)
|
||||||
|
}
|
||||||
|
if metadatas["metadata-key"] != "metadata-value" {
|
||||||
|
t.Fatalf("metadatas mismatch: %#v", metadatas)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRawJSONKeysFromMessage(t, block["transport"],
|
||||||
|
"bandwidthLimit",
|
||||||
|
"bandwidthLimitMode",
|
||||||
|
"useCompression",
|
||||||
|
"useEncryption",
|
||||||
|
)
|
||||||
|
var transport model.V2ProxyTransportSpec
|
||||||
|
if err := json.Unmarshal(block["transport"], &transport); err != nil {
|
||||||
|
t.Fatalf("unmarshal transport failed: %v", err)
|
||||||
|
}
|
||||||
|
if !transport.UseEncryption || !transport.UseCompression ||
|
||||||
|
transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
|
||||||
|
t.Fatalf("transport mismatch: %#v", transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
|
||||||
|
var loadBalancer model.V2ProxyLoadBalancerSpec
|
||||||
|
if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
|
||||||
|
t.Fatalf("unmarshal load balancer failed: %v", err)
|
||||||
|
}
|
||||||
|
if loadBalancer.Group != "public-group" {
|
||||||
|
t.Fatalf("load balancer mismatch: %#v", loadBalancer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
forbidden := map[string]struct{}{
|
||||||
|
"allowUsers": {},
|
||||||
|
"enabled": {},
|
||||||
|
"groupKey": {},
|
||||||
|
"healthCheck": {},
|
||||||
|
"httpPassword": {},
|
||||||
|
"httpUser": {},
|
||||||
|
"localIP": {},
|
||||||
|
"localPort": {},
|
||||||
|
"name": {},
|
||||||
|
"natTraversal": {},
|
||||||
|
"plugin": {},
|
||||||
|
"proxyProtocolVersion": {},
|
||||||
|
"requestHeaders": {},
|
||||||
|
"responseHeaders": {},
|
||||||
|
"secretKey": {},
|
||||||
|
"type": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var walk func(any)
|
||||||
|
walk = func(current any) {
|
||||||
|
switch current := current.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, nested := range current {
|
||||||
|
if _, ok := forbidden[key]; ok {
|
||||||
|
t.Fatalf("sensitive field %q leaked in active block", key)
|
||||||
|
}
|
||||||
|
walk(nested)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, nested := range current {
|
||||||
|
walk(nested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal active block failed: %v", err)
|
||||||
|
}
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode active block failed: %v", err)
|
||||||
|
}
|
||||||
|
walk(decoded)
|
||||||
|
}
|
||||||
@@ -22,9 +22,11 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/config/types"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
@@ -40,10 +42,16 @@ type v2EnvelopeForTest[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeStatsCollector struct {
|
type fakeStatsCollector struct {
|
||||||
proxies map[string]*mem.ProxyStats
|
server *mem.ServerStats
|
||||||
|
proxies map[string]*mem.ProxyStats
|
||||||
|
traffic map[string]*mem.ProxyTrafficInfo
|
||||||
|
pruneable map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||||
|
if f.server != nil {
|
||||||
|
return f.server
|
||||||
|
}
|
||||||
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +78,210 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
||||||
return nil
|
return f.traffic[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||||
return 0, len(f.proxies)
|
return 0, len(f.proxies)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
|
||||||
|
total := len(f.proxies)
|
||||||
|
cleared := 0
|
||||||
|
for name := range f.pruneable {
|
||||||
|
if _, ok := f.proxies[name]; ok {
|
||||||
|
delete(f.proxies, name)
|
||||||
|
cleared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.pruneable = map[string]bool{}
|
||||||
|
return cleared, total
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
server: &mem.ServerStats{
|
||||||
|
TotalTrafficIn: 1024,
|
||||||
|
TotalTrafficOut: 2048,
|
||||||
|
CurConns: 3,
|
||||||
|
ClientCounts: 4,
|
||||||
|
ProxyTypeCounts: map[string]int64{
|
||||||
|
"tcp": 2,
|
||||||
|
"http": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proxies: map[string]*mem.ProxyStats{},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
controller := NewController(&v1.ServerConfig{
|
||||||
|
BindPort: 7000,
|
||||||
|
VhostHTTPPort: 8080,
|
||||||
|
VhostHTTPSPort: 8443,
|
||||||
|
TCPMuxHTTPConnectPort: 9000,
|
||||||
|
KCPBindPort: 7001,
|
||||||
|
QUICBindPort: 7002,
|
||||||
|
SubDomainHost: "example.com",
|
||||||
|
MaxPortsPerClient: 8,
|
||||||
|
AllowPorts: []types.PortsRange{
|
||||||
|
{Start: 1000, End: 1002},
|
||||||
|
{Single: 2000},
|
||||||
|
},
|
||||||
|
Transport: v1.ServerTransportConfig{
|
||||||
|
MaxPoolCount: 5,
|
||||||
|
HeartbeatTimeout: 90,
|
||||||
|
TLS: v1.TLSServerConfig{
|
||||||
|
Force: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/system/info")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||||
|
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||||
|
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
|
||||||
|
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
|
||||||
|
"allowPortsStr",
|
||||||
|
"bindPort",
|
||||||
|
"heartbeatTimeout",
|
||||||
|
"kcpBindPort",
|
||||||
|
"maxPoolCount",
|
||||||
|
"maxPortsPerClient",
|
||||||
|
"quicBindPort",
|
||||||
|
"subdomainHost",
|
||||||
|
"tcpmuxHTTPConnectPort",
|
||||||
|
"tlsForce",
|
||||||
|
"vhostHTTPPort",
|
||||||
|
"vhostHTTPSPort",
|
||||||
|
)
|
||||||
|
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
|
||||||
|
"clientCounts",
|
||||||
|
"curConns",
|
||||||
|
"proxyTypeCount",
|
||||||
|
"totalTrafficIn",
|
||||||
|
"totalTrafficOut",
|
||||||
|
)
|
||||||
|
|
||||||
|
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
|
||||||
|
if systemResp.Data.Version == "" {
|
||||||
|
t.Fatal("version should be set at top level")
|
||||||
|
}
|
||||||
|
if systemResp.Data.Config.BindPort != 7000 ||
|
||||||
|
systemResp.Data.Config.VhostHTTPPort != 8080 ||
|
||||||
|
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
|
||||||
|
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
|
||||||
|
systemResp.Data.Config.KCPBindPort != 7001 ||
|
||||||
|
systemResp.Data.Config.QUICBindPort != 7002 ||
|
||||||
|
systemResp.Data.Config.SubdomainHost != "example.com" ||
|
||||||
|
systemResp.Data.Config.MaxPoolCount != 5 ||
|
||||||
|
systemResp.Data.Config.MaxPortsPerClient != 8 ||
|
||||||
|
systemResp.Data.Config.HeartbeatTimeout != 90 ||
|
||||||
|
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
|
||||||
|
!systemResp.Data.Config.TLSForce {
|
||||||
|
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
|
||||||
|
}
|
||||||
|
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
|
||||||
|
systemResp.Data.Status.TotalTrafficOut != 2048 ||
|
||||||
|
systemResp.Data.Status.CurConns != 3 ||
|
||||||
|
systemResp.Data.Status.ClientCounts != 4 ||
|
||||||
|
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
|
||||||
|
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
|
||||||
|
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
collector := &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
|
||||||
|
"http-offline": {Name: "http-offline", Type: "http"},
|
||||||
|
"udp-offline": {Name: "udp-offline", Type: "udp"},
|
||||||
|
"tcp-online": {Name: "tcp-online", Type: "tcp"},
|
||||||
|
"http-online": {Name: "http-online", Type: "http"},
|
||||||
|
"udp-online": {Name: "udp-online", Type: "udp"},
|
||||||
|
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
|
||||||
|
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
|
||||||
|
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
|
||||||
|
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
|
||||||
|
},
|
||||||
|
pruneable: map[string]bool{
|
||||||
|
"tcp-offline": true,
|
||||||
|
"http-offline": true,
|
||||||
|
"udp-offline": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mem.StatsCollector = collector
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||||
|
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||||
|
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
|
||||||
|
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||||
|
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
|
||||||
|
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
|
||||||
|
}
|
||||||
|
if _, ok := collector.proxies["tcp-offline"]; ok {
|
||||||
|
t.Fatal("pruned proxy statistics should be removed")
|
||||||
|
}
|
||||||
|
if _, ok := collector.proxies["tcp-online"]; !ok {
|
||||||
|
t.Fatal("online proxy statistics should remain")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||||
|
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
|
||||||
|
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
|
||||||
|
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
errResp = decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
|
||||||
|
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
||||||
controller := newV2TestController(t)
|
controller := newV2TestController(t)
|
||||||
router := newV2TestRouter(controller)
|
router := newV2TestRouter(controller)
|
||||||
@@ -211,15 +416,42 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
|||||||
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
||||||
}
|
}
|
||||||
proxyItem := proxyResp.Data.Items[0]
|
proxyItem := proxyResp.Data.Items[0]
|
||||||
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||||
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||||
}
|
}
|
||||||
|
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
|
||||||
|
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
|
||||||
|
var rawListSpec map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
|
||||||
|
t.Fatalf("unmarshal list proxy spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
||||||
|
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||||
|
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
|
||||||
|
"curConns",
|
||||||
|
"lastCloseAt",
|
||||||
|
"lastStartAt",
|
||||||
|
"phase",
|
||||||
|
"todayTrafficIn",
|
||||||
|
"todayTrafficOut",
|
||||||
|
)
|
||||||
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||||
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
|
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
|
||||||
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
|
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
|
||||||
}
|
}
|
||||||
|
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
|
||||||
|
var rawDetailSpec map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
|
||||||
|
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
|
||||||
|
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
|
||||||
|
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
|
||||||
|
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
|
||||||
|
}
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
||||||
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
||||||
@@ -238,6 +470,140 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
"ssh": {Name: "ssh", Type: "tcp"},
|
||||||
|
},
|
||||||
|
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||||
|
"ssh": {
|
||||||
|
Name: "ssh",
|
||||||
|
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
|
||||||
|
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||||
|
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||||
|
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
|
||||||
|
|
||||||
|
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||||
|
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
|
||||||
|
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
|
||||||
|
}
|
||||||
|
if len(trafficResp.Data.History) != 7 {
|
||||||
|
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
|
||||||
|
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
|
||||||
|
var prevDate time.Time
|
||||||
|
for i, point := range trafficResp.Data.History {
|
||||||
|
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
|
||||||
|
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
|
||||||
|
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
|
||||||
|
}
|
||||||
|
parsedDate, err := time.Parse(time.DateOnly, point.Date)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
|
||||||
|
}
|
||||||
|
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
|
||||||
|
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
|
||||||
|
}
|
||||||
|
prevDate = parsedDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
|
||||||
|
t.Fatalf("not found envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
|
||||||
|
name := "folder/ssh?x#y"
|
||||||
|
oldStatsCollector := mem.StatsCollector
|
||||||
|
mem.StatsCollector = &fakeStatsCollector{
|
||||||
|
proxies: map[string]*mem.ProxyStats{
|
||||||
|
name: {Name: name, Type: "tcp", User: "encoded"},
|
||||||
|
},
|
||||||
|
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||||
|
name: {
|
||||||
|
Name: name,
|
||||||
|
TrafficIn: []int64{1},
|
||||||
|
TrafficOut: []int64{2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mem.StatsCollector = oldStatsCollector
|
||||||
|
})
|
||||||
|
|
||||||
|
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
||||||
|
router := newV2TestRouter(controller)
|
||||||
|
encodedName := url.PathEscape(name)
|
||||||
|
|
||||||
|
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||||
|
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
|
||||||
|
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||||
|
if trafficResp.Data.Name != name {
|
||||||
|
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
|
||||||
|
}
|
||||||
|
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
|
||||||
|
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
|
||||||
|
controller := newV2TestController(t)
|
||||||
|
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||||
|
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
|
||||||
|
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -247,66 +613,85 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "tcp remote port",
|
name: "tcp remote port",
|
||||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||||
RemotePort: 6000,
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||||
}},
|
}},
|
||||||
q: "6000",
|
q: "6000",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "udp remote port",
|
name: "udp remote port",
|
||||||
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
|
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
|
||||||
RemotePort: 7000,
|
Type: "udp",
|
||||||
|
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
|
||||||
}},
|
}},
|
||||||
q: "7000",
|
q: "7000",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "remote port does not match colon form",
|
name: "remote port does not match colon form",
|
||||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||||
RemotePort: 6000,
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||||
}},
|
}},
|
||||||
q: ":6000",
|
q: ":6000",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "http custom domain",
|
name: "http custom domain",
|
||||||
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
|
item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
|
||||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
|
Type: "http",
|
||||||
|
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
|
||||||
}},
|
}},
|
||||||
q: "app.example.com",
|
q: "app.example.com",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "https subdomain",
|
name: "https subdomain",
|
||||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
Type: "https",
|
||||||
|
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||||
}},
|
}},
|
||||||
q: "portal",
|
q: "portal",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "subdomain does not match expanded host",
|
name: "subdomain does not match expanded host",
|
||||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
Type: "https",
|
||||||
|
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||||
}},
|
}},
|
||||||
q: "portal.example.com",
|
q: "portal.example.com",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tcpmux custom domain",
|
name: "tcpmux custom domain",
|
||||||
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
|
item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
|
||||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
|
Type: "tcpmux",
|
||||||
|
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
|
||||||
}},
|
}},
|
||||||
q: "mux.example.com",
|
q: "mux.example.com",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nil spec does not match spec fields",
|
name: "offline shell does not match online spec fields",
|
||||||
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{},
|
||||||
|
}},
|
||||||
q: "6000",
|
q: "6000",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "offline shell does not contribute zero remote port",
|
||||||
|
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||||
|
Type: "tcp",
|
||||||
|
TCP: &model.V2TCPProxySpec{},
|
||||||
|
}},
|
||||||
|
q: "0",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -322,7 +707,26 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
|||||||
controller := newV2TestController(t)
|
controller := newV2TestController(t)
|
||||||
router := newV2TestRouter(controller)
|
router := newV2TestRouter(controller)
|
||||||
|
|
||||||
resp := performRequest(router, "/api/clients")
|
resp := performRequest(router, "/api/serverinfo")
|
||||||
|
var serverInfo model.ServerInfoResp
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
|
||||||
|
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||||
|
}
|
||||||
|
if serverInfo.Version == "" {
|
||||||
|
t.Fatal("legacy serverinfo version should be set")
|
||||||
|
}
|
||||||
|
var serverInfoRaw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
|
||||||
|
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := serverInfoRaw["data"]; ok {
|
||||||
|
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := serverInfoRaw["config"]; ok {
|
||||||
|
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/clients")
|
||||||
var clients []model.ClientInfoResp
|
var clients []model.ClientInfoResp
|
||||||
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
|
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
|
||||||
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
|
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
|
||||||
@@ -343,6 +747,28 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
|||||||
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
||||||
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resp = performRequest(router, "/api/traffic/tcp-alice")
|
||||||
|
var traffic model.GetProxyTrafficResp
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
|
||||||
|
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||||
|
}
|
||||||
|
if traffic.Name != "tcp-alice" ||
|
||||||
|
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
|
||||||
|
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
|
||||||
|
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
|
||||||
|
}
|
||||||
|
var trafficRaw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
|
||||||
|
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := trafficRaw["data"]; ok {
|
||||||
|
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func v2TestIntPtr(value int) *int {
|
||||||
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
func newV2TestController(t *testing.T) *Controller {
|
func newV2TestController(t *testing.T) *Controller {
|
||||||
@@ -368,6 +794,10 @@ func newV2TestController(t *testing.T) *Controller {
|
|||||||
TodayTrafficIn: 30,
|
TodayTrafficIn: 30,
|
||||||
TodayTrafficOut: 40,
|
TodayTrafficOut: 40,
|
||||||
CurConns: 2,
|
CurConns: 2,
|
||||||
|
LastStartTime: "07-08 12:30:00",
|
||||||
|
LastCloseTime: "07-08 12:31:40",
|
||||||
|
LastStartAt: 1783504200,
|
||||||
|
LastCloseAt: 1783504300,
|
||||||
},
|
},
|
||||||
"http-alice": {
|
"http-alice": {
|
||||||
Name: "http-alice",
|
Name: "http-alice",
|
||||||
@@ -383,6 +813,13 @@ func newV2TestController(t *testing.T) *Controller {
|
|||||||
ClientID: "client-b",
|
ClientID: "client-b",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||||
|
"tcp-alice": {
|
||||||
|
Name: "tcp-alice",
|
||||||
|
TrafficIn: []int64{7, 6},
|
||||||
|
TrafficOut: []int64{70, 60},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
mem.StatsCollector = oldStatsCollector
|
mem.StatsCollector = oldStatsCollector
|
||||||
@@ -400,19 +837,28 @@ func newV2TestController(t *testing.T) *Controller {
|
|||||||
func newV2TestRouter(controller *Controller) *mux.Router {
|
func newV2TestRouter(controller *Controller) *mux.Router {
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
|
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
|
||||||
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
|
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
|
||||||
encodedPathRouter := router.NewRoute().Subrouter()
|
encodedPathRouter := router.NewRoute().Subrouter()
|
||||||
encodedPathRouter.UseEncodedPath()
|
encodedPathRouter.UseEncodedPath()
|
||||||
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
|
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
|
||||||
|
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
return performRequestWithMethod(handler, http.MethodGet, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, target, nil)
|
||||||
resp := httptest.NewRecorder()
|
resp := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(resp, req)
|
handler.ServeHTTP(resp, req)
|
||||||
return resp
|
return resp
|
||||||
@@ -427,3 +873,36 @@ func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if len(raw) != len(want) {
|
||||||
|
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
|
||||||
|
}
|
||||||
|
for _, key := range want {
|
||||||
|
if _, ok := raw[key]; !ok {
|
||||||
|
t.Fatalf("json key %q missing from %v", key, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
|
||||||
|
}
|
||||||
|
assertRawJSONKeys(t, out, want...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
out, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal json failed: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,41 @@ type V2PageResp[T any] struct {
|
|||||||
Items []T `json:"items"`
|
Items []T `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type V2SystemInfoResp struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Config V2SystemInfoConfigResp `json:"config"`
|
||||||
|
Status V2SystemInfoStatusResp `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2SystemInfoConfigResp struct {
|
||||||
|
BindPort int `json:"bindPort"`
|
||||||
|
VhostHTTPPort int `json:"vhostHTTPPort"`
|
||||||
|
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
||||||
|
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
||||||
|
KCPBindPort int `json:"kcpBindPort"`
|
||||||
|
QUICBindPort int `json:"quicBindPort"`
|
||||||
|
SubdomainHost string `json:"subdomainHost"`
|
||||||
|
MaxPoolCount int64 `json:"maxPoolCount"`
|
||||||
|
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
||||||
|
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
|
||||||
|
AllowPortsStr string `json:"allowPortsStr"`
|
||||||
|
TLSForce bool `json:"tlsForce"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2SystemInfoStatusResp struct {
|
||||||
|
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
||||||
|
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
||||||
|
CurConns int64 `json:"curConns"`
|
||||||
|
ClientCounts int64 `json:"clientCounts"`
|
||||||
|
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2SystemPruneResp struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cleared int `json:"cleared"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
type V2UserResp struct {
|
type V2UserResp struct {
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
ClientCount int `json:"clientCount"`
|
ClientCount int `json:"clientCount"`
|
||||||
@@ -40,18 +75,105 @@ type V2ClientStatusResp struct {
|
|||||||
|
|
||||||
type V2ProxyResp struct {
|
type V2ProxyResp struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
ClientID string `json:"clientID"`
|
ClientID string `json:"clientID"`
|
||||||
Spec any `json:"spec"`
|
Spec V2ProxySpec `json:"spec"`
|
||||||
Status V2ProxyStatusResp `json:"status"`
|
Status V2ProxyStatusResp `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type V2ProxySpec struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
|
||||||
|
UDP *V2UDPProxySpec `json:"udp,omitempty"`
|
||||||
|
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
|
||||||
|
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
|
||||||
|
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
|
||||||
|
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
|
||||||
|
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
|
||||||
|
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyBaseSpec struct {
|
||||||
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
|
Metadatas map[string]string `json:"metadatas,omitempty"`
|
||||||
|
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
|
||||||
|
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyTransportSpec struct {
|
||||||
|
UseEncryption bool `json:"useEncryption"`
|
||||||
|
UseCompression bool `json:"useCompression"`
|
||||||
|
BandwidthLimit string `json:"bandwidthLimit"`
|
||||||
|
BandwidthLimitMode string `json:"bandwidthLimitMode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyLoadBalancerSpec struct {
|
||||||
|
Group string `json:"group"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2TCPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
RemotePort *int `json:"remotePort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2UDPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
RemotePort *int `json:"remotePort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2HTTPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
CustomDomains []string `json:"customDomains,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
Locations []string `json:"locations,omitempty"`
|
||||||
|
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2HTTPSProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
CustomDomains []string `json:"customDomains,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2TCPMuxProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
CustomDomains []string `json:"customDomains,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
Multiplexer string `json:"multiplexer,omitempty"`
|
||||||
|
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2STCPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2SUDPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2XTCPProxySpec struct {
|
||||||
|
V2ProxyBaseSpec
|
||||||
|
}
|
||||||
|
|
||||||
type V2ProxyStatusResp struct {
|
type V2ProxyStatusResp struct {
|
||||||
State string `json:"phase"`
|
State string `json:"phase"`
|
||||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||||
CurConns int64 `json:"curConns"`
|
CurConns int64 `json:"curConns"`
|
||||||
LastStartTime string `json:"lastStartTime"`
|
LastStartAt int64 `json:"lastStartAt,omitempty"`
|
||||||
LastCloseTime string `json:"lastCloseTime"`
|
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyTrafficResp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Granularity string `json:"granularity"`
|
||||||
|
History []V2ProxyTrafficPointResp `json:"history"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V2ProxyTrafficPointResp struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
TrafficIn int64 `json:"trafficIn"`
|
||||||
|
TrafficOut int64 `json:"trafficOut"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ export const http = {
|
|||||||
request<T>(url, { ...options, method: 'GET' }),
|
request<T>(url, { ...options, method: 'GET' }),
|
||||||
getV2: <T>(url: string, options?: RequestInit) =>
|
getV2: <T>(url: string, options?: RequestInit) =>
|
||||||
requestV2<T>(url, { ...options, method: 'GET' }),
|
requestV2<T>(url, { ...options, method: 'GET' }),
|
||||||
|
postV2: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||||
|
requestV2<T>(url, {
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||||
request<T>(url, {
|
request<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { buildQueryString, http } from './http'
|
import { buildQueryString, http } from './http'
|
||||||
|
import { formatUnixSeconds } from '../utils/format'
|
||||||
import type { V2Page } from './http'
|
import type { V2Page } from './http'
|
||||||
import type {
|
import type {
|
||||||
GetProxyResponse,
|
GetProxyResponse,
|
||||||
ProxyListV2Params,
|
ProxyListV2Params,
|
||||||
ProxyStatsInfo,
|
ProxyStatsInfo,
|
||||||
ProxyV2Info,
|
ProxyV2Info,
|
||||||
|
ProxyV2Spec,
|
||||||
|
ProxyV2SpecBlocks,
|
||||||
|
ProxyV2Type,
|
||||||
TrafficResponse,
|
TrafficResponse,
|
||||||
} from '../types/proxy'
|
} from '../types/proxy'
|
||||||
|
|
||||||
|
export interface SystemPruneResponse {
|
||||||
|
type: 'offline_proxies'
|
||||||
|
cleared: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
export const getProxiesByType = (type: string) => {
|
export const getProxiesByType = (type: string) => {
|
||||||
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||||
}
|
}
|
||||||
@@ -32,32 +42,77 @@ export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
|
const getActiveProxySpec = (
|
||||||
name: proxy.name,
|
spec: ProxyV2Spec,
|
||||||
type: proxy.type,
|
): ProxyV2SpecBlocks[ProxyV2Type] => {
|
||||||
conf: proxy.spec,
|
switch (spec.type) {
|
||||||
user: proxy.user,
|
case 'tcp':
|
||||||
clientID: proxy.clientID,
|
return spec.tcp
|
||||||
todayTrafficIn: proxy.status.todayTrafficIn,
|
case 'udp':
|
||||||
todayTrafficOut: proxy.status.todayTrafficOut,
|
return spec.udp
|
||||||
curConns: proxy.status.curConns,
|
case 'http':
|
||||||
lastStartTime: proxy.status.lastStartTime,
|
return spec.http
|
||||||
lastCloseTime: proxy.status.lastCloseTime,
|
case 'https':
|
||||||
status: proxy.status.phase,
|
return spec.https
|
||||||
})
|
case 'tcpmux':
|
||||||
|
return spec.tcpmux
|
||||||
|
case 'stcp':
|
||||||
|
return spec.stcp
|
||||||
|
case 'sudp':
|
||||||
|
return spec.sudp
|
||||||
|
case 'xtcp':
|
||||||
|
return spec.xtcp
|
||||||
|
default:
|
||||||
|
return assertNever(spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertNever = (value: never): never => {
|
||||||
|
throw new Error(`Unsupported proxy spec: ${JSON.stringify(value)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => {
|
||||||
|
const type = proxy.spec.type
|
||||||
|
const activeSpec = getActiveProxySpec(proxy.spec)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: proxy.name,
|
||||||
|
type,
|
||||||
|
conf: proxy.status.phase === 'offline' ? null : activeSpec,
|
||||||
|
user: proxy.user,
|
||||||
|
clientID: proxy.clientID,
|
||||||
|
todayTrafficIn: proxy.status.todayTrafficIn,
|
||||||
|
todayTrafficOut: proxy.status.todayTrafficOut,
|
||||||
|
curConns: proxy.status.curConns,
|
||||||
|
lastStartTime: formatUnixSeconds(proxy.status.lastStartAt),
|
||||||
|
lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt),
|
||||||
|
status: proxy.status.phase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getProxy = (type: string, name: string) => {
|
export const getProxy = (type: string, name: string) => {
|
||||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getProxyByNameV2 = async (name: string) => {
|
||||||
|
const proxy = await http.getV2<ProxyV2Info>(
|
||||||
|
`../api/v2/proxies/${encodeURIComponent(name)}`,
|
||||||
|
)
|
||||||
|
return toLegacyProxyStats(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
export const getProxyByName = (name: string) => {
|
export const getProxyByName = (name: string) => {
|
||||||
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
|
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProxyTraffic = (name: string) => {
|
export const getProxyTraffic = (name: string) => {
|
||||||
return http.get<TrafficResponse>(`../api/traffic/${name}`)
|
return http.getV2<TrafficResponse>(
|
||||||
|
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clearOfflineProxies = () => {
|
export const clearOfflineProxies = () => {
|
||||||
return http.delete('../api/proxies?status=offline')
|
return http.postV2<SystemPruneResponse>(
|
||||||
|
'../api/v2/system/prune?type=offline_proxies',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { http } from './http'
|
|||||||
import type { ServerInfo } from '../types/server'
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
export const getServerInfo = () => {
|
export const getServerInfo = () => {
|
||||||
return http.get<ServerInfo>('../api/serverinfo')
|
return http.getV2<ServerInfo>('../api/v2/system/info')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { formatFileSize } from '../utils/format'
|
import { formatFileSize } from '../utils/format'
|
||||||
import { getProxyTraffic } from '../api/proxy'
|
import { getProxyTraffic } from '../api/proxy'
|
||||||
|
import type { TrafficResponse } from '../types/proxy'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
proxyName: string
|
proxyName: string
|
||||||
@@ -71,41 +72,24 @@ const chartData = ref<
|
|||||||
>([])
|
>([])
|
||||||
const maxVal = ref(0)
|
const maxVal = ref(0)
|
||||||
|
|
||||||
const processData = (trafficIn: number[], trafficOut: number[]) => {
|
const formatDateLabel = (date: string) => {
|
||||||
// Ensure we have arrays and reverse them (server returns newest first)
|
const parts = date.split('-')
|
||||||
const inArr = [...(trafficIn || [])].reverse()
|
if (parts.length !== 3) return date
|
||||||
const outArr = [...(trafficOut || [])].reverse()
|
return `${Number(parts[1])}-${Number(parts[2])}`
|
||||||
|
}
|
||||||
|
|
||||||
// Pad with zeros if less than 7 days
|
const processData = (history: TrafficResponse['history'] = []) => {
|
||||||
while (inArr.length < 7) inArr.unshift(0)
|
const points = history || []
|
||||||
while (outArr.length < 7) outArr.unshift(0)
|
const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
|
||||||
|
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
|
||||||
// Slice to last 7 entries just in case
|
|
||||||
const finalIn = inArr.slice(-7)
|
|
||||||
const finalOut = outArr.slice(-7)
|
|
||||||
|
|
||||||
// Calculate dates (last 7 days ending today)
|
|
||||||
const dates: string[] = []
|
|
||||||
const d = new Date()
|
|
||||||
d.setDate(d.getDate() - 6)
|
|
||||||
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
|
|
||||||
d.setDate(d.getDate() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find max value for scaling
|
|
||||||
const maxIn = Math.max(...finalIn)
|
|
||||||
const maxOut = Math.max(...finalOut)
|
|
||||||
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
|
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
|
||||||
|
|
||||||
// Build chart data
|
chartData.value = points.map((item) => ({
|
||||||
chartData.value = dates.map((date, i) => ({
|
date: formatDateLabel(item.date),
|
||||||
date,
|
in: item.trafficIn,
|
||||||
in: finalIn[i],
|
out: item.trafficOut,
|
||||||
out: finalOut[i],
|
inPercent: (item.trafficIn / maxVal.value) * 100,
|
||||||
inPercent: (finalIn[i] / maxVal.value) * 100,
|
outPercent: (item.trafficOut / maxVal.value) * 100,
|
||||||
outPercent: (finalOut[i] / maxVal.value) * 100,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +97,7 @@ const fetchData = () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
getProxyTraffic(props.proxyName)
|
getProxyTraffic(props.proxyName)
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
processData(json.trafficIn, json.trafficOut)
|
processData(json.history)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export interface ClientInfoData {
|
|||||||
wireProtocol?: string
|
wireProtocol?: string
|
||||||
hostname: string
|
hostname: string
|
||||||
clientIP?: string
|
clientIP?: string
|
||||||
metas?: Record<string, string>
|
|
||||||
firstConnectedAt: number
|
firstConnectedAt: number
|
||||||
lastConnectedAt: number
|
lastConnectedAt: number
|
||||||
disconnectedAt?: number
|
disconnectedAt?: number
|
||||||
|
|||||||
@@ -28,24 +28,102 @@ export interface ProxyListV2Params {
|
|||||||
|
|
||||||
export interface ProxyV2Info {
|
export interface ProxyV2Info {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
|
||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
spec: any
|
spec: ProxyV2Spec
|
||||||
status: ProxyV2Status
|
status: ProxyV2Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2BaseSpec {
|
||||||
|
annotations?: Record<string, string>
|
||||||
|
metadatas?: Record<string, string>
|
||||||
|
transport?: {
|
||||||
|
useEncryption: boolean
|
||||||
|
useCompression: boolean
|
||||||
|
bandwidthLimit: string
|
||||||
|
bandwidthLimitMode: string
|
||||||
|
}
|
||||||
|
loadBalancer?: {
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2TCPBlock extends ProxyV2BaseSpec {
|
||||||
|
remotePort?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2UDPBlock extends ProxyV2BaseSpec {
|
||||||
|
remotePort?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2HTTPBlock extends ProxyV2BaseSpec {
|
||||||
|
customDomains?: string[]
|
||||||
|
subdomain?: string
|
||||||
|
locations?: string[]
|
||||||
|
hostHeaderRewrite?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2HTTPSBlock extends ProxyV2BaseSpec {
|
||||||
|
customDomains?: string[]
|
||||||
|
subdomain?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyV2TCPMuxBlock extends ProxyV2BaseSpec {
|
||||||
|
customDomains?: string[]
|
||||||
|
subdomain?: string
|
||||||
|
multiplexer?: string
|
||||||
|
routeByHTTPUser?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyV2STCPBlock = ProxyV2BaseSpec
|
||||||
|
|
||||||
|
export type ProxyV2SUDPBlock = ProxyV2BaseSpec
|
||||||
|
|
||||||
|
export type ProxyV2XTCPBlock = ProxyV2BaseSpec
|
||||||
|
|
||||||
|
export interface ProxyV2SpecBlocks {
|
||||||
|
tcp: ProxyV2TCPBlock
|
||||||
|
udp: ProxyV2UDPBlock
|
||||||
|
http: ProxyV2HTTPBlock
|
||||||
|
https: ProxyV2HTTPSBlock
|
||||||
|
tcpmux: ProxyV2TCPMuxBlock
|
||||||
|
stcp: ProxyV2STCPBlock
|
||||||
|
sudp: ProxyV2SUDPBlock
|
||||||
|
xtcp: ProxyV2XTCPBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyV2Type = keyof ProxyV2SpecBlocks
|
||||||
|
|
||||||
|
type ProxyV2SpecFor<T extends ProxyV2Type> = {
|
||||||
|
type: T
|
||||||
|
} & {
|
||||||
|
[K in T]: ProxyV2SpecBlocks[K]
|
||||||
|
} & {
|
||||||
|
[K in Exclude<ProxyV2Type, T>]?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyV2Spec = {
|
||||||
|
[T in ProxyV2Type]: ProxyV2SpecFor<T>
|
||||||
|
}[ProxyV2Type]
|
||||||
|
|
||||||
export interface ProxyV2Status {
|
export interface ProxyV2Status {
|
||||||
phase: string
|
phase: 'online' | 'offline'
|
||||||
todayTrafficIn: number
|
todayTrafficIn: number
|
||||||
todayTrafficOut: number
|
todayTrafficOut: number
|
||||||
curConns: number
|
curConns: number
|
||||||
lastStartTime: string
|
lastStartAt?: number
|
||||||
lastCloseTime: string
|
lastCloseAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrafficResponse {
|
export interface TrafficResponse {
|
||||||
name: string
|
name: string
|
||||||
trafficIn: number[]
|
unit: 'bytes'
|
||||||
trafficOut: number[]
|
granularity: 'day'
|
||||||
|
history: TrafficPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficPoint {
|
||||||
|
date: string
|
||||||
|
trafficIn: number
|
||||||
|
trafficOut: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
version: string
|
version: string
|
||||||
|
config: ServerInfoConfig
|
||||||
|
status: ServerInfoStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerInfoConfig {
|
||||||
bindPort: number
|
bindPort: number
|
||||||
vhostHTTPPort: number
|
vhostHTTPPort: number
|
||||||
vhostHTTPSPort: number
|
vhostHTTPSPort: number
|
||||||
@@ -12,8 +17,9 @@ export interface ServerInfo {
|
|||||||
heartbeatTimeout: number
|
heartbeatTimeout: number
|
||||||
allowPortsStr: string
|
allowPortsStr: string
|
||||||
tlsForce: boolean
|
tlsForce: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// Stats
|
export interface ServerInfoStatus {
|
||||||
totalTrafficIn: number
|
totalTrafficIn: number
|
||||||
totalTrafficOut: number
|
totalTrafficOut: number
|
||||||
curConns: number
|
curConns: number
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export class Client {
|
|||||||
wireProtocol: string
|
wireProtocol: string
|
||||||
hostname: string
|
hostname: string
|
||||||
ip: string
|
ip: string
|
||||||
metas: Map<string, string>
|
|
||||||
firstConnectedAt: Date
|
firstConnectedAt: Date
|
||||||
lastConnectedAt: Date
|
lastConnectedAt: Date
|
||||||
disconnectedAt?: Date
|
disconnectedAt?: Date
|
||||||
@@ -26,12 +25,6 @@ export class Client {
|
|||||||
this.wireProtocol = data.wireProtocol || ''
|
this.wireProtocol = data.wireProtocol || ''
|
||||||
this.hostname = data.hostname
|
this.hostname = data.hostname
|
||||||
this.ip = data.clientIP || ''
|
this.ip = data.clientIP || ''
|
||||||
this.metas = new Map<string, string>()
|
|
||||||
if (data.metas) {
|
|
||||||
for (const [key, value] of Object.entries(data.metas)) {
|
|
||||||
this.metas.set(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
|
this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
|
||||||
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
|
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
|
||||||
if (data.disconnectedAt && data.disconnectedAt > 0) {
|
if (data.disconnectedAt && data.disconnectedAt > 0) {
|
||||||
@@ -52,10 +45,6 @@ export class Client {
|
|||||||
return this.runID
|
return this.runID
|
||||||
}
|
}
|
||||||
|
|
||||||
get shortRunId(): string {
|
|
||||||
return this.runID.substring(0, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
get wireProtocolLabel(): string {
|
get wireProtocolLabel(): string {
|
||||||
if (!this.wireProtocol) return ''
|
if (!this.wireProtocol) return ''
|
||||||
return `Protocol ${this.wireProtocol}`
|
return `Protocol ${this.wireProtocol}`
|
||||||
@@ -73,28 +62,4 @@ export class Client {
|
|||||||
if (!this.disconnectedAt) return ''
|
if (!this.disconnectedAt) return ''
|
||||||
return formatDistanceToNow(this.disconnectedAt)
|
return formatDistanceToNow(this.disconnectedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
get statusColor(): string {
|
|
||||||
return this.online ? 'success' : 'danger'
|
|
||||||
}
|
|
||||||
|
|
||||||
get metasArray(): Array<{ key: string; value: string }> {
|
|
||||||
const arr: Array<{ key: string; value: string }> = []
|
|
||||||
this.metas.forEach((value, key) => {
|
|
||||||
arr.push({ key, value })
|
|
||||||
})
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
|
|
||||||
matchesFilter(searchText: string): boolean {
|
|
||||||
const search = searchText.toLowerCase()
|
|
||||||
return (
|
|
||||||
this.key.toLowerCase().includes(search) ||
|
|
||||||
this.user.toLowerCase().includes(search) ||
|
|
||||||
this.clientID.toLowerCase().includes(search) ||
|
|
||||||
this.runID.toLowerCase().includes(search) ||
|
|
||||||
this.wireProtocol.toLowerCase().includes(search) ||
|
|
||||||
this.hostname.toLowerCase().includes(search)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ export function formatDistanceToNow(date: Date): string {
|
|||||||
return Math.floor(seconds) + ' seconds ago'
|
return Math.floor(seconds) + ' seconds ago'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatUnixSeconds(seconds?: number): string {
|
||||||
|
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return ''
|
||||||
|
|
||||||
|
const date = new Date(seconds * 1000)
|
||||||
|
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
||||||
if (bytes === 0) return '0 B'
|
if (bytes === 0) return '0 B'
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ import {
|
|||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import ProxyCard from '../components/ProxyCard.vue'
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
import type { ProxyStatsInfo } from '../types/proxy'
|
import type { ProxyStatsInfo } from '../types/proxy'
|
||||||
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -183,16 +184,9 @@ const total = ref(0)
|
|||||||
let requestSeq = 0
|
let requestSeq = 0
|
||||||
let searchDebounceTimer: number | null = null
|
let searchDebounceTimer: number | null = null
|
||||||
|
|
||||||
type ServerInfoLite = {
|
let serverInfoPromise: Promise<ServerInfo> | null = null
|
||||||
vhostHTTPPort: number
|
|
||||||
vhostHTTPSPort: number
|
|
||||||
tcpmuxHTTPConnectPort: number
|
|
||||||
subdomainHost: string
|
|
||||||
}
|
|
||||||
|
|
||||||
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||||
|
|
||||||
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
|
||||||
if (!serverInfoPromise) {
|
if (!serverInfoPromise) {
|
||||||
serverInfoPromise = getServerInfo().catch((err) => {
|
serverInfoPromise = getServerInfo().catch((err) => {
|
||||||
serverInfoPromise = null
|
serverInfoPromise = null
|
||||||
@@ -232,25 +226,33 @@ const convertProxy = async (
|
|||||||
}
|
}
|
||||||
if (type === 'http') {
|
if (type === 'http') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPPort) {
|
if (info && info.config.vhostHTTPPort) {
|
||||||
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
return new HTTPProxy(
|
||||||
|
proxy,
|
||||||
|
info.config.vhostHTTPPort,
|
||||||
|
info.config.subdomainHost,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'https') {
|
if (type === 'https') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPSPort) {
|
if (info && info.config.vhostHTTPSPort) {
|
||||||
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
return new HTTPSProxy(
|
||||||
|
proxy,
|
||||||
|
info.config.vhostHTTPSPort,
|
||||||
|
info.config.subdomainHost,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'tcpmux') {
|
if (type === 'tcpmux') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.tcpmuxHTTPConnectPort) {
|
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||||
return new TCPMuxProxy(
|
return new TCPMuxProxy(
|
||||||
proxy,
|
proxy,
|
||||||
info.tcpmuxHTTPConnectPort,
|
info.config.tcpmuxHTTPConnectPort,
|
||||||
info.subdomainHost,
|
info.config.subdomainHost,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ import {
|
|||||||
} from '../api/proxy'
|
} from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import type { ProxyStatsInfo } from '../types/proxy'
|
import type { ProxyStatsInfo } from '../types/proxy'
|
||||||
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -133,15 +134,9 @@ let searchDebounceTimer: number | null = null
|
|||||||
|
|
||||||
// Server info cache - cache the Promise itself so concurrent first calls
|
// Server info cache - cache the Promise itself so concurrent first calls
|
||||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||||
type ServerInfoLite = {
|
let serverInfoPromise: Promise<ServerInfo> | null = null
|
||||||
vhostHTTPPort: number
|
|
||||||
vhostHTTPSPort: number
|
|
||||||
tcpmuxHTTPConnectPort: number
|
|
||||||
subdomainHost: string
|
|
||||||
}
|
|
||||||
let serverInfoPromise: Promise<ServerInfoLite> | null = null
|
|
||||||
|
|
||||||
const fetchServerInfo = (): Promise<ServerInfoLite> => {
|
const fetchServerInfo = (): Promise<ServerInfo> => {
|
||||||
if (!serverInfoPromise) {
|
if (!serverInfoPromise) {
|
||||||
serverInfoPromise = getServerInfo().catch((err) => {
|
serverInfoPromise = getServerInfo().catch((err) => {
|
||||||
// Allow retry after failure
|
// Allow retry after failure
|
||||||
@@ -164,25 +159,33 @@ const convertProxy = async (
|
|||||||
}
|
}
|
||||||
if (type === 'http') {
|
if (type === 'http') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPPort) {
|
if (info && info.config.vhostHTTPPort) {
|
||||||
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
|
return new HTTPProxy(
|
||||||
|
proxy,
|
||||||
|
info.config.vhostHTTPPort,
|
||||||
|
info.config.subdomainHost,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'https') {
|
if (type === 'https') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.vhostHTTPSPort) {
|
if (info && info.config.vhostHTTPSPort) {
|
||||||
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
|
return new HTTPSProxy(
|
||||||
|
proxy,
|
||||||
|
info.config.vhostHTTPSPort,
|
||||||
|
info.config.subdomainHost,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (type === 'tcpmux') {
|
if (type === 'tcpmux') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.tcpmuxHTTPConnectPort) {
|
if (info && info.config.tcpmuxHTTPConnectPort) {
|
||||||
return new TCPMuxProxy(
|
return new TCPMuxProxy(
|
||||||
proxy,
|
proxy,
|
||||||
info.tcpmuxHTTPConnectPort,
|
info.config.tcpmuxHTTPConnectPort,
|
||||||
info.subdomainHost,
|
info.config.subdomainHost,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ import {
|
|||||||
Tickets,
|
Tickets,
|
||||||
Location,
|
Location,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { getProxyByName } from '../api/proxy'
|
import { getProxyByNameV2 } from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import {
|
import {
|
||||||
BaseProxy,
|
BaseProxy,
|
||||||
@@ -254,6 +254,7 @@ import {
|
|||||||
SUDPProxy,
|
SUDPProxy,
|
||||||
} from '../utils/proxy'
|
} from '../utils/proxy'
|
||||||
import Traffic from '../components/Traffic.vue'
|
import Traffic from '../components/Traffic.vue'
|
||||||
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -275,12 +276,7 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let serverInfo: {
|
let serverInfo: ServerInfo | null = null
|
||||||
vhostHTTPPort: number
|
|
||||||
vhostHTTPSPort: number
|
|
||||||
tcpmuxHTTPConnectPort: number
|
|
||||||
subdomainHost: string
|
|
||||||
} | null = null
|
|
||||||
|
|
||||||
const clientLink = computed(() => {
|
const clientLink = computed(() => {
|
||||||
if (!proxy.value) return ''
|
if (!proxy.value) return ''
|
||||||
@@ -365,27 +361,32 @@ const fetchProxy = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getProxyByName(name)
|
const data = await getProxyByNameV2(name)
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
const type = data.conf?.type || ''
|
const config = info.config
|
||||||
|
const type = data.type || data.conf?.type || ''
|
||||||
|
|
||||||
if (type === 'tcp') {
|
if (type === 'tcp') {
|
||||||
proxy.value = new TCPProxy(data)
|
proxy.value = new TCPProxy(data)
|
||||||
} else if (type === 'udp') {
|
} else if (type === 'udp') {
|
||||||
proxy.value = new UDPProxy(data)
|
proxy.value = new UDPProxy(data)
|
||||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
} else if (type === 'http' && config.vhostHTTPPort) {
|
||||||
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
|
proxy.value = new HTTPProxy(
|
||||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
data,
|
||||||
|
config.vhostHTTPPort,
|
||||||
|
config.subdomainHost,
|
||||||
|
)
|
||||||
|
} else if (type === 'https' && config.vhostHTTPSPort) {
|
||||||
proxy.value = new HTTPSProxy(
|
proxy.value = new HTTPSProxy(
|
||||||
data,
|
data,
|
||||||
info.vhostHTTPSPort,
|
config.vhostHTTPSPort,
|
||||||
info.subdomainHost,
|
config.subdomainHost,
|
||||||
)
|
)
|
||||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
} else if (type === 'tcpmux' && config.tcpmuxHTTPConnectPort) {
|
||||||
proxy.value = new TCPMuxProxy(
|
proxy.value = new TCPMuxProxy(
|
||||||
data,
|
data,
|
||||||
info.tcpmuxHTTPConnectPort,
|
config.tcpmuxHTTPConnectPort,
|
||||||
info.subdomainHost,
|
config.subdomainHost,
|
||||||
)
|
)
|
||||||
} else if (type === 'stcp') {
|
} else if (type === 'stcp') {
|
||||||
proxy.value = new STCPProxy(data)
|
proxy.value = new STCPProxy(data)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<el-col :xs="24" :sm="12" :lg="6">
|
<el-col :xs="24" :sm="12" :lg="6">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Clients"
|
label="Clients"
|
||||||
:value="data.clientCounts"
|
:value="data.status.clientCounts"
|
||||||
type="clients"
|
type="clients"
|
||||||
subtitle="Connected clients"
|
subtitle="Connected clients"
|
||||||
to="/clients"
|
to="/clients"
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<el-col :xs="24" :sm="12" :lg="6">
|
<el-col :xs="24" :sm="12" :lg="6">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Proxies"
|
label="Proxies"
|
||||||
:value="data.proxyCounts"
|
:value="proxyCounts"
|
||||||
type="proxies"
|
type="proxies"
|
||||||
subtitle="Active proxies"
|
subtitle="Active proxies"
|
||||||
to="/proxies/tcp"
|
to="/proxies/tcp"
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<el-col :xs="24" :sm="12" :lg="6">
|
<el-col :xs="24" :sm="12" :lg="6">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Connections"
|
label="Connections"
|
||||||
:value="data.curConns"
|
:value="data.status.curConns"
|
||||||
type="connections"
|
type="connections"
|
||||||
subtitle="Current connections"
|
subtitle="Current connections"
|
||||||
/>
|
/>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<div class="traffic-info">
|
<div class="traffic-info">
|
||||||
<div class="label">Inbound</div>
|
<div class="label">Inbound</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
{{ formatFileSize(data.totalTrafficIn) }}
|
{{ formatFileSize(data.status.totalTrafficIn) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<div class="traffic-info">
|
<div class="traffic-info">
|
||||||
<div class="label">Outbound</div>
|
<div class="label">Outbound</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
{{ formatFileSize(data.totalTrafficOut) }}
|
{{ formatFileSize(data.status.totalTrafficOut) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="proxy-types-grid">
|
<div class="proxy-types-grid">
|
||||||
<div
|
<div
|
||||||
v-for="(count, type) in data.proxyTypeCounts"
|
v-for="(count, type) in data.status.proxyTypeCount"
|
||||||
:key="type"
|
:key="type"
|
||||||
class="proxy-type-item"
|
class="proxy-type-item"
|
||||||
v-show="count > 0"
|
v-show="count > 0"
|
||||||
@@ -109,51 +109,51 @@
|
|||||||
<div class="config-grid">
|
<div class="config-grid">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="config-label">Bind Port</span>
|
<span class="config-label">Bind Port</span>
|
||||||
<span class="config-value">{{ data.bindPort }}</span>
|
<span class="config-value">{{ data.config.bindPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.kcpBindPort != 0">
|
<div class="config-item" v-if="data.config.kcpBindPort != 0">
|
||||||
<span class="config-label">KCP Port</span>
|
<span class="config-label">KCP Port</span>
|
||||||
<span class="config-value">{{ data.kcpBindPort }}</span>
|
<span class="config-value">{{ data.config.kcpBindPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.quicBindPort != 0">
|
<div class="config-item" v-if="data.config.quicBindPort != 0">
|
||||||
<span class="config-label">QUIC Port</span>
|
<span class="config-label">QUIC Port</span>
|
||||||
<span class="config-value">{{ data.quicBindPort }}</span>
|
<span class="config-value">{{ data.config.quicBindPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.vhostHTTPPort != 0">
|
<div class="config-item" v-if="data.config.vhostHTTPPort != 0">
|
||||||
<span class="config-label">HTTP Port</span>
|
<span class="config-label">HTTP Port</span>
|
||||||
<span class="config-value">{{ data.vhostHTTPPort }}</span>
|
<span class="config-value">{{ data.config.vhostHTTPPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.vhostHTTPSPort != 0">
|
<div class="config-item" v-if="data.config.vhostHTTPSPort != 0">
|
||||||
<span class="config-label">HTTPS Port</span>
|
<span class="config-label">HTTPS Port</span>
|
||||||
<span class="config-value">{{ data.vhostHTTPSPort }}</span>
|
<span class="config-value">{{ data.config.vhostHTTPSPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0">
|
<div class="config-item" v-if="data.config.tcpmuxHTTPConnectPort != 0">
|
||||||
<span class="config-label">TCPMux Port</span>
|
<span class="config-label">TCPMux Port</span>
|
||||||
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span>
|
<span class="config-value">{{ data.config.tcpmuxHTTPConnectPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.subdomainHost != ''">
|
<div class="config-item" v-if="data.config.subdomainHost != ''">
|
||||||
<span class="config-label">Subdomain Host</span>
|
<span class="config-label">Subdomain Host</span>
|
||||||
<span class="config-value">{{ data.subdomainHost }}</span>
|
<span class="config-value">{{ data.config.subdomainHost }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="config-label">Max Pool Count</span>
|
<span class="config-label">Max Pool Count</span>
|
||||||
<span class="config-value">{{ data.maxPoolCount }}</span>
|
<span class="config-value">{{ data.config.maxPoolCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="config-label">Max Ports/Client</span>
|
<span class="config-label">Max Ports/Client</span>
|
||||||
<span class="config-value">{{ data.maxPortsPerClient }}</span>
|
<span class="config-value">{{ maxPortsPerClientLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.allowPortsStr != ''">
|
<div class="config-item" v-if="data.config.allowPortsStr != ''">
|
||||||
<span class="config-label">Allow Ports</span>
|
<span class="config-label">Allow Ports</span>
|
||||||
<span class="config-value">{{ data.allowPortsStr }}</span>
|
<span class="config-value">{{ data.config.allowPortsStr }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.tlsForce">
|
<div class="config-item" v-if="data.config.tlsForce">
|
||||||
<span class="config-label">TLS Force</span>
|
<span class="config-label">TLS Force</span>
|
||||||
<el-tag size="small" type="warning">Enabled</el-tag>
|
<el-tag size="small" type="warning">Enabled</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="config-label">Heartbeat Timeout</span>
|
<span class="config-label">Heartbeat Timeout</span>
|
||||||
<span class="config-value">{{ data.heartbeatTimeout }}s</span>
|
<span class="config-value">{{ data.config.heartbeatTimeout }}s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -167,69 +167,59 @@ import { formatFileSize } from '../utils/format'
|
|||||||
import { Download, Upload } from '@element-plus/icons-vue'
|
import { Download, Upload } from '@element-plus/icons-vue'
|
||||||
import StatCard from '../components/StatCard.vue'
|
import StatCard from '../components/StatCard.vue'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
const data = ref({
|
const data = ref<ServerInfo>({
|
||||||
version: '',
|
version: '',
|
||||||
bindPort: 0,
|
config: {
|
||||||
kcpBindPort: 0,
|
bindPort: 0,
|
||||||
quicBindPort: 0,
|
kcpBindPort: 0,
|
||||||
vhostHTTPPort: 0,
|
quicBindPort: 0,
|
||||||
vhostHTTPSPort: 0,
|
vhostHTTPPort: 0,
|
||||||
tcpmuxHTTPConnectPort: 0,
|
vhostHTTPSPort: 0,
|
||||||
subdomainHost: '',
|
tcpmuxHTTPConnectPort: 0,
|
||||||
maxPoolCount: 0,
|
subdomainHost: '',
|
||||||
maxPortsPerClient: '',
|
maxPoolCount: 0,
|
||||||
allowPortsStr: '',
|
maxPortsPerClient: 0,
|
||||||
tlsForce: false,
|
allowPortsStr: '',
|
||||||
heartbeatTimeout: 0,
|
tlsForce: false,
|
||||||
clientCounts: 0,
|
heartbeatTimeout: 0,
|
||||||
curConns: 0,
|
},
|
||||||
proxyCounts: 0,
|
status: {
|
||||||
totalTrafficIn: 0,
|
clientCounts: 0,
|
||||||
totalTrafficOut: 0,
|
curConns: 0,
|
||||||
proxyTypeCounts: {} as Record<string, number>,
|
totalTrafficIn: 0,
|
||||||
|
totalTrafficOut: 0,
|
||||||
|
proxyTypeCount: {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActiveProxies = computed(() => {
|
const hasActiveProxies = computed(() => {
|
||||||
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)
|
return Object.values(data.value.status.proxyTypeCount).some((c) => c > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const proxyCounts = computed(() => {
|
||||||
|
return Object.values(data.value.status.proxyTypeCount).reduce(
|
||||||
|
(sum, count) => sum + (count || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxPortsPerClientLabel = computed(() => {
|
||||||
|
const value = data.value.config.maxPortsPerClient
|
||||||
|
return value === 0 ? 'no limit' : String(value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatTrafficTotal = () => {
|
const formatTrafficTotal = () => {
|
||||||
const total = data.value.totalTrafficIn + data.value.totalTrafficOut
|
const total =
|
||||||
|
data.value.status.totalTrafficIn + data.value.status.totalTrafficOut
|
||||||
return formatFileSize(total)
|
return formatFileSize(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const json = await getServerInfo()
|
const json = await getServerInfo()
|
||||||
data.value.version = json.version
|
data.value = json
|
||||||
data.value.bindPort = json.bindPort
|
|
||||||
data.value.kcpBindPort = json.kcpBindPort
|
|
||||||
data.value.quicBindPort = json.quicBindPort
|
|
||||||
data.value.vhostHTTPPort = json.vhostHTTPPort
|
|
||||||
data.value.vhostHTTPSPort = json.vhostHTTPSPort
|
|
||||||
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
|
|
||||||
data.value.subdomainHost = json.subdomainHost
|
|
||||||
data.value.maxPoolCount = json.maxPoolCount
|
|
||||||
data.value.maxPortsPerClient = String(json.maxPortsPerClient)
|
|
||||||
if (data.value.maxPortsPerClient == '0') {
|
|
||||||
data.value.maxPortsPerClient = 'no limit'
|
|
||||||
}
|
|
||||||
data.value.allowPortsStr = json.allowPortsStr
|
|
||||||
data.value.tlsForce = json.tlsForce
|
|
||||||
data.value.heartbeatTimeout = json.heartbeatTimeout
|
|
||||||
data.value.clientCounts = json.clientCounts
|
|
||||||
data.value.curConns = json.curConns
|
|
||||||
data.value.totalTrafficIn = json.totalTrafficIn
|
|
||||||
data.value.totalTrafficOut = json.totalTrafficOut
|
|
||||||
data.value.proxyTypeCounts = json.proxyTypeCount || {}
|
|
||||||
|
|
||||||
data.value.proxyCounts = 0
|
|
||||||
if (json.proxyTypeCount != null) {
|
|
||||||
Object.values(json.proxyTypeCount).forEach((count: any) => {
|
|
||||||
data.value.proxyCounts += count || 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user