forked from Mxmilu666/frp
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd26c6961 | ||
|
|
c8c1e5116c | ||
|
|
4ec8de973f | ||
|
|
5bfcea3d0c | ||
|
|
0a1b4ab21f | ||
|
|
5f575b8442 | ||
|
|
a1348cdf00 | ||
|
|
2f5e1f7945 | ||
|
|
22ae8166d3 | ||
|
|
af6bc6369d |
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
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
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
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.TAG_FRPS }}
|
${{ env.TAG_FRPS }}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ all: build
|
|||||||
build: app
|
build: app
|
||||||
|
|
||||||
app:
|
app:
|
||||||
@set -e; $(foreach n, $(os-archs), \
|
@$(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); \
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
[](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)
|
||||||
@@ -12,16 +13,6 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
## Recall.ai - API for meeting recordings
|
|
||||||
|
|
||||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
|
||||||
|
|
||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
@@ -39,6 +30,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
|||||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Recall.ai - API for meeting recordings
|
||||||
|
|
||||||
|
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||||
|
|
||||||
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
|
|
||||||
|
</div>
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## What is frp?
|
## What is frp?
|
||||||
|
|||||||
20
README_zh.md
20
README_zh.md
@@ -15,16 +15,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
## Recall.ai - API for meeting recordings
|
|
||||||
|
|
||||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
|
||||||
|
|
||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
@@ -42,6 +32,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
|||||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Recall.ai - API for meeting recordings
|
||||||
|
|
||||||
|
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||||
|
|
||||||
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
|
|
||||||
|
</div>
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## 为什么使用 frp ?
|
## 为什么使用 frp ?
|
||||||
|
|||||||
12
Release.md
12
Release.md
@@ -1,5 +1,9 @@
|
|||||||
## Fixes
|
## Features
|
||||||
|
|
||||||
* HTTP vhost servers no longer support HTTP/1.1 `Upgrade: h2c` requests. Cleartext HTTP/2 prior-knowledge remains supported.
|
* `transport.wireProtocol = "v2"` now also applies to UDP-based proxy payloads, including ordinary UDP and SUDP, so their payload framing is consistent with the selected wire protocol.
|
||||||
* Fixed control-session replacement leaks when frpc reconnects through a half-open TCP multiplexed connection.
|
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
|
||||||
* Fixed an SSH tunnel gateway panic when handling malformed exec requests.
|
* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
|
||||||
|
|
||||||
|
## Compatibility Notes
|
||||||
|
|
||||||
|
* When enabling `transport.wireProtocol = "v2"` for SUDP, upgrade both the proxy and visitor frpc instances first, or keep them on `v1` until both sides are upgraded.
|
||||||
|
|||||||
27
go.mod
27
go.mod
@@ -4,8 +4,8 @@ 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.18.0
|
github.com/coreos/go-oidc/v3 v3.14.1
|
||||||
github.com/fatedier/golib v0.8.1
|
github.com/fatedier/golib v0.7.0
|
||||||
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,6 +13,7 @@ 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
|
||||||
@@ -25,11 +26,11 @@ require (
|
|||||||
github.com/tidwall/gjson v1.17.1
|
github.com/tidwall/gjson v1.17.1
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13
|
github.com/xtaci/kcp-go/v5 v5.6.13
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.28.0
|
||||||
golang.org/x/sync v0.22.0
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.42.0
|
||||||
golang.org/x/time v0.10.0
|
golang.org/x/time v0.10.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
@@ -43,7 +44,7 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
@@ -52,6 +53,9 @@ 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
|
||||||
@@ -63,10 +67,11 @@ 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.37.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.47.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
|||||||
58
go.sum
58
go.sum
@@ -11,8 +11,8 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
|
|||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -20,12 +20,12 @@ 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.8.1 h1:pHcIu0zAcZ6VTkO1dW/meelCGN5nem52DKCBY7cUvyA=
|
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
||||||
github.com/fatedier/golib v0.8.1/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
github.com/fatedier/golib v0.7.0/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.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
@@ -78,6 +78,14 @@ 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=
|
||||||
@@ -138,6 +146,8 @@ 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=
|
||||||
@@ -149,30 +159,30 @@ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -180,14 +190,14 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -195,8 +205,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
|
|||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
|||||||
@@ -394,10 +394,6 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,31 +417,6 @@ func LoadClientConfig(path string, strict bool) (
|
|||||||
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateNoDuplicateNames rejects proxies or visitors that share a name. They are
|
|
||||||
// keyed by name in the config sources, so a duplicate would otherwise be silently
|
|
||||||
// overwritten and never started, with no error or log.
|
|
||||||
func validateNoDuplicateNames(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
|
|
||||||
proxyNames := make(map[string]struct{}, len(proxies))
|
|
||||||
for _, p := range proxies {
|
|
||||||
name := p.GetBaseConfig().Name
|
|
||||||
if _, ok := proxyNames[name]; ok {
|
|
||||||
return fmt.Errorf("proxy name [%s] is duplicated", name)
|
|
||||||
}
|
|
||||||
proxyNames[name] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
visitorNames := make(map[string]struct{}, len(visitors))
|
|
||||||
for _, v := range visitors {
|
|
||||||
name := v.GetBaseConfig().Name
|
|
||||||
if _, ok := visitorNames[name]; ok {
|
|
||||||
return fmt.Errorf("visitor name [%s] is duplicated", name)
|
|
||||||
}
|
|
||||||
visitorNames[name] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
||||||
proxyCfgs := proxies
|
proxyCfgs := proxies
|
||||||
for _, c := range proxyCfgs {
|
for _, c := range proxyCfgs {
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -464,111 +462,6 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
|
|||||||
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadClientConfigResult_DuplicateNames(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
content string
|
|
||||||
errSubstr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "duplicate proxy names",
|
|
||||||
content: `
|
|
||||||
serverAddr = "127.0.0.1"
|
|
||||||
serverPort = 7000
|
|
||||||
|
|
||||||
[[proxies]]
|
|
||||||
name = "dup"
|
|
||||||
type = "tcp"
|
|
||||||
localPort = 22
|
|
||||||
remotePort = 6000
|
|
||||||
|
|
||||||
[[proxies]]
|
|
||||||
name = "dup"
|
|
||||||
type = "tcp"
|
|
||||||
localPort = 3306
|
|
||||||
remotePort = 6001
|
|
||||||
`,
|
|
||||||
errSubstr: "proxy name [dup] is duplicated",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "duplicate visitor names",
|
|
||||||
content: `
|
|
||||||
serverAddr = "127.0.0.1"
|
|
||||||
serverPort = 7000
|
|
||||||
|
|
||||||
[[visitors]]
|
|
||||||
name = "dup"
|
|
||||||
type = "stcp"
|
|
||||||
serverName = "a"
|
|
||||||
secretKey = "secret"
|
|
||||||
bindPort = 9001
|
|
||||||
|
|
||||||
[[visitors]]
|
|
||||||
name = "dup"
|
|
||||||
type = "stcp"
|
|
||||||
serverName = "b"
|
|
||||||
secretKey = "secret"
|
|
||||||
bindPort = 9002
|
|
||||||
`,
|
|
||||||
errSubstr: "visitor name [dup] is duplicated",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unique names",
|
|
||||||
content: `
|
|
||||||
serverAddr = "127.0.0.1"
|
|
||||||
serverPort = 7000
|
|
||||||
|
|
||||||
[[proxies]]
|
|
||||||
name = "p1"
|
|
||||||
type = "tcp"
|
|
||||||
localPort = 22
|
|
||||||
remotePort = 6000
|
|
||||||
|
|
||||||
[[proxies]]
|
|
||||||
name = "p2"
|
|
||||||
type = "tcp"
|
|
||||||
localPort = 3306
|
|
||||||
remotePort = 6001
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same name across proxy and visitor",
|
|
||||||
content: `
|
|
||||||
serverAddr = "127.0.0.1"
|
|
||||||
serverPort = 7000
|
|
||||||
|
|
||||||
[[proxies]]
|
|
||||||
name = "same"
|
|
||||||
type = "tcp"
|
|
||||||
localPort = 22
|
|
||||||
remotePort = 6000
|
|
||||||
|
|
||||||
[[visitors]]
|
|
||||||
name = "same"
|
|
||||||
type = "stcp"
|
|
||||||
serverName = "a"
|
|
||||||
secretKey = "secret"
|
|
||||||
bindPort = 9001
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
path := filepath.Join(t.TempDir(), "frpc.toml")
|
|
||||||
require.NoError(os.WriteFile(path, []byte(tc.content), 0o600))
|
|
||||||
|
|
||||||
_, err := LoadClientConfigResult(path, false)
|
|
||||||
if tc.errSubstr == "" {
|
|
||||||
require.NoError(err)
|
|
||||||
} else {
|
|
||||||
require.ErrorContains(err, tc.errSubstr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
||||||
func TestYAMLEdgeCases(t *testing.T) {
|
func TestYAMLEdgeCases(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ 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 m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
if !data.LastCloseTime.IsZero() &&
|
||||||
|
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())
|
||||||
@@ -104,20 +106,10 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -239,11 +231,9 @@ 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,12 +22,6 @@ 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) {
|
||||||
@@ -49,70 +43,6 @@ 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,8 +41,6 @@ type ProxyStats struct {
|
|||||||
TodayTrafficOut int64
|
TodayTrafficOut int64
|
||||||
LastStartTime string
|
LastStartTime string
|
||||||
LastCloseTime string
|
LastCloseTime string
|
||||||
LastStartAt int64
|
|
||||||
LastCloseAt int64
|
|
||||||
CurConns int64
|
CurConns int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,5 +85,4 @@ 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,24 +15,31 @@
|
|||||||
package nathole
|
package nathole
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/net/stun"
|
"github.com/pion/stun/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
@@ -52,8 +59,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) {
|
||||||
@@ -69,50 +77,82 @@ 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,
|
||||||
client: client,
|
|
||||||
localAddr: conn.LocalAddr(),
|
localAddr: conn.LocalAddr(),
|
||||||
|
messageChan: make(chan *Message, 10),
|
||||||
}, 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
|
||||||
}
|
}
|
||||||
transaction, err := stun.NewBindingTransaction(serverAddr)
|
request, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := c.conn.SetReadDeadline(time.Now().Add(responseTimeout)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
response, err := c.client.Do(transaction)
|
|
||||||
if err != nil {
|
|
||||||
var netErr net.Error
|
|
||||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
||||||
return nil, fmt.Errorf("wait response from stun server timeout")
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &stunResponse{}
|
if err = request.NewTransactionID(); err != nil {
|
||||||
if response.MappedAddr != nil {
|
return nil, err
|
||||||
resp.externalAddr = response.MappedAddr.String()
|
|
||||||
}
|
}
|
||||||
if response.OtherAddr != nil {
|
if _, err := c.conn.WriteTo(request.Raw, serverAddr); err != nil {
|
||||||
resp.otherAddr = response.OtherAddr.String()
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var m stun.Message
|
||||||
|
select {
|
||||||
|
case msg := <-c.messageChan:
|
||||||
|
m.Raw = msg.Body
|
||||||
|
if err := m.Decode(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case <-time.After(responseTimeout):
|
||||||
|
return nil, fmt.Errorf("wait response from stun server timeout")
|
||||||
|
}
|
||||||
|
xorAddrGetter := &stun.XORMappedAddress{}
|
||||||
|
mappedAddrGetter := &stun.MappedAddress{}
|
||||||
|
changedAddrGetter := ChangedAddress{}
|
||||||
|
otherAddrGetter := &stun.OtherAddress{}
|
||||||
|
|
||||||
|
resp := &stunResponse{}
|
||||||
|
if err := mappedAddrGetter.GetFrom(&m); err == nil {
|
||||||
|
resp.externalAddr = mappedAddrGetter.String()
|
||||||
|
}
|
||||||
|
if err := xorAddrGetter.GetFrom(&m); err == nil {
|
||||||
|
resp.externalAddr = xorAddrGetter.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,382 +0,0 @@
|
|||||||
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,8 +18,10 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -46,6 +48,20 @@ 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 {
|
||||||
|
|||||||
@@ -72,15 +72,6 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if connInfo.ProxyProtocolHeader != nil {
|
|
||||||
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
|
|
||||||
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
|
|
||||||
rawConn.Close()
|
|
||||||
tlsConn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libio.Join(tlsConn, rawConn)
|
libio.Join(tlsConn, rawConn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package ssh
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -51,11 +52,6 @@ type tcpipForward struct {
|
|||||||
Port uint32
|
Port uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc4254#section-6.5
|
|
||||||
type execPayload struct {
|
|
||||||
Command string
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc4254#page-16
|
// https://datatracker.ietf.org/doc/html/rfc4254#page-16
|
||||||
type forwardedTCPPayload struct {
|
type forwardedTCPPayload struct {
|
||||||
Addr string
|
Addr string
|
||||||
@@ -313,13 +309,14 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c
|
|||||||
if req.WantReply {
|
if req.WantReply {
|
||||||
_ = req.Reply(true, nil)
|
_ = req.Reply(true, nil)
|
||||||
}
|
}
|
||||||
if req.Type != "exec" {
|
if req.Type != "exec" || len(req.Payload) <= 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
extraPayload, ok := parseExecPayload(req.Payload)
|
end := 4 + binary.BigEndian.Uint32(req.Payload[:4])
|
||||||
if !ok {
|
if len(req.Payload) < int(end) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
extraPayload := string(req.Payload[4:end])
|
||||||
select {
|
select {
|
||||||
case extraPayloadCh <- extraPayload:
|
case extraPayloadCh <- extraPayload:
|
||||||
default:
|
default:
|
||||||
@@ -327,14 +324,6 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseExecPayload(payload []byte) (string, bool) {
|
|
||||||
var msg execPayload
|
|
||||||
if err := ssh.Unmarshal(payload, &msg); err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return msg.Command, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TunnelServer) keepAlive(ch ssh.Channel) {
|
func (s *TunnelServer) keepAlive(ch ssh.Channel) {
|
||||||
tk := time.NewTicker(time.Second * 30)
|
tk := time.NewTicker(time.Second * 30)
|
||||||
defer tk.Stop()
|
defer tk.Stop()
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
// 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 ssh
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
cryptossh "golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseExecPayload(t *testing.T) {
|
|
||||||
payload := cryptossh.Marshal(&execPayload{Command: "tcp --remote_port 6000"})
|
|
||||||
|
|
||||||
got, ok := parseExecPayload(payload)
|
|
||||||
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Equal(t, "tcp --remote_port 6000", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseExecPayloadRejectsMalformedPayloads(t *testing.T) {
|
|
||||||
overflowLength := make([]byte, 5)
|
|
||||||
binary.BigEndian.PutUint32(overflowLength[:4], ^uint32(0))
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
payload []byte
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty",
|
|
||||||
payload: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "short length prefix",
|
|
||||||
payload: []byte{0, 0, 0},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "declared length exceeds remaining payload",
|
|
||||||
payload: []byte{0, 0, 0, 2, 'x'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "overflow length",
|
|
||||||
payload: overflowLength,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
var (
|
|
||||||
got string
|
|
||||||
ok bool
|
|
||||||
)
|
|
||||||
require.NotPanics(t, func() {
|
|
||||||
got, ok = parseExecPayload(tc.payload)
|
|
||||||
})
|
|
||||||
require.False(t, ok)
|
|
||||||
require.Empty(t, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,12 +26,6 @@ type GeneralResponse struct {
|
|||||||
Msg string
|
Msg string
|
||||||
}
|
}
|
||||||
|
|
||||||
type V2Response struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
Data any `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIHandler is a handler function that returns a response object or an error.
|
// APIHandler is a handler function that returns a response object or an error.
|
||||||
type APIHandler func(ctx *Context) (any, error)
|
type APIHandler func(ctx *Context) (any, error)
|
||||||
|
|
||||||
@@ -70,27 +64,3 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
|
|
||||||
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := NewContext(w, r)
|
|
||||||
res, err := handler(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
|
|
||||||
code := http.StatusInternalServerError
|
|
||||||
if e, ok := err.(*Error); ok {
|
|
||||||
code = e.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(code)
|
|
||||||
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ package net
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/hkdf"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -26,6 +25,7 @@ import (
|
|||||||
|
|
||||||
libcrypto "github.com/fatedier/golib/crypto"
|
libcrypto "github.com/fatedier/golib/crypto"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
)
|
)
|
||||||
@@ -335,6 +335,11 @@ func deriveAEADControlKeys(key []byte, algorithm string, transcriptHash []byte)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deriveAEADControlKey(key []byte, algorithm string, transcriptHash []byte, direction string) ([]byte, error) {
|
func deriveAEADControlKey(key []byte, algorithm string, transcriptHash []byte, direction string) ([]byte, error) {
|
||||||
info := aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction
|
info := []byte(aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction)
|
||||||
return hkdf.Key(sha256.New, key, transcriptHash, info, libcrypto.AEADKeySize)
|
reader := hkdf.New(sha256.New, key, transcriptHash, info)
|
||||||
|
out := make([]byte, libcrypto.AEADKeySize)
|
||||||
|
if _, err := io.ReadFull(reader, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,11 +114,5 @@ func TestDeriveAEADControlKeysUsesDistinctDirections(t *testing.T) {
|
|||||||
bytes.Repeat([]byte{0x44}, 32),
|
bytes.Repeat([]byte{0x44}, 32),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []byte{
|
|
||||||
0xa0, 0x58, 0xcd, 0x02, 0x5d, 0x96, 0x98, 0x5f,
|
|
||||||
0xeb, 0xeb, 0xff, 0x79, 0xa1, 0x9f, 0x62, 0xb7,
|
|
||||||
0x15, 0xe0, 0x53, 0x91, 0x3d, 0xfc, 0x74, 0x77,
|
|
||||||
0x05, 0x91, 0x4c, 0x62, 0x4b, 0xf3, 0xd4, 0x95,
|
|
||||||
}, clientToServerKey)
|
|
||||||
require.NotEqual(t, clientToServerKey, serverToClientKey)
|
require.NotEqual(t, clientToServerKey, serverToClientKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,6 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
|
||||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
|
||||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
|
||||||
// frames and close the connection on invalid bytes.
|
|
||||||
conn.PayloadType = websocket.BinaryFrame
|
|
||||||
return ctx, conn, nil
|
return ctx, conn, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,6 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
|||||||
|
|
||||||
muxer := http.NewServeMux()
|
muxer := http.NewServeMux()
|
||||||
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
||||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
|
||||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
|
||||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
|
||||||
// frames and close the connection on invalid bytes.
|
|
||||||
c.PayloadType = websocket.BinaryFrame
|
|
||||||
notifyCh := make(chan struct{})
|
notifyCh := make(chan struct{})
|
||||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||||
close(notifyCh)
|
close(notifyCh)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "0.70.1"
|
var version = "0.69.1"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import (
|
|||||||
|
|
||||||
libio "github.com/fatedier/golib/io"
|
libio "github.com/fatedier/golib/io"
|
||||||
"github.com/fatedier/golib/pool"
|
"github.com/fatedier/golib/pool"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
"golang.org/x/net/http2/h2c"
|
||||||
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
@@ -137,7 +139,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
|
|||||||
_, _ = rw.Write(getNotFoundPageContent())
|
_, _ = rw.Write(getNotFoundPageContent())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rp.proxy = proxy
|
rp.proxy = h2c.NewHandler(proxy, &http2.Server{})
|
||||||
return rp
|
return rp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +1,14 @@
|
|||||||
package vhost
|
package vhost
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHTTPServerProtocols(t *testing.T) {
|
|
||||||
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
|
||||||
protocols := new(http.Protocols)
|
|
||||||
protocols.SetHTTP1(true)
|
|
||||||
protocols.SetUnencryptedHTTP2(true)
|
|
||||||
server := &http.Server{
|
|
||||||
Handler: rp,
|
|
||||||
ReadHeaderTimeout: time.Second,
|
|
||||||
Protocols: protocols,
|
|
||||||
}
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
serveErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
serveErr <- server.Serve(listener)
|
|
||||||
}()
|
|
||||||
defer func() {
|
|
||||||
require.NoError(t, server.Close())
|
|
||||||
require.ErrorIs(t, <-serveErr, http.ErrServerClosed)
|
|
||||||
}()
|
|
||||||
|
|
||||||
require.True(t, server.Protocols.HTTP1())
|
|
||||||
require.True(t, server.Protocols.UnencryptedHTTP2())
|
|
||||||
|
|
||||||
t.Run("HTTP/1.1", func(t *testing.T) {
|
|
||||||
transport := &http.Transport{Protocols: httpProtocols(true, false)}
|
|
||||||
defer transport.CloseIdleConnections()
|
|
||||||
client := &http.Client{Transport: transport}
|
|
||||||
response, err := client.Get("http://" + listener.Addr().String() + "/")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer response.Body.Close()
|
|
||||||
|
|
||||||
require.Equal(t, "HTTP/1.1", response.Proto)
|
|
||||||
require.Equal(t, http.StatusNotFound, response.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("HTTP/2 prior knowledge", func(t *testing.T) {
|
|
||||||
transport := &http.Transport{Protocols: httpProtocols(false, true)}
|
|
||||||
defer transport.CloseIdleConnections()
|
|
||||||
client := &http.Client{Transport: transport}
|
|
||||||
response, err := client.Get("http://" + listener.Addr().String() + "/")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer response.Body.Close()
|
|
||||||
|
|
||||||
require.Equal(t, "HTTP/2.0", response.Proto)
|
|
||||||
require.Equal(t, http.StatusNotFound, response.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("HTTP/1.1 Upgrade h2c", func(t *testing.T) {
|
|
||||||
conn, err := net.Dial("tcp", listener.Addr().String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
_, err = fmt.Fprintf(conn,
|
|
||||||
"GET / HTTP/1.1\r\nHost: %s\r\n"+
|
|
||||||
"Connection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\n"+
|
|
||||||
"HTTP2-Settings: AAMAAABkAAQCAAAAAAIAAAAA\r\n\r\n",
|
|
||||||
listener.Addr())
|
|
||||||
require.NoError(t, err)
|
|
||||||
response, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer response.Body.Close()
|
|
||||||
|
|
||||||
require.NotEqual(t, http.StatusSwitchingProtocols, response.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func httpProtocols(http1, unencryptedHTTP2 bool) *http.Protocols {
|
|
||||||
protocols := new(http.Protocols)
|
|
||||||
protocols.SetHTTP1(http1)
|
|
||||||
protocols.SetUnencryptedHTTP2(unencryptedHTTP2)
|
|
||||||
return protocols
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckRouteAuthByRequest(t *testing.T) {
|
func TestCheckRouteAuthByRequest(t *testing.T) {
|
||||||
rc := &RouteConfig{
|
rc := &RouteConfig{
|
||||||
Username: "alice",
|
Username: "alice",
|
||||||
|
|||||||
@@ -48,17 +48,6 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
||||||
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
||||||
|
|
||||||
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
|
|
||||||
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
|
||||||
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
|
|
||||||
v2EncodedPathRouter.UseEncodedPath()
|
|
||||||
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
|
||||||
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")
|
||||||
subRouter.PathPrefix("/static/").Handler(
|
subRouter.PathPrefix("/static/").Handler(
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -41,311 +40,55 @@ import (
|
|||||||
"github.com/fatedier/frp/server/registry"
|
"github.com/fatedier/frp/server/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ControlID uint64
|
|
||||||
|
|
||||||
var nextControlID atomic.Uint64
|
|
||||||
|
|
||||||
type controlEntry struct {
|
|
||||||
ctl *Control
|
|
||||||
id ControlID
|
|
||||||
// runMu serializes lifecycle and routing decisions for one run ID.
|
|
||||||
// Replacements inherit it; removing the entry releases the manager's reference.
|
|
||||||
runMu *sync.Mutex
|
|
||||||
|
|
||||||
registryOnline bool
|
|
||||||
registryControlID ControlID
|
|
||||||
}
|
|
||||||
|
|
||||||
type ControlManager struct {
|
type ControlManager struct {
|
||||||
// controls indexed by run id
|
// controls indexed by run id
|
||||||
ctlsByRunID map[string]*controlEntry
|
ctlsByRunID map[string]*Control
|
||||||
registry *registry.ClientRegistry
|
|
||||||
closed bool
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewControlManager(clientRegistry *registry.ClientRegistry) *ControlManager {
|
func NewControlManager() *ControlManager {
|
||||||
return &ControlManager{
|
return &ControlManager{
|
||||||
ctlsByRunID: make(map[string]*controlEntry),
|
ctlsByRunID: make(map[string]*Control),
|
||||||
registry: clientRegistry,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// lockCurrentRun returns the current entry with its run gate held. It never
|
func (cm *ControlManager) Add(runID string, ctl *Control) (old *Control) {
|
||||||
// waits for the gate while holding cm.mu and revalidates the gate after waiting.
|
|
||||||
// The global order is runMu, cm.mu, ctl.lifecycleMu, then registry locks.
|
|
||||||
func (cm *ControlManager) lockCurrentRun(runID string, allowClosed bool) (*controlEntry, bool) {
|
|
||||||
cm.mu.RLock()
|
|
||||||
entry, ok := cm.ctlsByRunID[runID]
|
|
||||||
if cm.closed && !allowClosed {
|
|
||||||
ok = false
|
|
||||||
}
|
|
||||||
cm.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
runMu := entry.runMu
|
|
||||||
runMu.Lock()
|
|
||||||
cm.mu.RLock()
|
|
||||||
entry, ok = cm.ctlsByRunID[runID]
|
|
||||||
if (cm.closed && !allowClosed) || !ok || entry.runMu != runMu {
|
|
||||||
ok = false
|
|
||||||
}
|
|
||||||
cm.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
runMu.Unlock()
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return entry, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add makes ctl the pending current generation and records the predecessor
|
|
||||||
// finalization barrier it must wait for before activation.
|
|
||||||
func (cm *ControlManager) Add(ctl *Control) error {
|
|
||||||
for {
|
|
||||||
// Never wait for a run gate while holding cm.mu.
|
|
||||||
cm.mu.RLock()
|
|
||||||
old := cm.ctlsByRunID[ctl.runID]
|
|
||||||
cm.mu.RUnlock()
|
|
||||||
if old != nil {
|
|
||||||
old.runMu.Lock()
|
|
||||||
}
|
|
||||||
|
|
||||||
cm.mu.Lock()
|
|
||||||
if cm.closed {
|
|
||||||
cm.mu.Unlock()
|
|
||||||
if old != nil {
|
|
||||||
old.runMu.Unlock()
|
|
||||||
}
|
|
||||||
return fmt.Errorf("control manager is closed")
|
|
||||||
}
|
|
||||||
if cm.ctlsByRunID[ctl.runID] != old {
|
|
||||||
cm.mu.Unlock()
|
|
||||||
if old != nil {
|
|
||||||
old.runMu.Unlock()
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
id := ControlID(nextControlID.Add(1))
|
|
||||||
if err := ctl.admit(cm, id); err != nil {
|
|
||||||
cm.mu.Unlock()
|
|
||||||
if old != nil {
|
|
||||||
old.runMu.Unlock()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
runMu := &sync.Mutex{}
|
|
||||||
if old != nil {
|
|
||||||
runMu = old.runMu
|
|
||||||
}
|
|
||||||
entry := &controlEntry{ctl: ctl, id: id, runMu: runMu}
|
|
||||||
var (
|
|
||||||
oldCtl *Control
|
|
||||||
barrier <-chan struct{}
|
|
||||||
)
|
|
||||||
if old != nil {
|
|
||||||
oldCtl = old.ctl
|
|
||||||
barrier = oldCtl.markReplaced()
|
|
||||||
ctl.setHandoffBarrier(barrier)
|
|
||||||
entry.registryOnline = old.registryOnline
|
|
||||||
entry.registryControlID = old.registryControlID
|
|
||||||
}
|
|
||||||
cm.ctlsByRunID[ctl.runID] = entry
|
|
||||||
cm.mu.Unlock()
|
|
||||||
if old != nil {
|
|
||||||
old.runMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
if oldCtl != nil {
|
|
||||||
oldCtl.Replaced(ctl)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate registers ctl as online only if it is still the pending current
|
|
||||||
// generation.
|
|
||||||
func (cm *ControlManager) Activate(ctl *Control) (bool, error) {
|
|
||||||
entry, ok := cm.lockCurrentRun(ctl.runID, false)
|
|
||||||
if !ok {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
defer entry.runMu.Unlock()
|
|
||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
defer cm.mu.Unlock()
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
if cm.closed || cm.ctlsByRunID[ctl.runID] != entry || entry.ctl != ctl || entry.id != ctl.controlID {
|
var ok bool
|
||||||
return false, nil
|
old, ok = cm.ctlsByRunID[runID]
|
||||||
|
if ok {
|
||||||
|
old.Replaced(ctl)
|
||||||
|
}
|
||||||
|
cm.ctlsByRunID[runID] = ctl
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctl.lifecycleMu.Lock()
|
// we should make sure if it's the same control to prevent delete a new one
|
||||||
defer ctl.lifecycleMu.Unlock()
|
func (cm *ControlManager) Del(runID string, ctl *Control) {
|
||||||
if ctl.state != controlStatePending {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if ctl.activated {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
loginMsg := ctl.sessionCtx.LoginMsg
|
|
||||||
remoteAddr := ctl.sessionCtx.Conn.RemoteAddr().String()
|
|
||||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
|
||||||
remoteAddr = host
|
|
||||||
}
|
|
||||||
_, conflict := cm.registry.RegisterWithControlID(
|
|
||||||
loginMsg.User,
|
|
||||||
loginMsg.ClientID,
|
|
||||||
ctl.runID,
|
|
||||||
loginMsg.Hostname,
|
|
||||||
loginMsg.Version,
|
|
||||||
remoteAddr,
|
|
||||||
ctl.sessionCtx.WireProtocol,
|
|
||||||
uint64(entry.id),
|
|
||||||
)
|
|
||||||
if conflict {
|
|
||||||
return true, fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.registryOnline = true
|
|
||||||
entry.registryControlID = entry.id
|
|
||||||
ctl.activated = true
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// completeLogin reserves ctl's current ownership with its run gate while the
|
|
||||||
// bounded successful LoginResp write runs, then transitions it to running.
|
|
||||||
// The callback must only perform that bounded write; it must not call back into
|
|
||||||
// the control manager or the same control lifecycle.
|
|
||||||
func (cm *ControlManager) completeLogin(ctl *Control, writeSuccess func() error) (bool, error) {
|
|
||||||
entry, ok := cm.lockCurrentRun(ctl.runID, false)
|
|
||||||
if !ok {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
defer entry.runMu.Unlock()
|
|
||||||
if entry.ctl != ctl || entry.id != ctl.controlID {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctl.lifecycleMu.Lock()
|
|
||||||
defer ctl.lifecycleMu.Unlock()
|
|
||||||
if ctl.state != controlStatePending || !ctl.activated {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := writeSuccess(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !ctl.startLocked() {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove deletes and offlines ctl only if it is still the current generation.
|
|
||||||
func (cm *ControlManager) Remove(ctl *Control) bool {
|
|
||||||
entry, ok := cm.lockCurrentRun(ctl.runID, true)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer entry.runMu.Unlock()
|
|
||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
defer cm.mu.Unlock()
|
defer cm.mu.Unlock()
|
||||||
|
if c, ok := cm.ctlsByRunID[runID]; ok && c == ctl {
|
||||||
if cm.ctlsByRunID[ctl.runID] != entry || entry.ctl != ctl || entry.id != ctl.controlID {
|
delete(cm.ctlsByRunID, runID)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
delete(cm.ctlsByRunID, ctl.runID)
|
|
||||||
if entry.registryOnline {
|
|
||||||
cm.registry.MarkOfflineByRunIDAndControlID(ctl.runID, uint64(entry.registryControlID))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) {
|
func (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) {
|
||||||
entry, ok := cm.lockCurrentRun(runID, false)
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
defer entry.runMu.Unlock()
|
|
||||||
ctl = entry.ctl
|
|
||||||
|
|
||||||
ctl.lifecycleMu.Lock()
|
|
||||||
defer ctl.lifecycleMu.Unlock()
|
|
||||||
if ctl.state != controlStateRunning {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return ctl, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// admitVisitorByRunID commits a visitor admission against the current running
|
|
||||||
// control while its run and lifecycle ownership are held. The callback must
|
|
||||||
// only perform the in-memory, buffered visitor admission.
|
|
||||||
func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user string) error) (bool, error) {
|
|
||||||
entry, ok := cm.lockCurrentRun(runID, false)
|
|
||||||
if !ok {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
defer entry.runMu.Unlock()
|
|
||||||
ctl := entry.ctl
|
|
||||||
|
|
||||||
ctl.lifecycleMu.Lock()
|
|
||||||
defer ctl.lifecycleMu.Unlock()
|
|
||||||
if ctl.state != controlStateRunning {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return true, admit(ctl.sessionCtx.LoginMsg.User)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterWorkConn transfers conn to ctl only if ctl is still the current
|
|
||||||
// running generation. On error, ownership remains with the caller.
|
|
||||||
func (cm *ControlManager) RegisterWorkConn(ctl *Control, conn *proxy.WorkConn) error {
|
|
||||||
entry, ok := cm.lockCurrentRun(ctl.runID, false)
|
|
||||||
if !ok {
|
|
||||||
cm.mu.RLock()
|
cm.mu.RLock()
|
||||||
closed := cm.closed
|
defer cm.mu.RUnlock()
|
||||||
cm.mu.RUnlock()
|
ctl, ok = cm.ctlsByRunID[runID]
|
||||||
if closed {
|
return
|
||||||
return fmt.Errorf("control manager is closed")
|
|
||||||
}
|
|
||||||
return fmt.Errorf("client control for run id [%s] is no longer current", ctl.runID)
|
|
||||||
}
|
|
||||||
defer entry.runMu.Unlock()
|
|
||||||
if entry.ctl != ctl || entry.id != ctl.controlID {
|
|
||||||
return fmt.Errorf("client control for run id [%s] is no longer current", ctl.runID)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctl.lifecycleMu.Lock()
|
|
||||||
defer ctl.lifecycleMu.Unlock()
|
|
||||||
if ctl.state != controlStateRunning {
|
|
||||||
return fmt.Errorf("client control for run id [%s] is not running", ctl.runID)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case ctl.workConnCh <- conn:
|
|
||||||
ctl.xl.Debugf("new work connection registered")
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
ctl.xl.Debugf("work connection pool is full, discarding")
|
|
||||||
return fmt.Errorf("work connection pool is full, discarding")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ControlManager) Close() error {
|
func (cm *ControlManager) Close() error {
|
||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
cm.closed = true
|
defer cm.mu.Unlock()
|
||||||
ctls := make([]*Control, 0, len(cm.ctlsByRunID))
|
for _, ctl := range cm.ctlsByRunID {
|
||||||
for _, entry := range cm.ctlsByRunID {
|
ctl.Close()
|
||||||
ctls = append(ctls, entry.ctl)
|
|
||||||
}
|
|
||||||
cm.mu.Unlock()
|
|
||||||
|
|
||||||
for _, ctl := range ctls {
|
|
||||||
cm.Remove(ctl)
|
|
||||||
_ = ctl.Close()
|
|
||||||
}
|
}
|
||||||
|
cm.ctlsByRunID = make(map[string]*Control)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,20 +110,12 @@ type SessionContext struct {
|
|||||||
LoginMsg *msg.Login
|
LoginMsg *msg.Login
|
||||||
// server configuration
|
// server configuration
|
||||||
ServerCfg *v1.ServerConfig
|
ServerCfg *v1.ServerConfig
|
||||||
|
// client registry
|
||||||
|
ClientRegistry *registry.ClientRegistry
|
||||||
// negotiated wire protocol for this client session
|
// negotiated wire protocol for this client session
|
||||||
WireProtocol string
|
WireProtocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
type controlState uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
controlStateCreated controlState = iota
|
|
||||||
controlStatePending
|
|
||||||
controlStateRunning
|
|
||||||
controlStateClosing
|
|
||||||
controlStateClosed
|
|
||||||
)
|
|
||||||
|
|
||||||
type Control struct {
|
type Control struct {
|
||||||
// session context
|
// session context
|
||||||
sessionCtx *SessionContext
|
sessionCtx *SessionContext
|
||||||
@@ -407,26 +142,16 @@ type Control struct {
|
|||||||
// last time got the Ping message
|
// last time got the Ping message
|
||||||
lastPing atomic.Value
|
lastPing atomic.Value
|
||||||
|
|
||||||
// runID never changes during the lifetime of a control. controlID is assigned
|
// A new run id will be generated when a new client login.
|
||||||
// once by ControlManager and distinguishes same-runID generations.
|
// If run id got from login message has same run id, it means it's the same client, so we can
|
||||||
|
// replace old controller instantly.
|
||||||
runID string
|
runID string
|
||||||
controlID ControlID
|
|
||||||
manager *ControlManager
|
|
||||||
|
|
||||||
lifecycleMu sync.Mutex
|
|
||||||
state controlState
|
|
||||||
activated bool
|
|
||||||
handoffBarrier <-chan struct{}
|
|
||||||
|
|
||||||
interruptOnce sync.Once
|
|
||||||
interruptErr error
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
xl *xlog.Logger
|
xl *xlog.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
serverMetrics metrics.ServerMetrics
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {
|
func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {
|
||||||
@@ -438,11 +163,9 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
|||||||
poolCount: poolCount,
|
poolCount: poolCount,
|
||||||
portsUsedNum: 0,
|
portsUsedNum: 0,
|
||||||
runID: sessionCtx.LoginMsg.RunID,
|
runID: sessionCtx.LoginMsg.RunID,
|
||||||
state: controlStateCreated,
|
|
||||||
xl: xlog.FromContextSafe(ctx),
|
xl: xlog.FromContextSafe(ctx),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
doneCh: make(chan struct{}),
|
doneCh: make(chan struct{}),
|
||||||
serverMetrics: metrics.Server,
|
|
||||||
}
|
}
|
||||||
ctl.lastPing.Store(time.Now())
|
ctl.lastPing.Store(time.Now())
|
||||||
|
|
||||||
@@ -452,121 +175,48 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
|||||||
return ctl, nil
|
return ctl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) RunID() string {
|
|
||||||
return ctl.runID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctl *Control) ID() ControlID {
|
|
||||||
ctl.lifecycleMu.Lock()
|
|
||||||
defer ctl.lifecycleMu.Unlock()
|
|
||||||
return ctl.controlID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctl *Control) admit(manager *ControlManager, id ControlID) error {
|
|
||||||
ctl.lifecycleMu.Lock()
|
|
||||||
defer ctl.lifecycleMu.Unlock()
|
|
||||||
if ctl.state != controlStateCreated {
|
|
||||||
return fmt.Errorf("control [%s] is not in created state", ctl.runID)
|
|
||||||
}
|
|
||||||
ctl.manager = manager
|
|
||||||
ctl.controlID = id
|
|
||||||
ctl.state = controlStatePending
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctl *Control) setHandoffBarrier(barrier <-chan struct{}) {
|
|
||||||
ctl.lifecycleMu.Lock()
|
|
||||||
ctl.handoffBarrier = barrier
|
|
||||||
ctl.lifecycleMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctl *Control) WaitForHandoff() {
|
|
||||||
ctl.lifecycleMu.Lock()
|
|
||||||
barrier := ctl.handoffBarrier
|
|
||||||
ctl.lifecycleMu.Unlock()
|
|
||||||
if barrier != nil {
|
|
||||||
<-barrier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start starts the control session workers after login succeeds.
|
// Start starts the control session workers after login succeeds.
|
||||||
func (ctl *Control) Start() bool {
|
func (ctl *Control) Start() {
|
||||||
ctl.lifecycleMu.Lock()
|
go func() {
|
||||||
defer ctl.lifecycleMu.Unlock()
|
for i := 0; i < ctl.poolCount; i++ {
|
||||||
return ctl.startLocked()
|
// ignore error here, that means that this control is closed
|
||||||
|
_ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{})
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
func (ctl *Control) startLocked() bool {
|
|
||||||
if ctl.state != controlStatePending || !ctl.activated {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ctl.state = controlStateRunning
|
|
||||||
go ctl.worker()
|
go ctl.worker()
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) Close() error {
|
func (ctl *Control) Close() error {
|
||||||
ctl.lifecycleMu.Lock()
|
ctl.sessionCtx.Conn.Close()
|
||||||
switch ctl.state {
|
return nil
|
||||||
case controlStateCreated, controlStatePending:
|
|
||||||
ctl.state = controlStateClosing
|
|
||||||
ctl.finishLocked()
|
|
||||||
case controlStateRunning:
|
|
||||||
ctl.state = controlStateClosing
|
|
||||||
}
|
|
||||||
ctl.lifecycleMu.Unlock()
|
|
||||||
return ctl.interruptReadAndClose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) Replaced(newCtl *Control) {
|
func (ctl *Control) Replaced(newCtl *Control) {
|
||||||
ctl.markReplaced()
|
xl := ctl.xl
|
||||||
ctl.xl.Infof("replaced by client [%s] (control ID %d)", newCtl.runID, newCtl.ID())
|
xl.Infof("replaced by client [%s]", newCtl.runID)
|
||||||
_ = ctl.interruptReadAndClose()
|
ctl.runID = ""
|
||||||
|
ctl.sessionCtx.Conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// markReplaced returns the transitive predecessor barrier. A pending control
|
func (ctl *Control) RegisterWorkConn(conn *proxy.WorkConn) error {
|
||||||
// has no worker, so it finishes immediately and passes its inherited barrier
|
xl := ctl.xl
|
||||||
// to the replacement. A running control is finished only by its worker.
|
defer func() {
|
||||||
func (ctl *Control) markReplaced() <-chan struct{} {
|
if err := recover(); err != nil {
|
||||||
ctl.lifecycleMu.Lock()
|
xl.Errorf("panic error: %v", err)
|
||||||
defer ctl.lifecycleMu.Unlock()
|
xl.Errorf(string(debug.Stack()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
switch ctl.state {
|
select {
|
||||||
case controlStateCreated:
|
case ctl.workConnCh <- conn:
|
||||||
ctl.state = controlStateClosing
|
xl.Debugf("new work connection registered")
|
||||||
ctl.finishLocked()
|
|
||||||
return nil
|
return nil
|
||||||
case controlStatePending:
|
|
||||||
barrier := ctl.handoffBarrier
|
|
||||||
ctl.state = controlStateClosing
|
|
||||||
ctl.finishLocked()
|
|
||||||
return barrier
|
|
||||||
case controlStateRunning:
|
|
||||||
ctl.state = controlStateClosing
|
|
||||||
return ctl.doneCh
|
|
||||||
case controlStateClosing, controlStateClosed:
|
|
||||||
return ctl.doneCh
|
|
||||||
default:
|
default:
|
||||||
return ctl.doneCh
|
xl.Debugf("work connection pool is full, discarding")
|
||||||
|
return fmt.Errorf("work connection pool is full, discarding")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) interruptReadAndClose() error {
|
|
||||||
ctl.interruptOnce.Do(func() {
|
|
||||||
_ = ctl.sessionCtx.Conn.SetReadDeadline(time.Now())
|
|
||||||
ctl.interruptErr = ctl.sessionCtx.Conn.Close()
|
|
||||||
})
|
|
||||||
return ctl.interruptErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctl *Control) finishLocked() {
|
|
||||||
if ctl.state == controlStateClosed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctl.state = controlStateClosed
|
|
||||||
close(ctl.doneCh)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When frps get one user connection, we get one work connection from the pool and return it.
|
// When frps get one user connection, we get one work connection from the pool and return it.
|
||||||
// If no workConn available in the pool, send message to frpc to get one or more
|
// If no workConn available in the pool, send message to frpc to get one or more
|
||||||
// and wait until it is available.
|
// and wait until it is available.
|
||||||
@@ -621,10 +271,10 @@ func (ctl *Control) heartbeatWorker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
xl := ctl.xl
|
xl := ctl.xl
|
||||||
wait.Until(func() {
|
go wait.Until(func() {
|
||||||
if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout)*time.Second {
|
if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout)*time.Second {
|
||||||
xl.Warnf("heartbeat timeout")
|
xl.Warnf("heartbeat timeout")
|
||||||
_ = ctl.Close()
|
ctl.sessionCtx.Conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, time.Second, ctl.doneCh)
|
}, time.Second, ctl.doneCh)
|
||||||
@@ -639,14 +289,14 @@ func (ctl *Control) loginUserInfo() plugin.UserInfo {
|
|||||||
return plugin.UserInfo{
|
return plugin.UserInfo{
|
||||||
User: ctl.sessionCtx.LoginMsg.User,
|
User: ctl.sessionCtx.LoginMsg.User,
|
||||||
Metas: ctl.sessionCtx.LoginMsg.Metas,
|
Metas: ctl.sessionCtx.LoginMsg.Metas,
|
||||||
RunID: ctl.runID,
|
RunID: ctl.sessionCtx.LoginMsg.RunID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) closeProxy(pxy proxy.Proxy) {
|
func (ctl *Control) closeProxy(pxy proxy.Proxy) {
|
||||||
pxy.Close()
|
pxy.Close()
|
||||||
ctl.sessionCtx.PxyManager.Del(pxy.GetName())
|
ctl.sessionCtx.PxyManager.Del(pxy.GetName())
|
||||||
ctl.serverMetrics.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
|
||||||
|
|
||||||
notifyContent := &plugin.CloseProxyContent{
|
notifyContent := &plugin.CloseProxyContent{
|
||||||
User: ctl.loginUserInfo(),
|
User: ctl.loginUserInfo(),
|
||||||
@@ -661,24 +311,12 @@ func (ctl *Control) closeProxy(pxy proxy.Proxy) {
|
|||||||
|
|
||||||
func (ctl *Control) worker() {
|
func (ctl *Control) worker() {
|
||||||
xl := ctl.xl
|
xl := ctl.xl
|
||||||
ctl.serverMetrics.NewClient()
|
|
||||||
|
|
||||||
go ctl.heartbeatWorker()
|
go ctl.heartbeatWorker()
|
||||||
go ctl.msgDispatcher.Run()
|
go ctl.msgDispatcher.Run()
|
||||||
go func() {
|
|
||||||
for i := 0; i < ctl.poolCount; i++ {
|
|
||||||
// Ignore the error: it means this control is already closing.
|
|
||||||
_ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{})
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-ctl.msgDispatcher.Done()
|
<-ctl.msgDispatcher.Done()
|
||||||
ctl.lifecycleMu.Lock()
|
ctl.sessionCtx.Conn.Close()
|
||||||
if ctl.state == controlStateRunning {
|
|
||||||
ctl.state = controlStateClosing
|
|
||||||
}
|
|
||||||
ctl.lifecycleMu.Unlock()
|
|
||||||
_ = ctl.interruptReadAndClose()
|
|
||||||
|
|
||||||
ctl.mu.Lock()
|
ctl.mu.Lock()
|
||||||
close(ctl.workConnCh)
|
close(ctl.workConnCh)
|
||||||
@@ -693,14 +331,10 @@ func (ctl *Control) worker() {
|
|||||||
ctl.closeProxy(pxy)
|
ctl.closeProxy(pxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctl.serverMetrics.CloseClient()
|
metrics.Server.CloseClient()
|
||||||
if ctl.manager != nil {
|
ctl.sessionCtx.ClientRegistry.MarkOfflineByRunID(ctl.runID)
|
||||||
ctl.manager.Remove(ctl)
|
|
||||||
}
|
|
||||||
xl.Infof("client exit success")
|
xl.Infof("client exit success")
|
||||||
ctl.lifecycleMu.Lock()
|
close(ctl.doneCh)
|
||||||
ctl.finishLocked()
|
|
||||||
ctl.lifecycleMu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *Control) registerMsgHandlers() {
|
func (ctl *Control) registerMsgHandlers() {
|
||||||
@@ -740,9 +374,9 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
|
|||||||
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
|
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
|
||||||
clientID := ctl.sessionCtx.LoginMsg.ClientID
|
clientID := ctl.sessionCtx.LoginMsg.ClientID
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientID = ctl.runID
|
clientID = ctl.sessionCtx.LoginMsg.RunID
|
||||||
}
|
}
|
||||||
ctl.serverMetrics.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID)
|
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID)
|
||||||
}
|
}
|
||||||
_ = ctl.msgDispatcher.Send(resp)
|
_ = ctl.msgDispatcher.Send(resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,543 +0,0 @@
|
|||||||
// 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 server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/auth"
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
|
||||||
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
|
||||||
"github.com/fatedier/frp/server/controller"
|
|
||||||
"github.com/fatedier/frp/server/proxy"
|
|
||||||
"github.com/fatedier/frp/server/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestControlPendingReplacementFinishesWithoutStarting(t *testing.T) {
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
manager := NewControlManager(clientRegistry)
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
oldCtl, oldConn := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
newCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
|
|
||||||
mustAddAndActivate(t, manager, oldCtl)
|
|
||||||
|
|
||||||
err := manager.Add(newCtl)
|
|
||||||
require.NoError(t, err)
|
|
||||||
waitForControlDone(t, oldCtl)
|
|
||||||
require.False(t, oldCtl.Start())
|
|
||||||
require.Equal(t, []string{"deadline", "close"}, oldConn.eventsSnapshot())
|
|
||||||
require.Equal(t, int64(0), metrics.newClients())
|
|
||||||
require.Equal(t, int64(0), metrics.closedClients())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlRunningReplacementFinishesInWorker(t *testing.T) {
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
manager := NewControlManager(clientRegistry)
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
oldCtl, oldConn := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
newCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
|
|
||||||
mustAddAndActivate(t, manager, oldCtl)
|
|
||||||
require.True(t, oldCtl.Start())
|
|
||||||
waitForSignal(t, oldConn.readStarted, "control reader to start")
|
|
||||||
|
|
||||||
err := manager.Add(newCtl)
|
|
||||||
require.NoError(t, err)
|
|
||||||
waitForControlDone(t, oldCtl)
|
|
||||||
require.Equal(t, []string{"deadline", "close"}, oldConn.eventsSnapshot())
|
|
||||||
require.Equal(t, int64(1), metrics.newClients())
|
|
||||||
require.Equal(t, int64(1), metrics.closedClients())
|
|
||||||
|
|
||||||
_, ok := manager.GetByID("same-run")
|
|
||||||
require.False(t, ok)
|
|
||||||
require.Same(t, newCtl, currentControlForTest(manager, "same-run"))
|
|
||||||
info, ok := clientRegistry.GetByKey("client")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.True(t, info.Online)
|
|
||||||
require.Equal(t, uint64(oldCtl.ID()), info.ControlID)
|
|
||||||
|
|
||||||
active, err := manager.Activate(newCtl)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, active)
|
|
||||||
_, ok = manager.GetByID("same-run")
|
|
||||||
require.False(t, ok)
|
|
||||||
info, ok = clientRegistry.GetByKey("client")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Equal(t, uint64(newCtl.ID()), info.ControlID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlClosePendingAndRunning(t *testing.T) {
|
|
||||||
t.Run("pending", func(t *testing.T) {
|
|
||||||
manager := NewControlManager(registry.NewClientRegistry())
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
ctl, conn := newLifecycleTestControl(t, "pending", "pending", metrics)
|
|
||||||
err := manager.Add(ctl)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, ctl.Close())
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
|
||||||
require.Equal(t, int64(0), metrics.newClients())
|
|
||||||
require.Equal(t, int64(0), metrics.closedClients())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("running", func(t *testing.T) {
|
|
||||||
manager := NewControlManager(registry.NewClientRegistry())
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
ctl, conn := newLifecycleTestControl(t, "running", "running", metrics)
|
|
||||||
mustAddAndActivate(t, manager, ctl)
|
|
||||||
require.True(t, ctl.Start())
|
|
||||||
waitForSignal(t, conn.readStarted, "control reader to start")
|
|
||||||
|
|
||||||
require.NoError(t, ctl.Close())
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
|
||||||
require.Equal(t, int64(1), metrics.newClients())
|
|
||||||
require.Equal(t, int64(1), metrics.closedClients())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlCloseAndReplacedAreIdempotent(t *testing.T) {
|
|
||||||
manager := NewControlManager(registry.NewClientRegistry())
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
ctl, conn := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
replacement, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
|
|
||||||
err := manager.Add(ctl)
|
|
||||||
require.NoError(t, err)
|
|
||||||
err = manager.Add(replacement)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, ctl.Close())
|
|
||||||
ctl.Replaced(replacement)
|
|
||||||
require.NoError(t, ctl.Close())
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
|
|
||||||
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
|
||||||
require.Equal(t, int64(0), metrics.newClients())
|
|
||||||
require.Equal(t, int64(0), metrics.closedClients())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlHeartbeatTimeoutInterruptsRead(t *testing.T) {
|
|
||||||
manager := NewControlManager(registry.NewClientRegistry())
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
ctl, conn := newLifecycleTestControl(t, "heartbeat", "heartbeat", metrics)
|
|
||||||
ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout = 1
|
|
||||||
ctl.lastPing.Store(time.Now().Add(-2 * time.Second))
|
|
||||||
|
|
||||||
mustAddAndActivate(t, manager, ctl)
|
|
||||||
require.True(t, ctl.Start())
|
|
||||||
waitForSignal(t, conn.readStarted, "control reader to start")
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
|
|
||||||
require.Equal(t, []string{"deadline", "close"}, conn.eventsSnapshot())
|
|
||||||
require.Equal(t, int64(1), metrics.newClients())
|
|
||||||
require.Equal(t, int64(1), metrics.closedClients())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlStartReplacementRacePairsMetrics(t *testing.T) {
|
|
||||||
for range 100 {
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
manager := NewControlManager(clientRegistry)
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
ctl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
replacement, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
|
|
||||||
mustAddAndActivate(t, manager, ctl)
|
|
||||||
|
|
||||||
startGate := make(chan struct{})
|
|
||||||
startedCh := make(chan bool, 1)
|
|
||||||
addErrCh := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
<-startGate
|
|
||||||
startedCh <- ctl.Start()
|
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
<-startGate
|
|
||||||
addErr := manager.Add(replacement)
|
|
||||||
addErrCh <- addErr
|
|
||||||
}()
|
|
||||||
close(startGate)
|
|
||||||
|
|
||||||
started := <-startedCh
|
|
||||||
require.NoError(t, <-addErrCh)
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
if started {
|
|
||||||
require.Equal(t, int64(1), metrics.newClients())
|
|
||||||
require.Equal(t, int64(1), metrics.closedClients())
|
|
||||||
} else {
|
|
||||||
require.Equal(t, int64(0), metrics.newClients())
|
|
||||||
require.Equal(t, int64(0), metrics.closedClients())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlManagerRejectsStaleActivateAndRemove(t *testing.T) {
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
manager := NewControlManager(clientRegistry)
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
oldCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
newCtl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
|
|
||||||
mustAddAndActivate(t, manager, oldCtl)
|
|
||||||
err := manager.Add(newCtl)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Greater(t, uint64(newCtl.ID()), uint64(oldCtl.ID()))
|
|
||||||
|
|
||||||
active, err := manager.Activate(oldCtl)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, active)
|
|
||||||
require.False(t, manager.Remove(oldCtl))
|
|
||||||
|
|
||||||
_, ok := manager.GetByID("same-run")
|
|
||||||
require.False(t, ok)
|
|
||||||
require.Same(t, newCtl, currentControlForTest(manager, "same-run"))
|
|
||||||
info, ok := clientRegistry.GetByKey("client")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.True(t, info.Online)
|
|
||||||
require.Equal(t, uint64(oldCtl.ID()), info.ControlID)
|
|
||||||
|
|
||||||
active, err = manager.Activate(newCtl)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, active)
|
|
||||||
info, ok = clientRegistry.GetByKey("client")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.True(t, info.Online)
|
|
||||||
require.Equal(t, uint64(newCtl.ID()), info.ControlID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlManagerPreservesClientIDConflict(t *testing.T) {
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
manager := NewControlManager(clientRegistry)
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
first, _ := newLifecycleTestControl(t, "run-one", "shared-client", metrics)
|
|
||||||
conflicting, _ := newLifecycleTestControl(t, "run-two", "shared-client", metrics)
|
|
||||||
|
|
||||||
mustAddAndActivate(t, manager, first)
|
|
||||||
err := manager.Add(conflicting)
|
|
||||||
require.NoError(t, err)
|
|
||||||
active, err := manager.Activate(conflicting)
|
|
||||||
require.True(t, active)
|
|
||||||
require.ErrorContains(t, err, "already online")
|
|
||||||
|
|
||||||
require.True(t, manager.Remove(conflicting))
|
|
||||||
info, ok := clientRegistry.GetByKey("shared-client")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.True(t, info.Online)
|
|
||||||
require.Equal(t, "run-one", info.RunID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlManagerFailedLoginWriteReleasesRunWithoutStarting(t *testing.T) {
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
manager := NewControlManager(clientRegistry)
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
ctl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
replacement, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
|
|
||||||
mustAddAndActivate(t, manager, ctl)
|
|
||||||
|
|
||||||
writeErr := errors.New("write failed")
|
|
||||||
committed, err := manager.completeLogin(ctl, func() error { return writeErr })
|
|
||||||
require.ErrorIs(t, err, writeErr)
|
|
||||||
require.False(t, committed)
|
|
||||||
|
|
||||||
err = manager.Add(replacement)
|
|
||||||
require.NoError(t, err)
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
require.Same(t, replacement, currentControlForTest(manager, "same-run"))
|
|
||||||
require.Equal(t, int64(0), metrics.newClients())
|
|
||||||
require.Equal(t, int64(0), metrics.closedClients())
|
|
||||||
require.True(t, manager.Remove(replacement))
|
|
||||||
info, ok := clientRegistry.GetByKey("client")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.False(t, info.Online)
|
|
||||||
require.Empty(t, info.RunID)
|
|
||||||
require.Zero(t, info.ControlID)
|
|
||||||
require.False(t, info.DisconnectedAt.IsZero())
|
|
||||||
require.NoError(t, replacement.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlManagerCloseWaitsForInFlightLoginRun(t *testing.T) {
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
manager := NewControlManager(clientRegistry)
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
ctl, _ := newLifecycleTestControl(t, "same-run", "client", metrics)
|
|
||||||
|
|
||||||
mustAddAndActivate(t, manager, ctl)
|
|
||||||
|
|
||||||
writeEntered := make(chan struct{})
|
|
||||||
resumeWrite := make(chan struct{})
|
|
||||||
loginDone := make(chan struct {
|
|
||||||
committed bool
|
|
||||||
err error
|
|
||||||
}, 1)
|
|
||||||
go func() {
|
|
||||||
committed, loginErr := manager.completeLogin(ctl, func() error {
|
|
||||||
close(writeEntered)
|
|
||||||
<-resumeWrite
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
loginDone <- struct {
|
|
||||||
committed bool
|
|
||||||
err error
|
|
||||||
}{committed: committed, err: loginErr}
|
|
||||||
}()
|
|
||||||
waitForSignal(t, writeEntered, "LoginResp write")
|
|
||||||
|
|
||||||
closeDone := make(chan error, 1)
|
|
||||||
go func() { closeDone <- manager.Close() }()
|
|
||||||
waitForManagerClosed(t, manager)
|
|
||||||
select {
|
|
||||||
case err := <-closeDone:
|
|
||||||
t.Fatalf("manager close completed during LoginResp write: %v", err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
close(resumeWrite)
|
|
||||||
result := <-loginDone
|
|
||||||
require.NoError(t, result.err)
|
|
||||||
require.True(t, result.committed)
|
|
||||||
require.NoError(t, <-closeDone)
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
require.Nil(t, currentControlForTest(manager, "same-run"))
|
|
||||||
require.Equal(t, int64(1), metrics.newClients())
|
|
||||||
require.Equal(t, int64(1), metrics.closedClients())
|
|
||||||
info, ok := clientRegistry.GetByKey("client")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.False(t, info.Online)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLifecycleTestControl(
|
|
||||||
t *testing.T,
|
|
||||||
runID string,
|
|
||||||
clientID string,
|
|
||||||
serverMetrics *countingServerMetrics,
|
|
||||||
) (*Control, *deadlineReadConn) {
|
|
||||||
t.Helper()
|
|
||||||
conn := newDeadlineReadConn()
|
|
||||||
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
|
||||||
ctl, err := NewControl(context.Background(), &SessionContext{
|
|
||||||
RC: &controller.ResourceController{},
|
|
||||||
PxyManager: proxy.NewManager(),
|
|
||||||
PluginManager: plugin.NewManager(),
|
|
||||||
AuthVerifier: auth.AlwaysPassVerifier,
|
|
||||||
Conn: msgConn,
|
|
||||||
LoginMsg: &msg.Login{
|
|
||||||
RunID: runID,
|
|
||||||
ClientID: clientID,
|
|
||||||
},
|
|
||||||
ServerCfg: &v1.ServerConfig{},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
ctl.serverMetrics = serverMetrics
|
|
||||||
t.Cleanup(func() { _ = ctl.Close() })
|
|
||||||
return ctl, conn
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustAddAndActivate(t *testing.T, manager *ControlManager, ctl *Control) {
|
|
||||||
t.Helper()
|
|
||||||
require.NoError(t, manager.Add(ctl))
|
|
||||||
active, err := manager.Activate(ctl)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, active)
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForControlDone(t *testing.T, ctl *Control) {
|
|
||||||
t.Helper()
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
ctl.WaitClosed()
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
waitForSignal(t, done, "control to finish")
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentControlForTest(manager *ControlManager, runID string) *Control {
|
|
||||||
manager.mu.RLock()
|
|
||||||
defer manager.mu.RUnlock()
|
|
||||||
entry := manager.ctlsByRunID[runID]
|
|
||||||
if entry == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return entry.ctl
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentRunGateForTest(manager *ControlManager, runID string) *sync.Mutex {
|
|
||||||
manager.mu.RLock()
|
|
||||||
defer manager.mu.RUnlock()
|
|
||||||
entry := manager.ctlsByRunID[runID]
|
|
||||||
if entry == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return entry.runMu
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForManagerClosed(t *testing.T, manager *ControlManager) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(3 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
manager.mu.RLock()
|
|
||||||
closed := manager.closed
|
|
||||||
manager.mu.RUnlock()
|
|
||||||
if closed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatal("timed out waiting for control manager to close")
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForSignal(t *testing.T, ch <-chan struct{}, description string) {
|
|
||||||
t.Helper()
|
|
||||||
select {
|
|
||||||
case <-ch:
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatalf("timed out waiting for %s", description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type deadlineReadConn struct {
|
|
||||||
readStarted chan struct{}
|
|
||||||
unblockRead chan struct{}
|
|
||||||
|
|
||||||
readOnce sync.Once
|
|
||||||
unblockOnce sync.Once
|
|
||||||
deadlineOnce sync.Once
|
|
||||||
closeOnce sync.Once
|
|
||||||
|
|
||||||
eventsMu sync.Mutex
|
|
||||||
events []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDeadlineReadConn() *deadlineReadConn {
|
|
||||||
return &deadlineReadConn{
|
|
||||||
readStarted: make(chan struct{}),
|
|
||||||
unblockRead: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *deadlineReadConn) Read([]byte) (int, error) {
|
|
||||||
c.readOnce.Do(func() { close(c.readStarted) })
|
|
||||||
<-c.unblockRead
|
|
||||||
return 0, os.ErrDeadlineExceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*deadlineReadConn) Write(p []byte) (int, error) { return len(p), nil }
|
|
||||||
|
|
||||||
func (c *deadlineReadConn) Close() error {
|
|
||||||
c.closeOnce.Do(func() {
|
|
||||||
c.recordEvent("close")
|
|
||||||
c.unblockOnce.Do(func() { close(c.unblockRead) })
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*deadlineReadConn) LocalAddr() net.Addr { return lifecycleTestAddr("local") }
|
|
||||||
func (*deadlineReadConn) RemoteAddr() net.Addr { return lifecycleTestAddr("remote") }
|
|
||||||
|
|
||||||
func (c *deadlineReadConn) SetDeadline(deadline time.Time) error {
|
|
||||||
if err := c.SetReadDeadline(deadline); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.SetWriteDeadline(deadline)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *deadlineReadConn) SetReadDeadline(deadline time.Time) error {
|
|
||||||
if deadline.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
c.deadlineOnce.Do(func() {
|
|
||||||
c.recordEvent("deadline")
|
|
||||||
c.unblockOnce.Do(func() { close(c.unblockRead) })
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*deadlineReadConn) SetWriteDeadline(time.Time) error { return nil }
|
|
||||||
|
|
||||||
func (c *deadlineReadConn) recordEvent(event string) {
|
|
||||||
c.eventsMu.Lock()
|
|
||||||
c.events = append(c.events, event)
|
|
||||||
c.eventsMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *deadlineReadConn) eventsSnapshot() []string {
|
|
||||||
c.eventsMu.Lock()
|
|
||||||
defer c.eventsMu.Unlock()
|
|
||||||
return append([]string(nil), c.events...)
|
|
||||||
}
|
|
||||||
|
|
||||||
type lifecycleTestAddr string
|
|
||||||
|
|
||||||
func (a lifecycleTestAddr) Network() string { return string(a) }
|
|
||||||
func (a lifecycleTestAddr) String() string { return string(a) }
|
|
||||||
|
|
||||||
type countingServerMetrics struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
newCount int64
|
|
||||||
closeCount int64
|
|
||||||
closeEnter chan struct{}
|
|
||||||
closeResume chan struct{}
|
|
||||||
closeOnce sync.Once
|
|
||||||
}
|
|
||||||
|
|
||||||
func newCountingServerMetrics() *countingServerMetrics {
|
|
||||||
return &countingServerMetrics{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *countingServerMetrics) NewClient() {
|
|
||||||
m.mu.Lock()
|
|
||||||
m.newCount++
|
|
||||||
m.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *countingServerMetrics) CloseClient() {
|
|
||||||
m.mu.Lock()
|
|
||||||
m.closeCount++
|
|
||||||
closeEnter := m.closeEnter
|
|
||||||
closeResume := m.closeResume
|
|
||||||
m.mu.Unlock()
|
|
||||||
if closeEnter != nil {
|
|
||||||
m.closeOnce.Do(func() { close(closeEnter) })
|
|
||||||
<-closeResume
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*countingServerMetrics) NewProxy(string, string, string, string) {}
|
|
||||||
func (*countingServerMetrics) CloseProxy(string, string) {}
|
|
||||||
func (*countingServerMetrics) OpenConnection(string, string) {}
|
|
||||||
func (*countingServerMetrics) CloseConnection(string, string) {}
|
|
||||||
func (*countingServerMetrics) AddTrafficIn(string, string, int64) {}
|
|
||||||
func (*countingServerMetrics) AddTrafficOut(string, string, int64) {}
|
|
||||||
|
|
||||||
func (m *countingServerMetrics) newClients() int64 {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.newCount
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *countingServerMetrics) closedClients() int64 {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.closeCount
|
|
||||||
}
|
|
||||||
@@ -58,12 +58,8 @@ 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()
|
||||||
return model.ServerInfoResp{
|
svrResp := model.ServerInfoResp{
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
BindPort: c.serverCfg.BindPort,
|
BindPort: c.serverCfg.BindPort,
|
||||||
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||||
@@ -84,6 +80,8 @@ func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
|||||||
ClientCounts: serverStats.ClientCounts,
|
ClientCounts: serverStats.ClientCounts,
|
||||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return svrResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// /api/clients
|
// /api/clients
|
||||||
|
|||||||
@@ -1,647 +0,0 @@
|
|||||||
// Copyright 2026 The frp Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"fmt"
|
|
||||||
"maps"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
||||||
"github.com/fatedier/frp/server/http/model"
|
|
||||||
"github.com/fatedier/frp/server/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultV2Page = 1
|
|
||||||
defaultV2PageSize = 50
|
|
||||||
maxV2PageSize = 200
|
|
||||||
|
|
||||||
v2SystemPruneTypeOfflineProxies = "offline_proxies"
|
|
||||||
v2ProxyTrafficDefaultDays = 7
|
|
||||||
v2ProxyTrafficUnit = "bytes"
|
|
||||||
v2ProxyTrafficGranularity = "day"
|
|
||||||
)
|
|
||||||
|
|
||||||
var apiV2ProxyTypes = []string{
|
|
||||||
string(v1.ProxyTypeTCP),
|
|
||||||
string(v1.ProxyTypeUDP),
|
|
||||||
string(v1.ProxyTypeHTTP),
|
|
||||||
string(v1.ProxyTypeHTTPS),
|
|
||||||
string(v1.ProxyTypeTCPMUX),
|
|
||||||
string(v1.ProxyTypeSTCP),
|
|
||||||
string(v1.ProxyTypeXTCP),
|
|
||||||
string(v1.ProxyTypeSUDP),
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/v2/system/info
|
|
||||||
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
|
|
||||||
info := c.buildServerInfoResp()
|
|
||||||
proxyTypeCounts := info.ProxyTypeCounts
|
|
||||||
if proxyTypeCounts == nil {
|
|
||||||
proxyTypeCounts = map[string]int64{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return model.V2SystemInfoResp{
|
|
||||||
Version: info.Version,
|
|
||||||
Config: model.V2SystemInfoConfigResp{
|
|
||||||
BindPort: info.BindPort,
|
|
||||||
VhostHTTPPort: info.VhostHTTPPort,
|
|
||||||
VhostHTTPSPort: info.VhostHTTPSPort,
|
|
||||||
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
|
|
||||||
KCPBindPort: info.KCPBindPort,
|
|
||||||
QUICBindPort: info.QUICBindPort,
|
|
||||||
SubdomainHost: info.SubdomainHost,
|
|
||||||
MaxPoolCount: info.MaxPoolCount,
|
|
||||||
MaxPortsPerClient: info.MaxPortsPerClient,
|
|
||||||
HeartbeatTimeout: info.HeartBeatTimeout,
|
|
||||||
AllowPortsStr: info.AllowPortsStr,
|
|
||||||
TLSForce: info.TLSForce,
|
|
||||||
},
|
|
||||||
Status: model.V2SystemInfoStatusResp{
|
|
||||||
TotalTrafficIn: info.TotalTrafficIn,
|
|
||||||
TotalTrafficOut: info.TotalTrafficOut,
|
|
||||||
CurConns: info.CurConns,
|
|
||||||
ClientCounts: info.ClientCounts,
|
|
||||||
ProxyTypeCounts: proxyTypeCounts,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/v2/system/prune
|
|
||||||
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
|
|
||||||
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cleared, total := mem.StatsCollector.PruneOfflineProxies()
|
|
||||||
return model.V2SystemPruneResp{
|
|
||||||
Type: pruneType,
|
|
||||||
Cleared: cleared,
|
|
||||||
Total: total,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/v2/users
|
|
||||||
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
|
||||||
page, pageSize, err := parseV2PageParams(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if c.clientRegistry == nil {
|
|
||||||
return nil, fmt.Errorf("client registry unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
userStats := make(map[string]*model.V2UserResp)
|
|
||||||
for _, info := range c.clientRegistry.List() {
|
|
||||||
item := getOrCreateV2User(userStats, info.User)
|
|
||||||
item.ClientCount++
|
|
||||||
}
|
|
||||||
for _, proxyInfo := range c.listV2ProxyStats("") {
|
|
||||||
item := getOrCreateV2User(userStats, proxyInfo.User)
|
|
||||||
item.ProxyCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
q := strings.ToLower(ctx.Query("q"))
|
|
||||||
items := make([]model.V2UserResp, 0, len(userStats))
|
|
||||||
for _, item := range userStats {
|
|
||||||
if q != "" && !strings.Contains(strings.ToLower(item.User), q) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, *item)
|
|
||||||
}
|
|
||||||
slices.SortFunc(items, func(a, b model.V2UserResp) int {
|
|
||||||
return cmp.Compare(a.User, b.User)
|
|
||||||
})
|
|
||||||
|
|
||||||
return buildV2PageResp(items, page, pageSize), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/v2/clients
|
|
||||||
func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
|
||||||
page, pageSize, err := parseV2PageParams(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if c.clientRegistry == nil {
|
|
||||||
return nil, fmt.Errorf("client registry unavailable")
|
|
||||||
}
|
|
||||||
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userFilter, filterByUser := queryValue(ctx, "user")
|
|
||||||
clientIDFilter := ctx.Query("clientID")
|
|
||||||
runIDFilter := ctx.Query("runID")
|
|
||||||
q := strings.ToLower(ctx.Query("q"))
|
|
||||||
|
|
||||||
records := c.clientRegistry.List()
|
|
||||||
items := make([]model.ClientInfoResp, 0, len(records))
|
|
||||||
for _, info := range records {
|
|
||||||
if filterByUser && info.User != userFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if runIDFilter != "" && info.RunID != runIDFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !matchV2StatusFilter(info.Online, statusFilter) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp := buildClientInfoResp(info)
|
|
||||||
if q != "" && !matchV2ClientQuery(resp, q) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
|
|
||||||
if v := cmp.Compare(a.User, b.User); v != 0 {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return cmp.Compare(a.Key, b.Key)
|
|
||||||
})
|
|
||||||
|
|
||||||
return buildV2PageResp(items, page, pageSize), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/v2/clients/{key}
|
|
||||||
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
|
||||||
key, err := decodeV2PathParam(ctx, "key", "client key")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.clientRegistry == nil {
|
|
||||||
return nil, fmt.Errorf("client registry unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
info, ok := c.clientRegistry.GetByKey(key)
|
|
||||||
if !ok {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := buildClientInfoResp(info)
|
|
||||||
status := c.buildV2ClientStatus(info)
|
|
||||||
return model.V2ClientDetailResp{
|
|
||||||
ClientInfoResp: resp,
|
|
||||||
Status: status,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/v2/proxies
|
|
||||||
func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
|
||||||
page, pageSize, err := parseV2PageParams(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
userFilter, filterByUser := queryValue(ctx, "user")
|
|
||||||
clientIDFilter := ctx.Query("clientID")
|
|
||||||
q := strings.ToLower(ctx.Query("q"))
|
|
||||||
|
|
||||||
stats := c.listV2ProxyStats(proxyType)
|
|
||||||
items := make([]model.V2ProxyResp, 0, len(stats))
|
|
||||||
for _, ps := range stats {
|
|
||||||
resp := c.buildV2ProxyResp(ps)
|
|
||||||
if filterByUser && resp.User != userFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if clientIDFilter != "" && resp.ClientID != clientIDFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if q != "" && !matchV2ProxyQuery(resp, q) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
|
||||||
if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return cmp.Compare(a.Name, b.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
return buildV2PageResp(items, page, pageSize), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/v2/proxies/{name}
|
|
||||||
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
|
||||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ps := mem.StatsCollector.GetProxyByName(name)
|
|
||||||
if ps == nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
|
||||||
}
|
|
||||||
return c.buildV2ProxyResp(ps), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/v2/proxies/{name}/traffic
|
|
||||||
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
|
|
||||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
|
|
||||||
if proxyTrafficInfo == nil {
|
|
||||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
|
|
||||||
raw := ctx.Param(key)
|
|
||||||
if raw == "" {
|
|
||||||
return "", fmt.Errorf("missing %s", label)
|
|
||||||
}
|
|
||||||
decoded, err := url.PathUnescape(raw)
|
|
||||||
if err != nil {
|
|
||||||
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
|
|
||||||
}
|
|
||||||
return decoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
|
||||||
item, ok := items[user]
|
|
||||||
if !ok {
|
|
||||||
item = &model.V2UserResp{User: user}
|
|
||||||
items[user] = item
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseV2PageParams(ctx *httppkg.Context) (int, int, error) {
|
|
||||||
page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page")
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize")
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
if pageSize > maxV2PageSize {
|
|
||||||
return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize))
|
|
||||||
}
|
|
||||||
if page > math.MaxInt/pageSize {
|
|
||||||
return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large")
|
|
||||||
}
|
|
||||||
return page, pageSize, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) {
|
|
||||||
if raw == "" {
|
|
||||||
return defaultValue, nil
|
|
||||||
}
|
|
||||||
value, err := strconv.Atoi(raw)
|
|
||||||
if err != nil || value < 1 {
|
|
||||||
return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name))
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseV2StatusFilter(raw string) (string, error) {
|
|
||||||
status := strings.ToLower(raw)
|
|
||||||
switch status {
|
|
||||||
case "", "all", "online", "offline":
|
|
||||||
return status, nil
|
|
||||||
default:
|
|
||||||
return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseV2ProxyTypeFilter(raw string) (string, error) {
|
|
||||||
proxyType := strings.ToLower(raw)
|
|
||||||
if proxyType == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if slices.Contains(apiV2ProxyTypes, proxyType) {
|
|
||||||
return proxyType, nil
|
|
||||||
}
|
|
||||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseV2SystemPruneType(raw string) (string, error) {
|
|
||||||
pruneType := strings.ToLower(raw)
|
|
||||||
switch pruneType {
|
|
||||||
case "":
|
|
||||||
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
|
|
||||||
case v2SystemPruneTypeOfflineProxies:
|
|
||||||
return pruneType, nil
|
|
||||||
default:
|
|
||||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchV2StatusFilter(online bool, filter string) bool {
|
|
||||||
switch filter {
|
|
||||||
case "", "all":
|
|
||||||
return true
|
|
||||||
case "online":
|
|
||||||
return online
|
|
||||||
case "offline":
|
|
||||||
return !online
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] {
|
|
||||||
total := len(items)
|
|
||||||
return model.V2PageResp[T]{
|
|
||||||
Total: total,
|
|
||||||
Page: page,
|
|
||||||
PageSize: pageSize,
|
|
||||||
Items: paginateV2Items(items, page, pageSize),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func paginateV2Items[T any](items []T, page, pageSize int) []T {
|
|
||||||
start := (page - 1) * pageSize
|
|
||||||
if start >= len(items) {
|
|
||||||
return []T{}
|
|
||||||
}
|
|
||||||
end := min(start+pageSize, len(items))
|
|
||||||
return items[start:end]
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryValue(ctx *httppkg.Context, key string) (string, bool) {
|
|
||||||
values, ok := ctx.Req.URL.Query()[key]
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if len(values) == 0 {
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
return values[0], true
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
|
|
||||||
return containsV2Query(q,
|
|
||||||
item.Key,
|
|
||||||
item.User,
|
|
||||||
item.ClientID,
|
|
||||||
item.RunID,
|
|
||||||
item.Version,
|
|
||||||
item.WireProtocol,
|
|
||||||
item.Hostname,
|
|
||||||
item.ClientIP,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
|
||||||
values := []string{
|
|
||||||
item.Name,
|
|
||||||
item.Spec.Type,
|
|
||||||
item.User,
|
|
||||||
item.ClientID,
|
|
||||||
item.Status.State,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch item.Spec.Type {
|
|
||||||
case string(v1.ProxyTypeTCP):
|
|
||||||
if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
|
|
||||||
values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
|
|
||||||
}
|
|
||||||
case string(v1.ProxyTypeUDP):
|
|
||||||
if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
|
|
||||||
values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
|
|
||||||
}
|
|
||||||
case string(v1.ProxyTypeHTTP):
|
|
||||||
if item.Spec.HTTP != nil {
|
|
||||||
values = append(values, item.Spec.HTTP.CustomDomains...)
|
|
||||||
values = append(values, item.Spec.HTTP.Subdomain)
|
|
||||||
}
|
|
||||||
case string(v1.ProxyTypeHTTPS):
|
|
||||||
if item.Spec.HTTPS != nil {
|
|
||||||
values = append(values, item.Spec.HTTPS.CustomDomains...)
|
|
||||||
values = append(values, item.Spec.HTTPS.Subdomain)
|
|
||||||
}
|
|
||||||
case string(v1.ProxyTypeTCPMUX):
|
|
||||||
if item.Spec.TCPMux != nil {
|
|
||||||
values = append(values, item.Spec.TCPMux.CustomDomains...)
|
|
||||||
values = append(values, item.Spec.TCPMux.Subdomain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return containsV2Query(q, values...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsV2Query(q string, values ...string) bool {
|
|
||||||
for _, value := range values {
|
|
||||||
if strings.Contains(strings.ToLower(value), q) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
|
||||||
if proxyType != "" {
|
|
||||||
return mem.StatsCollector.GetProxiesByType(proxyType)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*mem.ProxyStats, 0)
|
|
||||||
for _, t := range apiV2ProxyTypes {
|
|
||||||
items = append(items, mem.StatsCollector.GetProxiesByType(t)...)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
|
|
||||||
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
|
|
||||||
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
|
|
||||||
history = append(history, model.V2ProxyTrafficPointResp{
|
|
||||||
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
|
|
||||||
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
|
|
||||||
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return model.V2ProxyTrafficResp{
|
|
||||||
Name: name,
|
|
||||||
Unit: v2ProxyTrafficUnit,
|
|
||||||
Granularity: v2ProxyTrafficGranularity,
|
|
||||||
History: history,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
|
|
||||||
if todayFirstIndex >= len(values) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return values[todayFirstIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
|
||||||
status := model.V2ClientStatusResp{State: "offline"}
|
|
||||||
if info.Online {
|
|
||||||
status.State = "online"
|
|
||||||
}
|
|
||||||
|
|
||||||
user := info.User
|
|
||||||
clientID := info.ClientID()
|
|
||||||
for _, ps := range c.listV2ProxyStats("") {
|
|
||||||
if ps.User != user || ps.ClientID != clientID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status.CurConns += ps.CurConns
|
|
||||||
status.ProxyCount++
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
|
||||||
state := "offline"
|
|
||||||
var cfg v1.ProxyConfigurer
|
|
||||||
if c.pxyManager != nil {
|
|
||||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
|
||||||
state = "online"
|
|
||||||
cfg = pxy.GetConfigurer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return model.V2ProxyResp{
|
|
||||||
Name: ps.Name,
|
|
||||||
User: ps.User,
|
|
||||||
ClientID: ps.ClientID,
|
|
||||||
Spec: buildV2ProxySpec(ps.Type, cfg),
|
|
||||||
Status: model.V2ProxyStatusResp{
|
|
||||||
State: state,
|
|
||||||
TodayTrafficIn: ps.TodayTrafficIn,
|
|
||||||
TodayTrafficOut: ps.TodayTrafficOut,
|
|
||||||
CurConns: ps.CurConns,
|
|
||||||
LastStartAt: ps.LastStartAt,
|
|
||||||
LastCloseAt: ps.LastCloseAt,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
|
|
||||||
spec := model.V2ProxySpec{Type: proxyType}
|
|
||||||
|
|
||||||
switch proxyType {
|
|
||||||
case string(v1.ProxyTypeTCP):
|
|
||||||
block := &model.V2TCPProxySpec{}
|
|
||||||
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
|
|
||||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
|
||||||
block.RemotePort = &c.RemotePort
|
|
||||||
}
|
|
||||||
spec.TCP = block
|
|
||||||
case string(v1.ProxyTypeUDP):
|
|
||||||
block := &model.V2UDPProxySpec{}
|
|
||||||
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
|
|
||||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
|
||||||
block.RemotePort = &c.RemotePort
|
|
||||||
}
|
|
||||||
spec.UDP = block
|
|
||||||
case string(v1.ProxyTypeHTTP):
|
|
||||||
block := &model.V2HTTPProxySpec{}
|
|
||||||
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
|
|
||||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
|
||||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
|
||||||
block.Subdomain = c.SubDomain
|
|
||||||
block.Locations = slices.Clone(c.Locations)
|
|
||||||
block.HostHeaderRewrite = c.HostHeaderRewrite
|
|
||||||
}
|
|
||||||
spec.HTTP = block
|
|
||||||
case string(v1.ProxyTypeHTTPS):
|
|
||||||
block := &model.V2HTTPSProxySpec{}
|
|
||||||
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
|
|
||||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
|
||||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
|
||||||
block.Subdomain = c.SubDomain
|
|
||||||
}
|
|
||||||
spec.HTTPS = block
|
|
||||||
case string(v1.ProxyTypeTCPMUX):
|
|
||||||
block := &model.V2TCPMuxProxySpec{}
|
|
||||||
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
|
|
||||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
|
||||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
|
||||||
block.Subdomain = c.SubDomain
|
|
||||||
block.Multiplexer = c.Multiplexer
|
|
||||||
block.RouteByHTTPUser = c.RouteByHTTPUser
|
|
||||||
}
|
|
||||||
spec.TCPMux = block
|
|
||||||
case string(v1.ProxyTypeSTCP):
|
|
||||||
block := &model.V2STCPProxySpec{}
|
|
||||||
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
|
|
||||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
|
||||||
}
|
|
||||||
spec.STCP = block
|
|
||||||
case string(v1.ProxyTypeSUDP):
|
|
||||||
block := &model.V2SUDPProxySpec{}
|
|
||||||
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
|
|
||||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
|
||||||
}
|
|
||||||
spec.SUDP = block
|
|
||||||
case string(v1.ProxyTypeXTCP):
|
|
||||||
block := &model.V2XTCPProxySpec{}
|
|
||||||
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
|
|
||||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
|
||||||
}
|
|
||||||
spec.XTCP = block
|
|
||||||
}
|
|
||||||
|
|
||||||
return spec
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
|
|
||||||
return model.V2ProxyBaseSpec{
|
|
||||||
Annotations: maps.Clone(base.Annotations),
|
|
||||||
Metadatas: maps.Clone(base.Metadatas),
|
|
||||||
Transport: &model.V2ProxyTransportSpec{
|
|
||||||
UseEncryption: base.Transport.UseEncryption,
|
|
||||||
UseCompression: base.Transport.UseCompression,
|
|
||||||
BandwidthLimit: base.Transport.BandwidthLimit.String(),
|
|
||||||
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
|
|
||||||
},
|
|
||||||
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
|
|
||||||
Group: base.LoadBalancer.Group,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,908 +0,0 @@
|
|||||||
// Copyright 2026 The frp Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/types"
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
||||||
"github.com/fatedier/frp/server/http/model"
|
|
||||||
serverproxy "github.com/fatedier/frp/server/proxy"
|
|
||||||
"github.com/fatedier/frp/server/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
type v2EnvelopeForTest[T any] struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
Data T `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeStatsCollector struct {
|
|
||||||
server *mem.ServerStats
|
|
||||||
proxies map[string]*mem.ProxyStats
|
|
||||||
traffic map[string]*mem.ProxyTrafficInfo
|
|
||||||
pruneable map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
|
||||||
if f.server != nil {
|
|
||||||
return f.server
|
|
||||||
}
|
|
||||||
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
|
|
||||||
items := make([]*mem.ProxyStats, 0)
|
|
||||||
for _, ps := range f.proxies {
|
|
||||||
if ps.Type == proxyType {
|
|
||||||
items = append(items, ps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
|
|
||||||
ps := f.proxies[proxyName]
|
|
||||||
if ps != nil && ps.Type == proxyType {
|
|
||||||
return ps
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
|
||||||
return f.proxies[proxyName]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
|
||||||
return f.traffic[name]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
|
||||||
return 0, len(f.proxies)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
|
|
||||||
total := len(f.proxies)
|
|
||||||
cleared := 0
|
|
||||||
for name := range f.pruneable {
|
|
||||||
if _, ok := f.proxies[name]; ok {
|
|
||||||
delete(f.proxies, name)
|
|
||||||
cleared++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f.pruneable = map[string]bool{}
|
|
||||||
return cleared, total
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
|
|
||||||
oldStatsCollector := mem.StatsCollector
|
|
||||||
mem.StatsCollector = &fakeStatsCollector{
|
|
||||||
server: &mem.ServerStats{
|
|
||||||
TotalTrafficIn: 1024,
|
|
||||||
TotalTrafficOut: 2048,
|
|
||||||
CurConns: 3,
|
|
||||||
ClientCounts: 4,
|
|
||||||
ProxyTypeCounts: map[string]int64{
|
|
||||||
"tcp": 2,
|
|
||||||
"http": 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
proxies: map[string]*mem.ProxyStats{},
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
mem.StatsCollector = oldStatsCollector
|
|
||||||
})
|
|
||||||
|
|
||||||
controller := NewController(&v1.ServerConfig{
|
|
||||||
BindPort: 7000,
|
|
||||||
VhostHTTPPort: 8080,
|
|
||||||
VhostHTTPSPort: 8443,
|
|
||||||
TCPMuxHTTPConnectPort: 9000,
|
|
||||||
KCPBindPort: 7001,
|
|
||||||
QUICBindPort: 7002,
|
|
||||||
SubDomainHost: "example.com",
|
|
||||||
MaxPortsPerClient: 8,
|
|
||||||
AllowPorts: []types.PortsRange{
|
|
||||||
{Start: 1000, End: 1002},
|
|
||||||
{Single: 2000},
|
|
||||||
},
|
|
||||||
Transport: v1.ServerTransportConfig{
|
|
||||||
MaxPoolCount: 5,
|
|
||||||
HeartbeatTimeout: 90,
|
|
||||||
TLS: v1.TLSServerConfig{
|
|
||||||
Force: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, registry.NewClientRegistry(), serverproxy.NewManager())
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequest(router, "/api/v2/system/info")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
|
||||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
|
||||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
|
||||||
}
|
|
||||||
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
|
|
||||||
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
|
|
||||||
"allowPortsStr",
|
|
||||||
"bindPort",
|
|
||||||
"heartbeatTimeout",
|
|
||||||
"kcpBindPort",
|
|
||||||
"maxPoolCount",
|
|
||||||
"maxPortsPerClient",
|
|
||||||
"quicBindPort",
|
|
||||||
"subdomainHost",
|
|
||||||
"tcpmuxHTTPConnectPort",
|
|
||||||
"tlsForce",
|
|
||||||
"vhostHTTPPort",
|
|
||||||
"vhostHTTPSPort",
|
|
||||||
)
|
|
||||||
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
|
|
||||||
"clientCounts",
|
|
||||||
"curConns",
|
|
||||||
"proxyTypeCount",
|
|
||||||
"totalTrafficIn",
|
|
||||||
"totalTrafficOut",
|
|
||||||
)
|
|
||||||
|
|
||||||
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
|
|
||||||
if systemResp.Data.Version == "" {
|
|
||||||
t.Fatal("version should be set at top level")
|
|
||||||
}
|
|
||||||
if systemResp.Data.Config.BindPort != 7000 ||
|
|
||||||
systemResp.Data.Config.VhostHTTPPort != 8080 ||
|
|
||||||
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
|
|
||||||
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
|
|
||||||
systemResp.Data.Config.KCPBindPort != 7001 ||
|
|
||||||
systemResp.Data.Config.QUICBindPort != 7002 ||
|
|
||||||
systemResp.Data.Config.SubdomainHost != "example.com" ||
|
|
||||||
systemResp.Data.Config.MaxPoolCount != 5 ||
|
|
||||||
systemResp.Data.Config.MaxPortsPerClient != 8 ||
|
|
||||||
systemResp.Data.Config.HeartbeatTimeout != 90 ||
|
|
||||||
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
|
|
||||||
!systemResp.Data.Config.TLSForce {
|
|
||||||
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
|
|
||||||
}
|
|
||||||
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
|
|
||||||
systemResp.Data.Status.TotalTrafficOut != 2048 ||
|
|
||||||
systemResp.Data.Status.CurConns != 3 ||
|
|
||||||
systemResp.Data.Status.ClientCounts != 4 ||
|
|
||||||
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
|
|
||||||
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
|
|
||||||
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
|
|
||||||
oldStatsCollector := mem.StatsCollector
|
|
||||||
collector := &fakeStatsCollector{
|
|
||||||
proxies: map[string]*mem.ProxyStats{
|
|
||||||
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
|
|
||||||
"http-offline": {Name: "http-offline", Type: "http"},
|
|
||||||
"udp-offline": {Name: "udp-offline", Type: "udp"},
|
|
||||||
"tcp-online": {Name: "tcp-online", Type: "tcp"},
|
|
||||||
"http-online": {Name: "http-online", Type: "http"},
|
|
||||||
"udp-online": {Name: "udp-online", Type: "udp"},
|
|
||||||
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
|
|
||||||
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
|
|
||||||
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
|
|
||||||
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
|
|
||||||
},
|
|
||||||
pruneable: map[string]bool{
|
|
||||||
"tcp-offline": true,
|
|
||||||
"http-offline": true,
|
|
||||||
"udp-offline": true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
mem.StatsCollector = collector
|
|
||||||
t.Cleanup(func() {
|
|
||||||
mem.StatsCollector = oldStatsCollector
|
|
||||||
})
|
|
||||||
|
|
||||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
|
||||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
|
||||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
|
||||||
}
|
|
||||||
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
|
|
||||||
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
|
||||||
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
|
|
||||||
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
|
|
||||||
}
|
|
||||||
if _, ok := collector.proxies["tcp-offline"]; ok {
|
|
||||||
t.Fatal("pruned proxy statistics should be removed")
|
|
||||||
}
|
|
||||||
if _, ok := collector.proxies["tcp-online"]; !ok {
|
|
||||||
t.Fatal("online proxy statistics should remain")
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
|
||||||
}
|
|
||||||
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
|
||||||
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
|
|
||||||
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
|
|
||||||
controller := newV2TestController(t)
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
|
|
||||||
if resp.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
||||||
}
|
|
||||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
|
||||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
|
|
||||||
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
|
|
||||||
if resp.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
||||||
}
|
|
||||||
errResp = decodeResponse[httppkg.V2Response](t, resp)
|
|
||||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
|
|
||||||
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
|
||||||
controller := newV2TestController(t)
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
|
||||||
}
|
|
||||||
pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
|
||||||
if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
|
|
||||||
t.Fatalf("envelope mismatch: %#v", pageResp)
|
|
||||||
}
|
|
||||||
if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
|
|
||||||
t.Fatalf("page data mismatch: %#v", pageResp.Data)
|
|
||||||
}
|
|
||||||
if got := pageResp.Data.Items[0].User; got != "" {
|
|
||||||
t.Fatalf("first sorted user mismatch, want empty got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
|
|
||||||
emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
|
||||||
if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
|
|
||||||
t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
|
|
||||||
aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
|
||||||
if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
|
|
||||||
t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/clients?status=offline")
|
|
||||||
offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
|
||||||
if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
|
|
||||||
t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
|
|
||||||
controller := newV2TestController(t)
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequest(router, "/api/v2/clients?page=0")
|
|
||||||
if resp.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
||||||
}
|
|
||||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
|
||||||
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
|
||||||
t.Fatalf("error envelope mismatch: %#v", errResp)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/clients?pageSize=201")
|
|
||||||
if resp.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
|
|
||||||
if resp.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
|
||||||
controller := newV2TestController(t)
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequest(router, "/api/v2/clients/alice.client-a")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
|
||||||
}
|
|
||||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
|
||||||
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
|
|
||||||
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
|
|
||||||
}
|
|
||||||
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
|
|
||||||
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
|
|
||||||
oldStatsCollector := mem.StatsCollector
|
|
||||||
mem.StatsCollector = &fakeStatsCollector{
|
|
||||||
proxies: map[string]*mem.ProxyStats{
|
|
||||||
"tcp-url": {
|
|
||||||
Name: "tcp-url",
|
|
||||||
Type: "tcp",
|
|
||||||
User: "url",
|
|
||||||
ClientID: "client/a?b#c",
|
|
||||||
CurConns: 7,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
mem.StatsCollector = oldStatsCollector
|
|
||||||
})
|
|
||||||
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
|
|
||||||
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
encodedKey := url.PathEscape("url.client/a?b#c")
|
|
||||||
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
|
||||||
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
|
|
||||||
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
|
|
||||||
}
|
|
||||||
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
|
|
||||||
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
|
||||||
controller := newV2TestController(t)
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequest(router, "/api/v2/proxies?type=invalid")
|
|
||||||
if resp.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
||||||
}
|
|
||||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
|
||||||
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
|
||||||
t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
|
|
||||||
proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
|
|
||||||
if proxyResp.Data.Total != 1 {
|
|
||||||
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
|
||||||
}
|
|
||||||
proxyItem := proxyResp.Data.Items[0]
|
|
||||||
if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
|
||||||
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
|
||||||
}
|
|
||||||
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
|
|
||||||
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
|
|
||||||
var rawListSpec map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
|
|
||||||
t.Fatalf("unmarshal list proxy spec failed: %v", err)
|
|
||||||
}
|
|
||||||
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
|
|
||||||
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
|
||||||
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
|
||||||
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
|
|
||||||
"curConns",
|
|
||||||
"lastCloseAt",
|
|
||||||
"lastStartAt",
|
|
||||||
"phase",
|
|
||||||
"todayTrafficIn",
|
|
||||||
"todayTrafficOut",
|
|
||||||
)
|
|
||||||
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
|
||||||
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
|
|
||||||
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
|
|
||||||
}
|
|
||||||
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
|
|
||||||
var rawDetailSpec map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
|
|
||||||
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
|
|
||||||
}
|
|
||||||
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
|
|
||||||
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
|
|
||||||
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
|
|
||||||
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
|
||||||
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
|
||||||
if userResp.Data.Total != 3 {
|
|
||||||
t.Fatalf("user total mismatch: %#v", userResp.Data)
|
|
||||||
}
|
|
||||||
expectedProxyCounts := map[string]int{
|
|
||||||
"": 1,
|
|
||||||
"alice": 2,
|
|
||||||
"bob": 1,
|
|
||||||
}
|
|
||||||
for _, item := range userResp.Data.Items {
|
|
||||||
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
|
|
||||||
t.Fatalf("user counts mismatch: %#v", item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
|
|
||||||
oldStatsCollector := mem.StatsCollector
|
|
||||||
mem.StatsCollector = &fakeStatsCollector{
|
|
||||||
proxies: map[string]*mem.ProxyStats{
|
|
||||||
"ssh": {Name: "ssh", Type: "tcp"},
|
|
||||||
},
|
|
||||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
|
||||||
"ssh": {
|
|
||||||
Name: "ssh",
|
|
||||||
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
|
|
||||||
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
mem.StatsCollector = oldStatsCollector
|
|
||||||
})
|
|
||||||
|
|
||||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
|
||||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
|
||||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
|
||||||
}
|
|
||||||
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
|
|
||||||
|
|
||||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
|
||||||
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
|
|
||||||
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
|
|
||||||
}
|
|
||||||
if len(trafficResp.Data.History) != 7 {
|
|
||||||
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
|
|
||||||
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
|
|
||||||
var prevDate time.Time
|
|
||||||
for i, point := range trafficResp.Data.History {
|
|
||||||
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
|
|
||||||
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
|
|
||||||
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
|
|
||||||
}
|
|
||||||
parsedDate, err := time.Parse(time.DateOnly, point.Date)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
|
|
||||||
}
|
|
||||||
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
|
|
||||||
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
|
|
||||||
}
|
|
||||||
prevDate = parsedDate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
|
|
||||||
controller := newV2TestController(t)
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
|
|
||||||
if resp.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
|
||||||
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
|
|
||||||
t.Fatalf("not found envelope mismatch: %#v", errResp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
|
|
||||||
name := "folder/ssh?x#y"
|
|
||||||
oldStatsCollector := mem.StatsCollector
|
|
||||||
mem.StatsCollector = &fakeStatsCollector{
|
|
||||||
proxies: map[string]*mem.ProxyStats{
|
|
||||||
name: {Name: name, Type: "tcp", User: "encoded"},
|
|
||||||
},
|
|
||||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
|
||||||
name: {
|
|
||||||
Name: name,
|
|
||||||
TrafficIn: []int64{1},
|
|
||||||
TrafficOut: []int64{2},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
mem.StatsCollector = oldStatsCollector
|
|
||||||
})
|
|
||||||
|
|
||||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
encodedName := url.PathEscape(name)
|
|
||||||
|
|
||||||
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
|
||||||
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
|
|
||||||
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
|
||||||
if trafficResp.Data.Name != name {
|
|
||||||
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
|
|
||||||
}
|
|
||||||
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
|
|
||||||
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
|
|
||||||
controller := newV2TestController(t)
|
|
||||||
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
|
|
||||||
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
|
|
||||||
resp := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(resp, req)
|
|
||||||
|
|
||||||
if resp.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
|
||||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
|
|
||||||
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
item model.V2ProxyResp
|
|
||||||
q string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "tcp remote port",
|
|
||||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
|
||||||
Type: "tcp",
|
|
||||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
|
||||||
}},
|
|
||||||
q: "6000",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "udp remote port",
|
|
||||||
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
|
|
||||||
Type: "udp",
|
|
||||||
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
|
|
||||||
}},
|
|
||||||
q: "7000",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "remote port does not match colon form",
|
|
||||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
|
||||||
Type: "tcp",
|
|
||||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
|
||||||
}},
|
|
||||||
q: ":6000",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "http custom domain",
|
|
||||||
item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
|
|
||||||
Type: "http",
|
|
||||||
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
|
|
||||||
}},
|
|
||||||
q: "app.example.com",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "https subdomain",
|
|
||||||
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
|
||||||
Type: "https",
|
|
||||||
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
|
||||||
}},
|
|
||||||
q: "portal",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "subdomain does not match expanded host",
|
|
||||||
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
|
||||||
Type: "https",
|
|
||||||
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
|
||||||
}},
|
|
||||||
q: "portal.example.com",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tcpmux custom domain",
|
|
||||||
item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
|
|
||||||
Type: "tcpmux",
|
|
||||||
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
|
|
||||||
}},
|
|
||||||
q: "mux.example.com",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "offline shell does not match online spec fields",
|
|
||||||
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
|
||||||
Type: "tcp",
|
|
||||||
TCP: &model.V2TCPProxySpec{},
|
|
||||||
}},
|
|
||||||
q: "6000",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "offline shell does not contribute zero remote port",
|
|
||||||
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
|
||||||
Type: "tcp",
|
|
||||||
TCP: &model.V2TCPProxySpec{},
|
|
||||||
}},
|
|
||||||
q: "0",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
|
|
||||||
t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
|
||||||
controller := newV2TestController(t)
|
|
||||||
router := newV2TestRouter(controller)
|
|
||||||
|
|
||||||
resp := performRequest(router, "/api/serverinfo")
|
|
||||||
var serverInfo model.ServerInfoResp
|
|
||||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
|
|
||||||
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
|
|
||||||
}
|
|
||||||
if serverInfo.Version == "" {
|
|
||||||
t.Fatal("legacy serverinfo version should be set")
|
|
||||||
}
|
|
||||||
var serverInfoRaw map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
|
|
||||||
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := serverInfoRaw["data"]; ok {
|
|
||||||
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
|
|
||||||
}
|
|
||||||
if _, ok := serverInfoRaw["config"]; ok {
|
|
||||||
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/clients")
|
|
||||||
var clients []model.ClientInfoResp
|
|
||||||
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
|
|
||||||
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
|
|
||||||
}
|
|
||||||
if len(clients) != 3 {
|
|
||||||
t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = performRequest(router, "/api/proxy/tcp")
|
|
||||||
var proxies model.GetProxyInfoResp
|
|
||||||
if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
|
|
||||||
t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
|
|
||||||
}
|
|
||||||
if len(proxies.Proxies) != 2 {
|
|
||||||
t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
|
|
||||||
}
|
|
||||||
var envelope httppkg.V2Response
|
|
||||||
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
|
||||||
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
oldStatsCollector := mem.StatsCollector
|
|
||||||
mem.StatsCollector = &fakeStatsCollector{
|
|
||||||
proxies: map[string]*mem.ProxyStats{
|
|
||||||
"tcp-empty": {
|
|
||||||
Name: "tcp-empty",
|
|
||||||
Type: "tcp",
|
|
||||||
User: "",
|
|
||||||
ClientID: "legacy-client",
|
|
||||||
TodayTrafficIn: 10,
|
|
||||||
TodayTrafficOut: 20,
|
|
||||||
CurConns: 1,
|
|
||||||
},
|
|
||||||
"tcp-alice": {
|
|
||||||
Name: "tcp-alice",
|
|
||||||
Type: "tcp",
|
|
||||||
User: "alice",
|
|
||||||
ClientID: "client-a",
|
|
||||||
TodayTrafficIn: 30,
|
|
||||||
TodayTrafficOut: 40,
|
|
||||||
CurConns: 2,
|
|
||||||
LastStartTime: "07-08 12:30:00",
|
|
||||||
LastCloseTime: "07-08 12:31:40",
|
|
||||||
LastStartAt: 1783504200,
|
|
||||||
LastCloseAt: 1783504300,
|
|
||||||
},
|
|
||||||
"http-alice": {
|
|
||||||
Name: "http-alice",
|
|
||||||
Type: "http",
|
|
||||||
User: "alice",
|
|
||||||
ClientID: "client-a",
|
|
||||||
CurConns: 3,
|
|
||||||
},
|
|
||||||
"udp-bob": {
|
|
||||||
Name: "udp-bob",
|
|
||||||
Type: "udp",
|
|
||||||
User: "bob",
|
|
||||||
ClientID: "client-b",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
|
||||||
"tcp-alice": {
|
|
||||||
Name: "tcp-alice",
|
|
||||||
TrafficIn: []int64{7, 6},
|
|
||||||
TrafficOut: []int64{70, 60},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
mem.StatsCollector = oldStatsCollector
|
|
||||||
})
|
|
||||||
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
|
|
||||||
clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
|
|
||||||
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
|
|
||||||
clientRegistry.MarkOfflineByRunID("run-b")
|
|
||||||
|
|
||||||
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
|
||||||
}
|
|
||||||
|
|
||||||
func newV2TestRouter(controller *Controller) *mux.Router {
|
|
||||||
router := mux.NewRouter()
|
|
||||||
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
|
|
||||||
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
|
|
||||||
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
|
|
||||||
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
|
|
||||||
encodedPathRouter := router.NewRoute().Subrouter()
|
|
||||||
encodedPathRouter.UseEncodedPath()
|
|
||||||
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
|
||||||
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
|
|
||||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
|
|
||||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
|
||||||
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
|
|
||||||
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
|
||||||
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
|
||||||
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
|
||||||
return performRequestWithMethod(handler, http.MethodGet, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequest(method, target, nil)
|
|
||||||
resp := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(resp, req)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out T
|
|
||||||
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
|
|
||||||
t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
// Copyright 2026 The frp Authors
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
type V2PageResp[T any] struct {
|
|
||||||
Total int `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
PageSize int `json:"pageSize"`
|
|
||||||
Items []T `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2SystemInfoResp struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Config V2SystemInfoConfigResp `json:"config"`
|
|
||||||
Status V2SystemInfoStatusResp `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2SystemInfoConfigResp struct {
|
|
||||||
BindPort int `json:"bindPort"`
|
|
||||||
VhostHTTPPort int `json:"vhostHTTPPort"`
|
|
||||||
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
|
||||||
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
|
||||||
KCPBindPort int `json:"kcpBindPort"`
|
|
||||||
QUICBindPort int `json:"quicBindPort"`
|
|
||||||
SubdomainHost string `json:"subdomainHost"`
|
|
||||||
MaxPoolCount int64 `json:"maxPoolCount"`
|
|
||||||
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
|
||||||
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
|
|
||||||
AllowPortsStr string `json:"allowPortsStr"`
|
|
||||||
TLSForce bool `json:"tlsForce"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2SystemInfoStatusResp struct {
|
|
||||||
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
|
||||||
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
|
||||||
CurConns int64 `json:"curConns"`
|
|
||||||
ClientCounts int64 `json:"clientCounts"`
|
|
||||||
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2SystemPruneResp struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Cleared int `json:"cleared"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2UserResp struct {
|
|
||||||
User string `json:"user"`
|
|
||||||
ClientCount int `json:"clientCount"`
|
|
||||||
ProxyCount int `json:"proxyCount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ClientDetailResp struct {
|
|
||||||
ClientInfoResp
|
|
||||||
Status V2ClientStatusResp `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ClientStatusResp struct {
|
|
||||||
State string `json:"phase"`
|
|
||||||
CurConns int64 `json:"curConns"`
|
|
||||||
ProxyCount int64 `json:"proxyCount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ProxyResp struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
User string `json:"user"`
|
|
||||||
ClientID string `json:"clientID"`
|
|
||||||
Spec V2ProxySpec `json:"spec"`
|
|
||||||
Status V2ProxyStatusResp `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ProxySpec struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
|
|
||||||
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
|
|
||||||
UDP *V2UDPProxySpec `json:"udp,omitempty"`
|
|
||||||
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
|
|
||||||
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
|
|
||||||
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
|
|
||||||
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
|
|
||||||
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
|
|
||||||
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ProxyBaseSpec struct {
|
|
||||||
Annotations map[string]string `json:"annotations,omitempty"`
|
|
||||||
Metadatas map[string]string `json:"metadatas,omitempty"`
|
|
||||||
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
|
|
||||||
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ProxyTransportSpec struct {
|
|
||||||
UseEncryption bool `json:"useEncryption"`
|
|
||||||
UseCompression bool `json:"useCompression"`
|
|
||||||
BandwidthLimit string `json:"bandwidthLimit"`
|
|
||||||
BandwidthLimitMode string `json:"bandwidthLimitMode"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ProxyLoadBalancerSpec struct {
|
|
||||||
Group string `json:"group"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2TCPProxySpec struct {
|
|
||||||
V2ProxyBaseSpec
|
|
||||||
RemotePort *int `json:"remotePort,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2UDPProxySpec struct {
|
|
||||||
V2ProxyBaseSpec
|
|
||||||
RemotePort *int `json:"remotePort,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2HTTPProxySpec struct {
|
|
||||||
V2ProxyBaseSpec
|
|
||||||
CustomDomains []string `json:"customDomains,omitempty"`
|
|
||||||
Subdomain string `json:"subdomain,omitempty"`
|
|
||||||
Locations []string `json:"locations,omitempty"`
|
|
||||||
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2HTTPSProxySpec struct {
|
|
||||||
V2ProxyBaseSpec
|
|
||||||
CustomDomains []string `json:"customDomains,omitempty"`
|
|
||||||
Subdomain string `json:"subdomain,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2TCPMuxProxySpec struct {
|
|
||||||
V2ProxyBaseSpec
|
|
||||||
CustomDomains []string `json:"customDomains,omitempty"`
|
|
||||||
Subdomain string `json:"subdomain,omitempty"`
|
|
||||||
Multiplexer string `json:"multiplexer,omitempty"`
|
|
||||||
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2STCPProxySpec struct {
|
|
||||||
V2ProxyBaseSpec
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2SUDPProxySpec struct {
|
|
||||||
V2ProxyBaseSpec
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2XTCPProxySpec struct {
|
|
||||||
V2ProxyBaseSpec
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ProxyStatusResp struct {
|
|
||||||
State string `json:"phase"`
|
|
||||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
|
||||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
|
||||||
CurConns int64 `json:"curConns"`
|
|
||||||
LastStartAt int64 `json:"lastStartAt,omitempty"`
|
|
||||||
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ProxyTrafficResp struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Unit string `json:"unit"`
|
|
||||||
Granularity string `json:"granularity"`
|
|
||||||
History []V2ProxyTrafficPointResp `json:"history"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type V2ProxyTrafficPointResp struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
TrafficIn int64 `json:"trafficIn"`
|
|
||||||
TrafficOut int64 `json:"trafficOut"`
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,6 @@ type ClientInfo struct {
|
|||||||
User string
|
User string
|
||||||
RawClientID string
|
RawClientID string
|
||||||
RunID string
|
RunID string
|
||||||
ControlID uint64
|
|
||||||
Hostname string
|
Hostname string
|
||||||
IP string
|
IP string
|
||||||
Version string
|
Version string
|
||||||
@@ -65,16 +64,6 @@ func newClientRegistryWithClock(clk clock.PassiveClock) *ClientRegistry {
|
|||||||
|
|
||||||
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
|
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
|
||||||
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol string) (key string, conflict bool) {
|
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol string) (key string, conflict bool) {
|
||||||
return cr.RegisterWithControlID(user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterWithControlID is the generation-aware form used by ControlManager.
|
|
||||||
// A control ID is process-local and prevents an older control generation from
|
|
||||||
// changing the registry entry now owned by a newer generation with the same run ID.
|
|
||||||
func (cr *ClientRegistry) RegisterWithControlID(
|
|
||||||
user, rawClientID, runID, hostname, version, remoteAddr, wireProtocol string,
|
|
||||||
controlID uint64,
|
|
||||||
) (key string, conflict bool) {
|
|
||||||
if runID == "" {
|
if runID == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -94,16 +83,6 @@ func (cr *ClientRegistry) RegisterWithControlID(
|
|||||||
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
|
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
|
||||||
return key, true
|
return key, true
|
||||||
}
|
}
|
||||||
if previousKey, ok := cr.runIndex[runID]; ok && previousKey != key {
|
|
||||||
if previous, ok := cr.clients[previousKey]; ok && previous.RunID == runID {
|
|
||||||
if previous.RawClientID == "" {
|
|
||||||
delete(cr.clients, previousKey)
|
|
||||||
} else {
|
|
||||||
setClientOffline(previous, now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(cr.runIndex, runID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
info = &ClientInfo{
|
info = &ClientInfo{
|
||||||
@@ -118,7 +97,6 @@ func (cr *ClientRegistry) RegisterWithControlID(
|
|||||||
|
|
||||||
info.RawClientID = rawClientID
|
info.RawClientID = rawClientID
|
||||||
info.RunID = runID
|
info.RunID = runID
|
||||||
info.ControlID = controlID
|
|
||||||
info.Hostname = hostname
|
info.Hostname = hostname
|
||||||
info.IP = remoteAddr
|
info.IP = remoteAddr
|
||||||
info.Version = version
|
info.Version = version
|
||||||
@@ -136,16 +114,6 @@ func (cr *ClientRegistry) RegisterWithControlID(
|
|||||||
|
|
||||||
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
|
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
|
||||||
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
||||||
cr.markOfflineByRunID(runID, 0, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkOfflineByRunIDAndControlID marks a client offline only when the registry
|
|
||||||
// entry still belongs to the supplied control generation.
|
|
||||||
func (cr *ClientRegistry) MarkOfflineByRunIDAndControlID(runID string, controlID uint64) {
|
|
||||||
cr.markOfflineByRunID(runID, controlID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *ClientRegistry) markOfflineByRunID(runID string, controlID uint64, matchControlID bool) {
|
|
||||||
cr.mu.Lock()
|
cr.mu.Lock()
|
||||||
defer cr.mu.Unlock()
|
defer cr.mu.Unlock()
|
||||||
|
|
||||||
@@ -153,24 +121,18 @@ func (cr *ClientRegistry) markOfflineByRunID(runID string, controlID uint64, mat
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if info, ok := cr.clients[key]; ok && info.RunID == runID && (!matchControlID || info.ControlID == controlID) {
|
if info, ok := cr.clients[key]; ok && info.RunID == runID {
|
||||||
if info.RawClientID == "" {
|
if info.RawClientID == "" {
|
||||||
delete(cr.clients, key)
|
delete(cr.clients, key)
|
||||||
} else {
|
} else {
|
||||||
setClientOffline(info, cr.clock.Now())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if info, ok := cr.clients[key]; !ok || info.RunID != runID {
|
|
||||||
delete(cr.runIndex, runID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setClientOffline(info *ClientInfo, now time.Time) {
|
|
||||||
info.RunID = ""
|
info.RunID = ""
|
||||||
info.ControlID = 0
|
|
||||||
info.Online = false
|
info.Online = false
|
||||||
|
now := cr.clock.Now()
|
||||||
info.DisconnectedAt = now
|
info.DisconnectedAt = now
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
delete(cr.runIndex, runID)
|
||||||
|
}
|
||||||
|
|
||||||
// List returns a snapshot of all known clients.
|
// List returns a snapshot of all known clients.
|
||||||
func (cr *ClientRegistry) List() []ClientInfo {
|
func (cr *ClientRegistry) List() []ClientInfo {
|
||||||
|
|||||||
@@ -72,89 +72,3 @@ func TestClientRegistryUsesClockForTimestamps(t *testing.T) {
|
|||||||
t.Fatalf("disconnected time mismatch, want %s got %s", disconnectedAt, info.DisconnectedAt)
|
t.Fatalf("disconnected time mismatch, want %s got %s", disconnectedAt, info.DisconnectedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientRegistryControlIDPreventsStaleOffline(t *testing.T) {
|
|
||||||
registry := NewClientRegistry()
|
|
||||||
key, conflict := registry.RegisterWithControlID(
|
|
||||||
"user", "client-id", "run-id", "old-host", "1.0.0", "127.0.0.1", wire.ProtocolV1, 1,
|
|
||||||
)
|
|
||||||
if conflict {
|
|
||||||
t.Fatal("unexpected client conflict")
|
|
||||||
}
|
|
||||||
_, conflict = registry.RegisterWithControlID(
|
|
||||||
"user", "client-id", "run-id", "new-host", "1.0.1", "127.0.0.2", wire.ProtocolV2, 2,
|
|
||||||
)
|
|
||||||
if conflict {
|
|
||||||
t.Fatal("same run ID replacement should not conflict")
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.MarkOfflineByRunIDAndControlID("run-id", 1)
|
|
||||||
info, ok := registry.GetByKey(key)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("client %q not found", key)
|
|
||||||
}
|
|
||||||
if !info.Online || info.ControlID != 2 || info.Hostname != "new-host" {
|
|
||||||
t.Fatalf("stale offline changed current generation: %+v", info)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.MarkOfflineByRunIDAndControlID("run-id", 2)
|
|
||||||
info, ok = registry.GetByKey(key)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("client %q not found after disconnect", key)
|
|
||||||
}
|
|
||||||
if info.Online || info.ControlID != 0 || info.RunID != "" {
|
|
||||||
t.Fatalf("current generation was not marked offline: %+v", info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientRegistryClientIDConflictSemantics(t *testing.T) {
|
|
||||||
registry := NewClientRegistry()
|
|
||||||
_, conflict := registry.RegisterWithControlID(
|
|
||||||
"user", "client-id", "run-one", "host", "1.0.0", "127.0.0.1", wire.ProtocolV1, 1,
|
|
||||||
)
|
|
||||||
if conflict {
|
|
||||||
t.Fatal("unexpected initial client conflict")
|
|
||||||
}
|
|
||||||
_, conflict = registry.RegisterWithControlID(
|
|
||||||
"user", "client-id", "run-two", "host", "1.0.0", "127.0.0.2", wire.ProtocolV1, 2,
|
|
||||||
)
|
|
||||||
if !conflict {
|
|
||||||
t.Fatal("different online run IDs with the same explicit client ID must conflict")
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.MarkOfflineByRunIDAndControlID("run-one", 1)
|
|
||||||
_, conflict = registry.RegisterWithControlID(
|
|
||||||
"user", "client-id", "run-two", "host", "1.0.0", "127.0.0.2", wire.ProtocolV1, 2,
|
|
||||||
)
|
|
||||||
if conflict {
|
|
||||||
t.Fatal("offline explicit client ID should be reusable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientRegistrySameRunIDMovesBetweenClientKeys(t *testing.T) {
|
|
||||||
registry := NewClientRegistry()
|
|
||||||
oldKey, conflict := registry.RegisterWithControlID(
|
|
||||||
"user", "old-client", "run-id", "old-host", "1.0.0", "127.0.0.1", wire.ProtocolV1, 1,
|
|
||||||
)
|
|
||||||
if conflict {
|
|
||||||
t.Fatal("unexpected initial client conflict")
|
|
||||||
}
|
|
||||||
newKey, conflict := registry.RegisterWithControlID(
|
|
||||||
"user", "new-client", "run-id", "new-host", "1.0.1", "127.0.0.2", wire.ProtocolV2, 2,
|
|
||||||
)
|
|
||||||
if conflict {
|
|
||||||
t.Fatal("same run ID moving to a new client key should not conflict")
|
|
||||||
}
|
|
||||||
|
|
||||||
oldInfo, ok := registry.GetByKey(oldKey)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("old explicit client %q should remain as offline history", oldKey)
|
|
||||||
}
|
|
||||||
if oldInfo.Online || oldInfo.RunID != "" || oldInfo.ControlID != 0 {
|
|
||||||
t.Fatalf("old client key remained online: %+v", oldInfo)
|
|
||||||
}
|
|
||||||
newInfo, ok := registry.GetByKey(newKey)
|
|
||||||
if !ok || !newInfo.Online || newInfo.RunID != "run-id" || newInfo.ControlID != 2 {
|
|
||||||
t.Fatalf("new client key was not registered: %+v", newInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -52,6 +51,7 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/server/controller"
|
"github.com/fatedier/frp/server/controller"
|
||||||
"github.com/fatedier/frp/server/group"
|
"github.com/fatedier/frp/server/group"
|
||||||
|
"github.com/fatedier/frp/server/metrics"
|
||||||
"github.com/fatedier/frp/server/ports"
|
"github.com/fatedier/frp/server/ports"
|
||||||
"github.com/fatedier/frp/server/proxy"
|
"github.com/fatedier/frp/server/proxy"
|
||||||
"github.com/fatedier/frp/server/registry"
|
"github.com/fatedier/frp/server/registry"
|
||||||
@@ -64,8 +64,6 @@ const (
|
|||||||
vhostReadWriteTimeout time.Duration = 30 * time.Second
|
vhostReadWriteTimeout time.Duration = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
var errControlReplaced = errors.New("control was replaced during login")
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
crypto.DefaultSalt = "frp"
|
crypto.DefaultSalt = "frp"
|
||||||
// Disable quic-go's receive buffer warning.
|
// Disable quic-go's receive buffer warning.
|
||||||
@@ -163,10 +161,9 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
svr := &Service{
|
svr := &Service{
|
||||||
ctlManager: NewControlManager(clientRegistry),
|
ctlManager: NewControlManager(),
|
||||||
clientRegistry: clientRegistry,
|
clientRegistry: registry.NewClientRegistry(),
|
||||||
pxyManager: proxy.NewManager(),
|
pxyManager: proxy.NewManager(),
|
||||||
pluginManager: plugin.NewManager(),
|
pluginManager: plugin.NewManager(),
|
||||||
rc: &controller.ResourceController{
|
rc: &controller.ResourceController{
|
||||||
@@ -300,14 +297,10 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
|||||||
svr.rc.HTTPReverseProxy = rp
|
svr.rc.HTTPReverseProxy = rp
|
||||||
|
|
||||||
address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPPort))
|
address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPPort))
|
||||||
protocols := new(http.Protocols)
|
|
||||||
protocols.SetHTTP1(true)
|
|
||||||
protocols.SetUnencryptedHTTP2(true)
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: address,
|
Addr: address,
|
||||||
Handler: rp,
|
Handler: rp,
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
ReadHeaderTimeout: 60 * time.Second,
|
||||||
Protocols: protocols,
|
|
||||||
}
|
}
|
||||||
var l net.Listener
|
var l net.Listener
|
||||||
if httpMuxOn {
|
if httpMuxOn {
|
||||||
@@ -476,9 +469,6 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("register control error: %v", err)
|
xl.Warnf("register control error: %v", err)
|
||||||
if ctl != nil {
|
|
||||||
svr.ctlManager.Remove(ctl)
|
|
||||||
}
|
|
||||||
if writeErr := writeWithDeadline(conn, connWriteTimeout, func() error {
|
if writeErr := writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||||
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
@@ -487,27 +477,29 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
}); writeErr != nil {
|
}); writeErr != nil {
|
||||||
xl.Warnf("write login error response error: %v", writeErr)
|
xl.Warnf("write login error response error: %v", writeErr)
|
||||||
}
|
}
|
||||||
if ctl != nil {
|
|
||||||
_ = ctl.Close()
|
|
||||||
} else {
|
|
||||||
conn.Close()
|
conn.Close()
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err = svr.completeControlLogin(ctl, func() error {
|
if err = writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||||
return writeWithDeadline(conn, connWriteTimeout, func() error {
|
|
||||||
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
RunID: ctl.runID,
|
RunID: ctl.runID,
|
||||||
Error: "",
|
Error: "",
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
xl.Warnf("complete control login error: %v", err)
|
xl.Warnf("write login response error: %v", err)
|
||||||
svr.ctlManager.Remove(ctl)
|
svr.ctlManager.Del(m.RunID, ctl)
|
||||||
_ = ctl.Close()
|
svr.clientRegistry.MarkOfflineByRunID(m.RunID)
|
||||||
|
conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ctl.Start()
|
||||||
|
metrics.Server.NewClient()
|
||||||
|
go func() {
|
||||||
|
// block until control closed
|
||||||
|
ctl.WaitClosed()
|
||||||
|
svr.ctlManager.Del(m.RunID, ctl)
|
||||||
|
}()
|
||||||
case *msg.NewWorkConn:
|
case *msg.NewWorkConn:
|
||||||
if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil {
|
if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil {
|
||||||
_ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{
|
_ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{
|
||||||
@@ -535,17 +527,6 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) completeControlLogin(ctl *Control, writeSuccess func() error) error {
|
|
||||||
committed, err := svr.ctlManager.completeLogin(ctl, writeSuccess)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !committed {
|
|
||||||
return errControlReplaced
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type acceptedConnection struct {
|
type acceptedConnection struct {
|
||||||
conn *msg.Conn
|
conn *msg.Conn
|
||||||
wireProtocol string
|
wireProtocol string
|
||||||
@@ -795,6 +776,7 @@ func (svr *Service) RegisterControl(
|
|||||||
Conn: ctlConn,
|
Conn: ctlConn,
|
||||||
LoginMsg: loginMsg,
|
LoginMsg: loginMsg,
|
||||||
ServerCfg: svr.cfg,
|
ServerCfg: svr.cfg,
|
||||||
|
ClientRegistry: svr.clientRegistry,
|
||||||
WireProtocol: wireProtocol,
|
WireProtocol: wireProtocol,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -803,17 +785,18 @@ func (svr *Service) RegisterControl(
|
|||||||
return nil, fmt.Errorf("unexpected error when creating new controller")
|
return nil, fmt.Errorf("unexpected error when creating new controller")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := svr.ctlManager.Add(ctl); err != nil {
|
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
|
||||||
return ctl, err
|
oldCtl.WaitClosed()
|
||||||
}
|
}
|
||||||
ctl.WaitForHandoff()
|
|
||||||
|
|
||||||
active, err := svr.ctlManager.Activate(ctl)
|
remoteAddr := ctlConn.RemoteAddr().String()
|
||||||
if err != nil {
|
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||||
return ctl, err
|
remoteAddr = host
|
||||||
}
|
}
|
||||||
if !active {
|
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr, wireProtocol)
|
||||||
return ctl, errControlReplaced
|
if conflict {
|
||||||
|
svr.ctlManager.Del(loginMsg.RunID, ctl)
|
||||||
|
return nil, fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctl, nil
|
return ctl, nil
|
||||||
@@ -847,25 +830,20 @@ func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn
|
|||||||
xl.Warnf("invalid NewWorkConn with run id [%s]", newMsg.RunID)
|
xl.Warnf("invalid NewWorkConn with run id [%s]", newMsg.RunID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return svr.ctlManager.RegisterWorkConn(ctl, proxy.NewWorkConn(workConn))
|
return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
|
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
|
||||||
admit := func(visitorUser string) error {
|
visitorUser := ""
|
||||||
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
|
||||||
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
|
|
||||||
}
|
|
||||||
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
|
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
|
||||||
// If runID is required, it is not compatible with versions prior to v0.50.0.
|
// If runID is required, it is not compatible with versions prior to v0.50.0.
|
||||||
if newMsg.RunID != "" {
|
if newMsg.RunID != "" {
|
||||||
admitted, err := svr.ctlManager.admitVisitorByRunID(newMsg.RunID, admit)
|
ctl, exist := svr.ctlManager.GetByID(newMsg.RunID)
|
||||||
if err != nil {
|
if !exist {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !admitted {
|
|
||||||
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
|
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
|
||||||
}
|
}
|
||||||
return nil
|
visitorUser = ctl.sessionCtx.LoginMsg.User
|
||||||
}
|
}
|
||||||
return admit("")
|
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
||||||
|
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,29 +15,12 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"runtime"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/net/mux"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/auth"
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
|
||||||
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
|
||||||
"github.com/fatedier/frp/pkg/proto/wire"
|
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
|
||||||
"github.com/fatedier/frp/server/controller"
|
|
||||||
"github.com/fatedier/frp/server/proxy"
|
|
||||||
"github.com/fatedier/frp/server/registry"
|
|
||||||
"github.com/fatedier/frp/server/visitor"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
|
func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
|
||||||
@@ -78,569 +61,3 @@ func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
|
|||||||
t.Fatal("timed out waiting for write after deadline reset")
|
t.Fatal("timed out waiting for write after deadline reset")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSharedPortHTTPListenerProtocols(t *testing.T) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sharedMux := mux.NewMux(listener)
|
|
||||||
httpListener := sharedMux.ListenHTTP(1)
|
|
||||||
muxServeErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
muxServeErr <- sharedMux.Serve()
|
|
||||||
}()
|
|
||||||
|
|
||||||
newProtocols := func(http1, unencryptedHTTP2 bool) *http.Protocols {
|
|
||||||
protocols := new(http.Protocols)
|
|
||||||
protocols.SetHTTP1(http1)
|
|
||||||
protocols.SetUnencryptedHTTP2(unencryptedHTTP2)
|
|
||||||
return protocols
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlerProtocolHeader = "X-Test-Handler-Protocol"
|
|
||||||
httpServer := &http.Server{
|
|
||||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set(handlerProtocolHeader, r.Proto)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}),
|
|
||||||
ReadHeaderTimeout: time.Second,
|
|
||||||
Protocols: newProtocols(true, true),
|
|
||||||
}
|
|
||||||
httpServeErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
httpServeErr <- httpServer.Serve(httpListener)
|
|
||||||
}()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
require.NoError(t, httpServer.Close())
|
|
||||||
require.ErrorIs(t, waitForResult(t, httpServeErr, "shared HTTP server to stop"), http.ErrServerClosed)
|
|
||||||
require.NoError(t, sharedMux.Close())
|
|
||||||
require.ErrorIs(t, waitForResult(t, muxServeErr, "shared mux to stop"), net.ErrClosed)
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
http1 bool
|
|
||||||
unencryptedHTTP2 bool
|
|
||||||
expectedProtocol string
|
|
||||||
}{
|
|
||||||
{name: "HTTP/1.1", http1: true, expectedProtocol: "HTTP/1.1"},
|
|
||||||
{name: "HTTP/2 prior knowledge", unencryptedHTTP2: true, expectedProtocol: "HTTP/2.0"},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
transport := &http.Transport{
|
|
||||||
Protocols: newProtocols(tc.http1, tc.unencryptedHTTP2),
|
|
||||||
}
|
|
||||||
defer transport.CloseIdleConnections()
|
|
||||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
|
||||||
request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+listener.Addr().String()+"/", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
response, err := client.Do(request)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusNoContent, response.StatusCode)
|
|
||||||
require.Equal(t, tc.expectedProtocol, response.Proto)
|
|
||||||
require.Equal(t, tc.expectedProtocol, response.Header.Get(handlerProtocolHeader))
|
|
||||||
require.NoError(t, response.Body.Close())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceControlHandoffSkipsStalePendingGeneration(t *testing.T) {
|
|
||||||
svr := newControlTestService(t)
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
metrics.closeEnter = make(chan struct{})
|
|
||||||
metrics.closeResume = make(chan struct{})
|
|
||||||
|
|
||||||
ctlA, connA, err := registerLifecycleTestControl(svr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
ctlA.serverMetrics = metrics
|
|
||||||
require.NoError(t, svr.completeControlLogin(ctlA, func() error { return nil }))
|
|
||||||
waitForSignal(t, connA.readStarted, "A reader to start")
|
|
||||||
|
|
||||||
require.NoError(t, ctlA.Close())
|
|
||||||
waitForSignal(t, metrics.closeEnter, "A finalization barrier")
|
|
||||||
|
|
||||||
type registerResult struct {
|
|
||||||
ctl *Control
|
|
||||||
conn *deadlineReadConn
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
resultB := make(chan registerResult, 1)
|
|
||||||
go func() {
|
|
||||||
ctl, conn, registerErr := registerLifecycleTestControl(svr)
|
|
||||||
resultB <- registerResult{ctl: ctl, conn: conn, err: registerErr}
|
|
||||||
}()
|
|
||||||
ctlB := waitForDifferentCurrentControl(t, svr.ctlManager, "shared-run", ctlA)
|
|
||||||
ctlB.serverMetrics = metrics
|
|
||||||
|
|
||||||
resultC := make(chan registerResult, 1)
|
|
||||||
go func() {
|
|
||||||
ctl, conn, registerErr := registerLifecycleTestControl(svr)
|
|
||||||
resultC <- registerResult{ctl: ctl, conn: conn, err: registerErr}
|
|
||||||
}()
|
|
||||||
ctlC := waitForDifferentCurrentControl(t, svr.ctlManager, "shared-run", ctlB)
|
|
||||||
ctlC.serverMetrics = metrics
|
|
||||||
waitForControlDone(t, ctlB)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case result := <-resultB:
|
|
||||||
t.Fatalf("B returned before A finalized: %v", result.err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case result := <-resultC:
|
|
||||||
t.Fatalf("C returned before A finalized: %v", result.err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
close(metrics.closeResume)
|
|
||||||
waitForControlDone(t, ctlA)
|
|
||||||
|
|
||||||
b := <-resultB
|
|
||||||
require.Same(t, ctlB, b.ctl)
|
|
||||||
require.ErrorIs(t, b.err, errControlReplaced)
|
|
||||||
require.False(t, svr.ctlManager.Remove(ctlB))
|
|
||||||
require.NoError(t, ctlB.Close())
|
|
||||||
|
|
||||||
c := <-resultC
|
|
||||||
require.NoError(t, c.err)
|
|
||||||
require.Same(t, ctlC, c.ctl)
|
|
||||||
_, ok := svr.ctlManager.GetByID("shared-run")
|
|
||||||
require.False(t, ok)
|
|
||||||
require.Same(t, ctlC, currentControlForTest(svr.ctlManager, "shared-run"))
|
|
||||||
|
|
||||||
info, ok := svr.clientRegistry.GetByKey("client")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.True(t, info.Online)
|
|
||||||
require.Equal(t, uint64(ctlC.ID()), info.ControlID)
|
|
||||||
|
|
||||||
var staleWrites atomic.Int64
|
|
||||||
err = svr.completeControlLogin(ctlB, func() error {
|
|
||||||
staleWrites.Add(1)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.ErrorIs(t, err, errControlReplaced)
|
|
||||||
require.Equal(t, int64(0), staleWrites.Load())
|
|
||||||
|
|
||||||
require.NoError(t, svr.completeControlLogin(ctlC, func() error { return nil }))
|
|
||||||
waitForSignal(t, c.conn.readStarted, "C reader to start")
|
|
||||||
current, ok := svr.ctlManager.GetByID("shared-run")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Same(t, ctlC, current)
|
|
||||||
require.Equal(t, int64(2), metrics.newClients())
|
|
||||||
require.Equal(t, int64(1), metrics.closedClients())
|
|
||||||
|
|
||||||
require.NoError(t, ctlC.Close())
|
|
||||||
waitForControlDone(t, ctlC)
|
|
||||||
require.Equal(t, int64(2), metrics.newClients())
|
|
||||||
require.Equal(t, int64(2), metrics.closedClients())
|
|
||||||
_, ok = svr.ctlManager.GetByID("shared-run")
|
|
||||||
require.False(t, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceLoginResponseSynchronizationIsScopedToRun(t *testing.T) {
|
|
||||||
svr := newControlTestService(t)
|
|
||||||
metrics := newCountingServerMetrics()
|
|
||||||
ctlA, connA, err := registerLifecycleTestControl(svr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
ctlA.serverMetrics = metrics
|
|
||||||
|
|
||||||
writeEntered := make(chan struct{})
|
|
||||||
resumeWrite := make(chan struct{})
|
|
||||||
var resumeWriteOnce sync.Once
|
|
||||||
resume := func() {
|
|
||||||
resumeWriteOnce.Do(func() { close(resumeWrite) })
|
|
||||||
}
|
|
||||||
t.Cleanup(resume)
|
|
||||||
writeCount := atomic.Int64{}
|
|
||||||
loginDone := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
loginDone <- svr.completeControlLogin(ctlA, func() error {
|
|
||||||
close(writeEntered)
|
|
||||||
<-resumeWrite
|
|
||||||
writeCount.Add(1)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}()
|
|
||||||
waitForSignal(t, writeEntered, "A LoginResp write")
|
|
||||||
|
|
||||||
runMu := currentRunGateForTest(svr.ctlManager, "shared-run")
|
|
||||||
require.NotNil(t, runMu)
|
|
||||||
if !svr.ctlManager.mu.TryLock() {
|
|
||||||
t.Fatal("ControlManager mutex was held while LoginResp write was in progress")
|
|
||||||
}
|
|
||||||
svr.ctlManager.mu.Unlock()
|
|
||||||
|
|
||||||
ctlB, connB := newLifecycleTestControl(t, "shared-run", "client", metrics)
|
|
||||||
gateAvailable := make(chan bool)
|
|
||||||
addDone := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
if runMu.TryLock() {
|
|
||||||
runMu.Unlock()
|
|
||||||
gateAvailable <- true
|
|
||||||
} else {
|
|
||||||
gateAvailable <- false
|
|
||||||
}
|
|
||||||
addErr := svr.ctlManager.Add(ctlB)
|
|
||||||
addDone <- addErr
|
|
||||||
}()
|
|
||||||
available := waitForResult(t, gateAvailable, "same-run replacement gate probe")
|
|
||||||
require.False(t, available, "same-run gate was available to replacement during LoginResp write")
|
|
||||||
select {
|
|
||||||
case addErr := <-addDone:
|
|
||||||
t.Fatalf("same-run replacement completed during LoginResp write: %v", addErr)
|
|
||||||
case <-time.After(20 * time.Millisecond):
|
|
||||||
}
|
|
||||||
require.Same(t, ctlA, currentControlForTest(svr.ctlManager, "shared-run"))
|
|
||||||
|
|
||||||
otherMetrics := newCountingServerMetrics()
|
|
||||||
otherCtl, otherConn := newLifecycleTestControl(t, "other-run", "other-client", otherMetrics)
|
|
||||||
type unrelatedResult struct {
|
|
||||||
addErr error
|
|
||||||
active bool
|
|
||||||
activateErr error
|
|
||||||
loginErr error
|
|
||||||
current *Control
|
|
||||||
found bool
|
|
||||||
}
|
|
||||||
unrelatedDone := make(chan unrelatedResult, 1)
|
|
||||||
go func() {
|
|
||||||
result := unrelatedResult{}
|
|
||||||
result.addErr = svr.ctlManager.Add(otherCtl)
|
|
||||||
if result.addErr == nil {
|
|
||||||
result.active, result.activateErr = svr.ctlManager.Activate(otherCtl)
|
|
||||||
}
|
|
||||||
if result.activateErr == nil && result.active {
|
|
||||||
result.loginErr = svr.completeControlLogin(otherCtl, func() error { return nil })
|
|
||||||
}
|
|
||||||
result.current, result.found = svr.ctlManager.GetByID("other-run")
|
|
||||||
unrelatedDone <- result
|
|
||||||
}()
|
|
||||||
result := waitForResult(t, unrelatedDone, "unrelated run lifecycle")
|
|
||||||
require.NoError(t, result.addErr)
|
|
||||||
require.NoError(t, result.activateErr)
|
|
||||||
require.True(t, result.active)
|
|
||||||
require.NoError(t, result.loginErr)
|
|
||||||
require.True(t, result.found)
|
|
||||||
require.Same(t, otherCtl, result.current)
|
|
||||||
waitForSignal(t, otherConn.readStarted, "unrelated control reader to start")
|
|
||||||
require.Equal(t, int64(1), otherMetrics.newClients())
|
|
||||||
|
|
||||||
resume()
|
|
||||||
require.NoError(t, waitForResult(t, loginDone, "LoginResp completion"))
|
|
||||||
require.NoError(t, waitForResult(t, addDone, "replacement"))
|
|
||||||
waitForControlDone(t, ctlA)
|
|
||||||
require.Same(t, ctlB, currentControlForTest(svr.ctlManager, "shared-run"))
|
|
||||||
require.Equal(t, int64(1), writeCount.Load())
|
|
||||||
require.Equal(t, int64(1), metrics.newClients())
|
|
||||||
require.Equal(t, int64(1), metrics.closedClients())
|
|
||||||
require.Equal(t, []string{"deadline", "close"}, connA.eventsSnapshot())
|
|
||||||
|
|
||||||
require.False(t, svr.ctlManager.Remove(ctlA))
|
|
||||||
require.NoError(t, ctlA.Close())
|
|
||||||
require.True(t, svr.ctlManager.Remove(ctlB))
|
|
||||||
require.NoError(t, ctlB.Close())
|
|
||||||
require.Equal(t, []string{"deadline", "close"}, connB.eventsSnapshot())
|
|
||||||
|
|
||||||
require.NoError(t, otherCtl.Close())
|
|
||||||
waitForControlDone(t, otherCtl)
|
|
||||||
require.Equal(t, int64(1), otherMetrics.newClients())
|
|
||||||
require.Equal(t, int64(1), otherMetrics.closedClients())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceVisitorAdmissionSerializesReplacement(t *testing.T) {
|
|
||||||
svr := newControlTestService(t)
|
|
||||||
ctlA, controlConn, err := registerLifecycleTestControl(svr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
ctlA.sessionCtx.LoginMsg.User = "old-user"
|
|
||||||
require.NoError(t, svr.completeControlLogin(ctlA, func() error { return nil }))
|
|
||||||
waitForSignal(t, controlConn.readStarted, "A reader to start")
|
|
||||||
|
|
||||||
admissionEntered := make(chan struct{})
|
|
||||||
resumeAdmission := make(chan struct{})
|
|
||||||
var resumeOnce sync.Once
|
|
||||||
resume := func() {
|
|
||||||
resumeOnce.Do(func() { close(resumeAdmission) })
|
|
||||||
}
|
|
||||||
t.Cleanup(resume)
|
|
||||||
type admissionResult struct {
|
|
||||||
admitted bool
|
|
||||||
user string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
admissionDone := make(chan admissionResult, 1)
|
|
||||||
go func() {
|
|
||||||
var admittedUser string
|
|
||||||
admitted, admitErr := svr.ctlManager.admitVisitorByRunID("shared-run", func(user string) error {
|
|
||||||
admittedUser = user
|
|
||||||
close(admissionEntered)
|
|
||||||
<-resumeAdmission
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
admissionDone <- admissionResult{admitted: admitted, user: admittedUser, err: admitErr}
|
|
||||||
}()
|
|
||||||
waitForSignal(t, admissionEntered, "visitor admission callback")
|
|
||||||
runMu := currentRunGateForTest(svr.ctlManager, "shared-run")
|
|
||||||
require.NotNil(t, runMu)
|
|
||||||
|
|
||||||
type registerResult struct {
|
|
||||||
ctl *Control
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
gateAvailable := make(chan bool)
|
|
||||||
replacementDone := make(chan registerResult, 1)
|
|
||||||
go func() {
|
|
||||||
if runMu.TryLock() {
|
|
||||||
runMu.Unlock()
|
|
||||||
gateAvailable <- true
|
|
||||||
} else {
|
|
||||||
gateAvailable <- false
|
|
||||||
}
|
|
||||||
ctl, _, registerErr := registerLifecycleTestControl(svr)
|
|
||||||
replacementDone <- registerResult{ctl: ctl, err: registerErr}
|
|
||||||
}()
|
|
||||||
available := waitForResult(t, gateAvailable, "visitor replacement gate probe")
|
|
||||||
require.False(t, available, "same-run gate was available during visitor admission")
|
|
||||||
select {
|
|
||||||
case result := <-replacementDone:
|
|
||||||
t.Fatalf("replacement completed during visitor admission: %v", result.err)
|
|
||||||
case <-time.After(20 * time.Millisecond):
|
|
||||||
}
|
|
||||||
require.Same(t, ctlA, currentControlForTest(svr.ctlManager, "shared-run"))
|
|
||||||
|
|
||||||
resume()
|
|
||||||
admission := waitForResult(t, admissionDone, "visitor admission")
|
|
||||||
require.NoError(t, admission.err)
|
|
||||||
require.True(t, admission.admitted)
|
|
||||||
require.Equal(t, "old-user", admission.user)
|
|
||||||
replacement := waitForResult(t, replacementDone, "replacement")
|
|
||||||
require.NoError(t, replacement.err)
|
|
||||||
ctlB := replacement.ctl
|
|
||||||
require.Same(t, ctlB, currentControlForTest(svr.ctlManager, "shared-run"))
|
|
||||||
waitForControlDone(t, ctlA)
|
|
||||||
require.True(t, svr.ctlManager.Remove(ctlB))
|
|
||||||
require.NoError(t, ctlB.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) {
|
|
||||||
svr := newControlTestService(t)
|
|
||||||
ctl, controlConn, err := registerLifecycleTestControl(svr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
pendingConn := newCountingCloseConn()
|
|
||||||
pendingMsgConn := msg.NewConn(pendingConn, msg.NewV1ReadWriter(pendingConn))
|
|
||||||
err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"})
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, int64(1), pendingConn.closeCount.Load())
|
|
||||||
require.Len(t, ctl.workConnCh, 0)
|
|
||||||
|
|
||||||
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
|
||||||
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
|
||||||
current, ok := svr.ctlManager.GetByID("shared-run")
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Same(t, ctl, current)
|
|
||||||
require.Len(t, ctl.workConnCh, 0)
|
|
||||||
|
|
||||||
runningConn := newCountingCloseConn()
|
|
||||||
runningMsgConn := msg.NewConn(runningConn, msg.NewV1ReadWriter(runningConn))
|
|
||||||
require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"}))
|
|
||||||
require.Len(t, ctl.workConnCh, 1)
|
|
||||||
|
|
||||||
require.NoError(t, ctl.Close())
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
require.Equal(t, int64(1), runningConn.closeCount.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) {
|
|
||||||
for _, action := range []string{"replace", "close"} {
|
|
||||||
t.Run(action, func(t *testing.T) {
|
|
||||||
svr := newControlTestService(t)
|
|
||||||
ctl, controlConn, err := registerLifecycleTestControl(svr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
|
||||||
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
|
||||||
|
|
||||||
barrier := newWorkConnBarrierPlugin()
|
|
||||||
svr.pluginManager.Register(barrier)
|
|
||||||
workConn := newCountingCloseConn()
|
|
||||||
workMsgConn := msg.NewConn(workConn, msg.NewV1ReadWriter(workConn))
|
|
||||||
routeDone := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"})
|
|
||||||
}()
|
|
||||||
waitForSignal(t, barrier.entered, "work connection plugin barrier")
|
|
||||||
|
|
||||||
var replacement *Control
|
|
||||||
switch action {
|
|
||||||
case "replace":
|
|
||||||
replacement, _, err = registerLifecycleTestControl(svr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
case "close":
|
|
||||||
require.NoError(t, ctl.Close())
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(barrier.resume)
|
|
||||||
require.Error(t, waitForResult(t, routeDone, "work connection route to finish"))
|
|
||||||
require.Equal(t, int64(1), workConn.closeCount.Load())
|
|
||||||
require.Len(t, ctl.workConnCh, 0)
|
|
||||||
|
|
||||||
if replacement != nil {
|
|
||||||
require.Len(t, replacement.workConnCh, 0)
|
|
||||||
require.True(t, svr.ctlManager.Remove(replacement))
|
|
||||||
require.NoError(t, replacement.Close())
|
|
||||||
waitForControlDone(t, replacement)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) {
|
|
||||||
svr := newControlTestService(t)
|
|
||||||
listener, err := svr.rc.VisitorManager.Listen("visitor", "secret", []string{"pending-user"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = listener.Close() })
|
|
||||||
|
|
||||||
controlConn := newDeadlineReadConn()
|
|
||||||
controlMsgConn := msg.NewConn(controlConn, msg.NewV1ReadWriter(controlConn))
|
|
||||||
ctl, err := svr.RegisterControl(controlMsgConn, &msg.Login{
|
|
||||||
RunID: "visitor-run",
|
|
||||||
User: "pending-user",
|
|
||||||
ClientID: "visitor-client",
|
|
||||||
ClientSpec: msg.ClientSpec{
|
|
||||||
AlwaysAuthPass: true,
|
|
||||||
},
|
|
||||||
}, true, wire.ProtocolV1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
timestamp := time.Now().Unix()
|
|
||||||
visitorMsg := &msg.NewVisitorConn{
|
|
||||||
RunID: "visitor-run",
|
|
||||||
ProxyName: "visitor",
|
|
||||||
Timestamp: timestamp,
|
|
||||||
SignKey: util.GetAuthKey("secret", timestamp),
|
|
||||||
}
|
|
||||||
pendingConn := newCountingCloseConn()
|
|
||||||
err = svr.RegisterVisitorConn(pendingConn, visitorMsg, wire.ProtocolV1)
|
|
||||||
require.ErrorContains(t, err, "no client control found")
|
|
||||||
require.NoError(t, pendingConn.Close())
|
|
||||||
require.Equal(t, int64(1), pendingConn.closeCount.Load())
|
|
||||||
|
|
||||||
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
|
||||||
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
|
||||||
runningConn := newCountingCloseConn()
|
|
||||||
require.NoError(t, svr.RegisterVisitorConn(runningConn, visitorMsg, wire.ProtocolV1))
|
|
||||||
accepted, err := listener.Accept()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, accepted.Close())
|
|
||||||
require.Equal(t, int64(1), runningConn.closeCount.Load())
|
|
||||||
|
|
||||||
require.NoError(t, ctl.Close())
|
|
||||||
waitForControlDone(t, ctl)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newControlTestService(t *testing.T) *Service {
|
|
||||||
t.Helper()
|
|
||||||
cfg := &v1.ServerConfig{}
|
|
||||||
cfg.Auth.Method = v1.AuthMethodToken
|
|
||||||
authRuntime, err := auth.BuildServerAuth(&cfg.Auth)
|
|
||||||
require.NoError(t, err)
|
|
||||||
clientRegistry := registry.NewClientRegistry()
|
|
||||||
return &Service{
|
|
||||||
ctlManager: NewControlManager(clientRegistry),
|
|
||||||
clientRegistry: clientRegistry,
|
|
||||||
pxyManager: proxy.NewManager(),
|
|
||||||
pluginManager: plugin.NewManager(),
|
|
||||||
rc: &controller.ResourceController{
|
|
||||||
VisitorManager: visitor.NewManager(),
|
|
||||||
},
|
|
||||||
auth: authRuntime,
|
|
||||||
cfg: cfg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerLifecycleTestControl(svr *Service) (*Control, *deadlineReadConn, error) {
|
|
||||||
conn := newDeadlineReadConn()
|
|
||||||
msgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wire.ProtocolV1))
|
|
||||||
ctl, err := svr.RegisterControl(msgConn, &msg.Login{
|
|
||||||
RunID: "shared-run",
|
|
||||||
ClientID: "client",
|
|
||||||
ClientSpec: msg.ClientSpec{
|
|
||||||
AlwaysAuthPass: true,
|
|
||||||
},
|
|
||||||
}, true, wire.ProtocolV1)
|
|
||||||
return ctl, conn, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForDifferentCurrentControl(t *testing.T, manager *ControlManager, runID string, old *Control) *Control {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(3 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if ctl := currentControlForTest(manager, runID); ctl != nil && ctl != old {
|
|
||||||
return ctl
|
|
||||||
}
|
|
||||||
runtime.Gosched()
|
|
||||||
}
|
|
||||||
t.Fatalf("timed out waiting for a new current control after ID %d", old.ID())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerWorkConnAsCaller(svr *Service, workConn *msg.Conn, newMsg *msg.NewWorkConn) error {
|
|
||||||
err := svr.RegisterWorkConn(workConn, newMsg)
|
|
||||||
if err != nil {
|
|
||||||
_ = workConn.Close()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForResult[T any](t *testing.T, ch <-chan T, description string) T {
|
|
||||||
t.Helper()
|
|
||||||
select {
|
|
||||||
case result := <-ch:
|
|
||||||
return result
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatalf("timed out waiting for %s", description)
|
|
||||||
var zero T
|
|
||||||
return zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type workConnBarrierPlugin struct {
|
|
||||||
entered chan struct{}
|
|
||||||
resume chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newWorkConnBarrierPlugin() *workConnBarrierPlugin {
|
|
||||||
return &workConnBarrierPlugin{
|
|
||||||
entered: make(chan struct{}),
|
|
||||||
resume: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*workConnBarrierPlugin) Name() string { return "work-conn-barrier" }
|
|
||||||
|
|
||||||
func (*workConnBarrierPlugin) IsSupport(op string) bool { return op == plugin.OpNewWorkConn }
|
|
||||||
|
|
||||||
func (p *workConnBarrierPlugin) Handle(
|
|
||||||
context.Context,
|
|
||||||
string,
|
|
||||||
any,
|
|
||||||
) (*plugin.Response, any, error) {
|
|
||||||
close(p.entered)
|
|
||||||
<-p.resume
|
|
||||||
return &plugin.Response{Unchange: true}, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type countingCloseConn struct {
|
|
||||||
closeCount atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func newCountingCloseConn() *countingCloseConn { return &countingCloseConn{} }
|
|
||||||
|
|
||||||
func (*countingCloseConn) Read([]byte) (int, error) { return 0, net.ErrClosed }
|
|
||||||
func (*countingCloseConn) Write(p []byte) (int, error) { return len(p), nil }
|
|
||||||
func (c *countingCloseConn) Close() error { c.closeCount.Add(1); return nil }
|
|
||||||
func (*countingCloseConn) LocalAddr() net.Addr { return lifecycleTestAddr("local") }
|
|
||||||
func (*countingCloseConn) RemoteAddr() net.Addr { return lifecycleTestAddr("remote") }
|
|
||||||
func (*countingCloseConn) SetDeadline(time.Time) error { return nil }
|
|
||||||
func (*countingCloseConn) SetReadDeadline(time.Time) error { return nil }
|
|
||||||
func (*countingCloseConn) SetWriteDeadline(time.Time) error { return nil }
|
|
||||||
|
|||||||
@@ -94,9 +94,11 @@ func (f *Framework) RunProcessesWithBinaries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
|
func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
|
||||||
p, output, err := f.StartFrps(args...)
|
p := process.NewWithEnvs(TestContext.FRPServerPath, args, f.osEnvs)
|
||||||
|
f.serverProcesses = append(f.serverProcesses, p)
|
||||||
|
err := p.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p, output, err
|
return p, p.Output(), err
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-p.Done():
|
case <-p.Done():
|
||||||
@@ -105,39 +107,17 @@ func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
|
|||||||
return p, p.Output(), nil
|
return p, p.Output(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartFrps starts frps without an implicit sleep so tests can wait on an
|
|
||||||
// explicit readiness event.
|
|
||||||
func (f *Framework) StartFrps(args ...string) (*process.Process, string, error) {
|
|
||||||
p := process.NewWithEnvs(TestContext.FRPServerPath, args, f.osEnvs)
|
|
||||||
f.serverProcesses = append(f.serverProcesses, p)
|
|
||||||
err := p.Start()
|
|
||||||
if err != nil {
|
|
||||||
return p, p.Output(), err
|
|
||||||
}
|
|
||||||
return p, p.Output(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
|
func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
|
||||||
p, output, err := f.StartFrpc(args...)
|
|
||||||
if err != nil {
|
|
||||||
return p, output, err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-p.Done():
|
|
||||||
case <-time.After(1500 * time.Millisecond):
|
|
||||||
}
|
|
||||||
return p, p.Output(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartFrpc starts frpc without an implicit sleep so tests can wait on an
|
|
||||||
// explicit login or proxy-readiness event.
|
|
||||||
func (f *Framework) StartFrpc(args ...string) (*process.Process, string, error) {
|
|
||||||
p := process.NewWithEnvs(TestContext.FRPClientPath, args, f.osEnvs)
|
p := process.NewWithEnvs(TestContext.FRPClientPath, args, f.osEnvs)
|
||||||
f.clientProcesses = append(f.clientProcesses, p)
|
f.clientProcesses = append(f.clientProcesses, p)
|
||||||
err := p.Start()
|
err := p.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p, p.Output(), err
|
return p, p.Output(), err
|
||||||
}
|
}
|
||||||
|
select {
|
||||||
|
case <-p.Done():
|
||||||
|
case <-time.After(1500 * time.Millisecond):
|
||||||
|
}
|
||||||
return p, p.Output(), nil
|
return p, p.Output(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
// 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 relay
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HalfOpen forwards TCP connections until Blackhole is called. A blackholed
|
|
||||||
// pair stops forwarding but deliberately retains the upstream socket so the
|
|
||||||
// peer sees a real half-open connection until the relay is closed.
|
|
||||||
type HalfOpen struct {
|
|
||||||
bindAddr string
|
|
||||||
bindPort int
|
|
||||||
upstreamAddr string
|
|
||||||
|
|
||||||
listener net.Listener
|
|
||||||
done chan struct{}
|
|
||||||
accepted chan struct{}
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
pairs []*connectionPair
|
|
||||||
|
|
||||||
wg sync.WaitGroup
|
|
||||||
closeOnce sync.Once
|
|
||||||
}
|
|
||||||
|
|
||||||
type connectionPair struct {
|
|
||||||
downstream net.Conn
|
|
||||||
upstream net.Conn
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
blackholed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(upstreamAddr string) *HalfOpen {
|
|
||||||
return &HalfOpen{
|
|
||||||
bindAddr: "127.0.0.1",
|
|
||||||
upstreamAddr: upstreamAddr,
|
|
||||||
done: make(chan struct{}),
|
|
||||||
accepted: make(chan struct{}, 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *HalfOpen) Run() error {
|
|
||||||
listener, err := net.Listen("tcp", net.JoinHostPort(r.bindAddr, strconv.Itoa(r.bindPort)))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
r.listener = listener
|
|
||||||
r.bindPort = listener.Addr().(*net.TCPAddr).Port
|
|
||||||
|
|
||||||
r.wg.Add(1)
|
|
||||||
go r.acceptLoop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *HalfOpen) acceptLoop() {
|
|
||||||
defer r.wg.Done()
|
|
||||||
for {
|
|
||||||
downstream, err := r.listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
upstream, err := net.DialTimeout("tcp", r.upstreamAddr, 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
_ = downstream.Close()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
pair := &connectionPair{downstream: downstream, upstream: upstream}
|
|
||||||
r.mu.Lock()
|
|
||||||
r.pairs = append(r.pairs, pair)
|
|
||||||
r.mu.Unlock()
|
|
||||||
select {
|
|
||||||
case r.accepted <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
r.wg.Add(1)
|
|
||||||
go r.servePair(pair)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *HalfOpen) servePair(pair *connectionPair) {
|
|
||||||
defer r.wg.Done()
|
|
||||||
copyDone := make(chan struct{}, 2)
|
|
||||||
go func() {
|
|
||||||
_, _ = io.Copy(pair.upstream, pair.downstream)
|
|
||||||
copyDone <- struct{}{}
|
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
_, _ = io.Copy(pair.downstream, pair.upstream)
|
|
||||||
copyDone <- struct{}{}
|
|
||||||
}()
|
|
||||||
|
|
||||||
completed := 0
|
|
||||||
select {
|
|
||||||
case <-copyDone:
|
|
||||||
completed = 1
|
|
||||||
if !pair.isBlackholed() {
|
|
||||||
_ = pair.downstream.Close()
|
|
||||||
_ = pair.upstream.Close()
|
|
||||||
}
|
|
||||||
case <-r.done:
|
|
||||||
_ = pair.downstream.Close()
|
|
||||||
_ = pair.upstream.Close()
|
|
||||||
}
|
|
||||||
for completed < 2 {
|
|
||||||
<-copyDone
|
|
||||||
completed++
|
|
||||||
}
|
|
||||||
|
|
||||||
if pair.isBlackholed() {
|
|
||||||
<-r.done
|
|
||||||
_ = pair.downstream.Close()
|
|
||||||
_ = pair.upstream.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *HalfOpen) WaitForConnections(count int, timeout time.Duration) error {
|
|
||||||
timer := time.NewTimer(timeout)
|
|
||||||
defer timer.Stop()
|
|
||||||
for {
|
|
||||||
r.mu.Lock()
|
|
||||||
accepted := len(r.pairs)
|
|
||||||
r.mu.Unlock()
|
|
||||||
if accepted >= count {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-r.accepted:
|
|
||||||
case <-r.done:
|
|
||||||
return fmt.Errorf("relay closed after accepting %d of %d connections", accepted, count)
|
|
||||||
case <-timer.C:
|
|
||||||
return fmt.Errorf("timed out after accepting %d of %d connections", accepted, count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blackhole uses a one-based connection index in accept order.
|
|
||||||
func (r *HalfOpen) Blackhole(index int) error {
|
|
||||||
r.mu.Lock()
|
|
||||||
if index <= 0 || index > len(r.pairs) {
|
|
||||||
accepted := len(r.pairs)
|
|
||||||
r.mu.Unlock()
|
|
||||||
return fmt.Errorf("connection %d is unavailable; accepted %d", index, accepted)
|
|
||||||
}
|
|
||||||
pair := r.pairs[index-1]
|
|
||||||
r.mu.Unlock()
|
|
||||||
|
|
||||||
pair.mu.Lock()
|
|
||||||
if pair.blackholed {
|
|
||||||
pair.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pair.blackholed = true
|
|
||||||
pair.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
_ = pair.downstream.SetDeadline(now)
|
|
||||||
_ = pair.upstream.SetDeadline(now)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *HalfOpen) Close() error {
|
|
||||||
r.closeOnce.Do(func() {
|
|
||||||
close(r.done)
|
|
||||||
if r.listener != nil {
|
|
||||||
_ = r.listener.Close()
|
|
||||||
}
|
|
||||||
r.mu.Lock()
|
|
||||||
pairs := append([]*connectionPair(nil), r.pairs...)
|
|
||||||
r.mu.Unlock()
|
|
||||||
for _, pair := range pairs {
|
|
||||||
_ = pair.downstream.Close()
|
|
||||||
_ = pair.upstream.Close()
|
|
||||||
}
|
|
||||||
r.wg.Wait()
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *HalfOpen) BindAddr() string { return r.bindAddr }
|
|
||||||
func (r *HalfOpen) BindPort() int { return r.bindPort }
|
|
||||||
|
|
||||||
func (p *connectionPair) isBlackholed() bool {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
return p.blackholed
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
// 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 features
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/test/e2e/framework"
|
|
||||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/relay"
|
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = ginkgo.Describe("[Feature: ControlReplacement]", func() {
|
|
||||||
f := framework.NewDefaultFramework()
|
|
||||||
|
|
||||||
for _, wireProtocol := range []string{"v1", "v2"} {
|
|
||||||
for _, tcpMux := range []bool{true, false} {
|
|
||||||
ginkgo.It(fmt.Sprintf("recovers a %s control through a half-open relay with tcpMux=%t", wireProtocol, tcpMux), func() {
|
|
||||||
runHalfOpenControlReplacement(f, wireProtocol, tcpMux)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
func runHalfOpenControlReplacement(f *framework.Framework, wireProtocol string, tcpMux bool) {
|
|
||||||
serverPort := f.AllocPort()
|
|
||||||
dashboardPort := f.AllocPort()
|
|
||||||
remotePort := f.AllocPort()
|
|
||||||
heartbeatTimeout := int64(-1)
|
|
||||||
if !tcpMux {
|
|
||||||
heartbeatTimeout = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
serverConfig := fmt.Sprintf(`
|
|
||||||
bindAddr = "127.0.0.1"
|
|
||||||
bindPort = %d
|
|
||||||
log.level = "trace"
|
|
||||||
transport.tcpMux = %t
|
|
||||||
transport.tcpMuxKeepaliveInterval = 30
|
|
||||||
transport.heartbeatTimeout = %d
|
|
||||||
webServer.addr = "127.0.0.1"
|
|
||||||
webServer.port = %d
|
|
||||||
webServer.pprofEnable = true
|
|
||||||
enablePrometheus = true
|
|
||||||
`, serverPort, tcpMux, heartbeatTimeout, dashboardPort)
|
|
||||||
serverConfigPath := f.WriteTempFile("issue-5391-frps.toml", serverConfig)
|
|
||||||
serverProcess, _, err := f.StartFrps("-c", serverConfigPath)
|
|
||||||
framework.ExpectNoError(err)
|
|
||||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", serverPort), 5*time.Second))
|
|
||||||
|
|
||||||
halfOpenRelay := relay.New(fmt.Sprintf("127.0.0.1:%d", serverPort))
|
|
||||||
f.RunServer("", halfOpenRelay)
|
|
||||||
|
|
||||||
heartbeatInterval := int64(-1)
|
|
||||||
clientHeartbeatTimeout := int64(-1)
|
|
||||||
if !tcpMux {
|
|
||||||
heartbeatInterval = 1
|
|
||||||
clientHeartbeatTimeout = 3
|
|
||||||
}
|
|
||||||
clientConfig := fmt.Sprintf(`
|
|
||||||
serverAddr = "127.0.0.1"
|
|
||||||
serverPort = %d
|
|
||||||
clientID = "issue-5391"
|
|
||||||
loginFailExit = false
|
|
||||||
log.level = "trace"
|
|
||||||
transport.wireProtocol = %q
|
|
||||||
transport.tcpMux = %t
|
|
||||||
transport.tcpMuxKeepaliveInterval = 1
|
|
||||||
transport.heartbeatInterval = %d
|
|
||||||
transport.heartbeatTimeout = %d
|
|
||||||
transport.tls.enable = false
|
|
||||||
|
|
||||||
[[proxies]]
|
|
||||||
name = "issue-5391-tcp"
|
|
||||||
type = "tcp"
|
|
||||||
localPort = %d
|
|
||||||
remotePort = %d
|
|
||||||
`, halfOpenRelay.BindPort(), wireProtocol, tcpMux, heartbeatInterval, clientHeartbeatTimeout,
|
|
||||||
f.PortByName(framework.TCPEchoServerPort), remotePort)
|
|
||||||
clientConfigPath := f.WriteTempFile("issue-5391-frpc.toml", clientConfig)
|
|
||||||
clientProcess, _, err := f.StartFrpc("-c", clientConfigPath)
|
|
||||||
framework.ExpectNoError(err)
|
|
||||||
framework.ExpectNoError(halfOpenRelay.WaitForConnections(1, 5*time.Second))
|
|
||||||
framework.ExpectNoError(clientProcess.WaitForOutput("login to server success", 1, 10*time.Second))
|
|
||||||
framework.ExpectNoError(clientProcess.WaitForOutput("[issue-5391-tcp] start proxy success", 1, 10*time.Second))
|
|
||||||
framework.ExpectNoError(waitForReplacementState(dashboardPort, remotePort, 10*time.Second))
|
|
||||||
|
|
||||||
replacementCount := 1
|
|
||||||
if tcpMux {
|
|
||||||
replacementCount = 3
|
|
||||||
}
|
|
||||||
for i := 0; i < replacementCount; i++ {
|
|
||||||
connectionIndex := 1
|
|
||||||
if tcpMux {
|
|
||||||
connectionIndex = i + 1
|
|
||||||
}
|
|
||||||
framework.ExpectNoError(halfOpenRelay.Blackhole(connectionIndex))
|
|
||||||
if tcpMux {
|
|
||||||
framework.ExpectNoError(halfOpenRelay.WaitForConnections(connectionIndex+1, 15*time.Second))
|
|
||||||
}
|
|
||||||
framework.ExpectNoError(clientProcess.WaitForOutput("login to server success", i+2, 15*time.Second))
|
|
||||||
framework.ExpectNoError(clientProcess.WaitForOutput("[issue-5391-tcp] start proxy success", i+2, 15*time.Second))
|
|
||||||
framework.ExpectNoError(waitForReplacementState(dashboardPort, remotePort, 10*time.Second))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = clientProcess.Stop()
|
|
||||||
select {
|
|
||||||
case <-clientProcess.Done():
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
framework.Failf("frpc did not exit")
|
|
||||||
}
|
|
||||||
framework.ExpectNoError(waitForReplacementShutdown(dashboardPort, 10*time.Second))
|
|
||||||
framework.ExpectNoError(halfOpenRelay.Close())
|
|
||||||
framework.ExpectNoError(waitForNoHandoffWaiters(dashboardPort, 5*time.Second))
|
|
||||||
|
|
||||||
_ = serverProcess.Stop()
|
|
||||||
select {
|
|
||||||
case <-serverProcess.Done():
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
framework.Failf("frps did not exit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForReplacementState(dashboardPort, remotePort int, timeout time.Duration) error {
|
|
||||||
return waitForLifecycleCondition(timeout, func() error {
|
|
||||||
metricsBody, err := getLifecycleEndpoint(dashboardPort, "/metrics")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := expectMetricValue(metricsBody, "frp_server_client_counts", "", 1); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := expectMetricValue(metricsBody, "frp_server_proxy_counts", `type="tcp"`, 1); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
clients, err := getOnlineLifecycleClients(dashboardPort)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(clients) != 1 || clients[0].ClientID != "issue-5391" {
|
|
||||||
return fmt.Errorf("expected one online client, got %+v", clients)
|
|
||||||
}
|
|
||||||
profile, err := getLifecycleEndpoint(dashboardPort, "/debug/pprof/goroutine?debug=2")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := expectNoHandoffWaiter(profile, "after replacement"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
resp, err := request.New().
|
|
||||||
TCP().
|
|
||||||
Port(remotePort).
|
|
||||||
Timeout(time.Second).
|
|
||||||
Body([]byte(consts.TestString)).
|
|
||||||
Do()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if string(resp.Content) != consts.TestString {
|
|
||||||
return fmt.Errorf("unexpected proxy response %q", resp.Content)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForReplacementShutdown(dashboardPort int, timeout time.Duration) error {
|
|
||||||
return waitForLifecycleCondition(timeout, func() error {
|
|
||||||
metricsBody, err := getLifecycleEndpoint(dashboardPort, "/metrics")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := expectMetricValue(metricsBody, "frp_server_client_counts", "", 0); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := expectMetricValue(metricsBody, "frp_server_proxy_counts", `type="tcp"`, 0); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
clients, err := getOnlineLifecycleClients(dashboardPort)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(clients) != 0 {
|
|
||||||
return fmt.Errorf("expected no online clients, got %+v", clients)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForNoHandoffWaiters(dashboardPort int, timeout time.Duration) error {
|
|
||||||
return waitForLifecycleCondition(timeout, func() error {
|
|
||||||
profile, err := getLifecycleEndpoint(dashboardPort, "/debug/pprof/goroutine?debug=2")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return expectNoHandoffWaiter(profile, "after relay shutdown")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type lifecycleClient struct {
|
|
||||||
ClientID string `json:"clientID"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func expectNoHandoffWaiter(profile, phase string) error {
|
|
||||||
if strings.Contains(profile, "(*Control).WaitForHandoff") {
|
|
||||||
return fmt.Errorf("control handoff waiter remained %s", phase)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOnlineLifecycleClients(dashboardPort int) ([]lifecycleClient, error) {
|
|
||||||
body, err := getLifecycleEndpoint(dashboardPort, "/api/clients?status=online")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var clients []lifecycleClient
|
|
||||||
if err := json.Unmarshal([]byte(body), &clients); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return clients, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLifecycleEndpoint(port int, path string) (string, error) {
|
|
||||||
client := &http.Client{Timeout: time.Second}
|
|
||||||
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("GET %s returned %s", path, resp.Status)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(body), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func expectMetricValue(body, name, labels string, want float64) error {
|
|
||||||
prefix := name
|
|
||||||
if labels != "" {
|
|
||||||
prefix += "{" + labels + "}"
|
|
||||||
}
|
|
||||||
for line := range strings.SplitSeq(body, "\n") {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) != 2 || fields[0] != prefix {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
got, err := strconv.ParseFloat(fields[1], 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if got != want {
|
|
||||||
return fmt.Errorf("metric %s = %v, want %v", prefix, got, want)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("metric %s not found", prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForLifecycleCondition(timeout time.Duration, condition func() error) error {
|
|
||||||
timer := time.NewTimer(timeout)
|
|
||||||
defer timer.Stop()
|
|
||||||
ticker := time.NewTicker(25 * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
var lastErr error
|
|
||||||
for {
|
|
||||||
err := condition()
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
case <-timer.C:
|
|
||||||
return fmt.Errorf("condition was not met: %w", lastErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
"github.com/onsi/ginkgo/v2"
|
||||||
pp "github.com/pires/go-proxyproto"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
"github.com/fatedier/frp/test/e2e/framework"
|
"github.com/fatedier/frp/test/e2e/framework"
|
||||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||||
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
||||||
"github.com/fatedier/frp/test/e2e/mock/server/streamserver"
|
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/port"
|
"github.com/fatedier/frp/test/e2e/pkg/port"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||||
@@ -456,85 +450,4 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
|||||||
ExpectResp([]byte("test")).
|
ExpectResp([]byte("test")).
|
||||||
Ensure()
|
Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
ginkgo.It("tls2raw with proxy protocol v2", func() {
|
|
||||||
generator := &cert.SelfSignedCertGenerator{}
|
|
||||||
artifacts, err := generator.Generate("example.com")
|
|
||||||
framework.ExpectNoError(err)
|
|
||||||
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
|
|
||||||
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
|
|
||||||
|
|
||||||
serverConf := consts.DefaultServerConfig
|
|
||||||
vhostHTTPSPort := f.AllocPort()
|
|
||||||
serverConf += fmt.Sprintf(`
|
|
||||||
vhostHTTPSPort = %d
|
|
||||||
`, vhostHTTPSPort)
|
|
||||||
|
|
||||||
localPort := f.AllocPort()
|
|
||||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
|
||||||
[[proxies]]
|
|
||||||
name = "tls2raw-proxy-protocol-test"
|
|
||||||
type = "https"
|
|
||||||
customDomains = ["example.com"]
|
|
||||||
transport.proxyProtocolVersion = "v2"
|
|
||||||
[proxies.plugin]
|
|
||||||
type = "tls2raw"
|
|
||||||
localAddr = "127.0.0.1:%d"
|
|
||||||
crtPath = "%s"
|
|
||||||
keyPath = "%s"
|
|
||||||
`, localPort, crtPath, keyPath)
|
|
||||||
|
|
||||||
f.RunProcesses(serverConf, []string{clientConf})
|
|
||||||
|
|
||||||
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
|
|
||||||
streamserver.WithCustomHandler(func(c net.Conn) {
|
|
||||||
defer c.Close()
|
|
||||||
|
|
||||||
writeResp := func(body string) {
|
|
||||||
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
|
|
||||||
}
|
|
||||||
|
|
||||||
rd := bufio.NewReader(c)
|
|
||||||
ppHeader, err := pp.Read(rd)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("read proxy protocol error: %v", err)
|
|
||||||
writeResp("missing proxy protocol")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ppHeader.Version != 2 {
|
|
||||||
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
|
|
||||||
writeResp("unexpected proxy protocol version")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
|
|
||||||
if !ok || srcAddr.IP.String() != "127.0.0.1" {
|
|
||||||
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
|
|
||||||
writeResp("unexpected proxy protocol source address")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.ReadRequest(rd)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("read http request after proxy protocol error: %v", err)
|
|
||||||
writeResp("missing http request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = io.Copy(io.Discard, req.Body)
|
|
||||||
_ = req.Body.Close()
|
|
||||||
|
|
||||||
writeResp("test")
|
|
||||||
}))
|
|
||||||
f.RunServer("", localServer)
|
|
||||||
|
|
||||||
framework.NewRequestExpect(f).
|
|
||||||
Port(vhostHTTPSPort).
|
|
||||||
RequestModify(func(r *request.Request) {
|
|
||||||
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
|
|
||||||
ServerName: "example.com",
|
|
||||||
InsecureSkipVerify: true,
|
|
||||||
})
|
|
||||||
}).
|
|
||||||
ExpectResp([]byte("test")).
|
|
||||||
Ensure()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
1
web/frps/components.d.ts
vendored
1
web/frps/components.d.ts
vendored
@@ -15,6 +15,7 @@ declare module 'vue' {
|
|||||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
|||||||
@@ -1,30 +1,10 @@
|
|||||||
import { buildQueryString, http } from './http'
|
import { http } from './http'
|
||||||
import type { V2Page } from './http'
|
import type { ClientInfoData } from '../types/client'
|
||||||
import type { ClientInfoData, ClientListV2Params } from '../types/client'
|
|
||||||
|
|
||||||
export const getClients = () => {
|
export const getClients = () => {
|
||||||
return http.get<ClientInfoData[]>('../api/clients')
|
return http.get<ClientInfoData[]>('../api/clients')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getClientsV2 = (params: ClientListV2Params = {}) => {
|
|
||||||
return http.getV2<V2Page<ClientInfoData>>(
|
|
||||||
`../api/v2/clients${buildQueryString({
|
|
||||||
page: params.page,
|
|
||||||
pageSize: params.pageSize,
|
|
||||||
status:
|
|
||||||
params.status && params.status !== 'all' ? params.status : undefined,
|
|
||||||
q: params.q || undefined,
|
|
||||||
user: params.user,
|
|
||||||
clientID: params.clientID || undefined,
|
|
||||||
runID: params.runID || undefined,
|
|
||||||
})}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getClient = (key: string) => {
|
export const getClient = (key: string) => {
|
||||||
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getClientV2 = (key: string) => {
|
|
||||||
return http.getV2<ClientInfoData>(`../api/v2/clients/${encodeURIComponent(key)}`)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,21 +11,6 @@ class HTTPError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface V2Envelope<T> {
|
|
||||||
code: number
|
|
||||||
msg: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface V2Page<T> {
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
pageSize: number
|
|
||||||
items: T[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type QueryParamValue = string | number | boolean | null | undefined
|
|
||||||
|
|
||||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
const defaultOptions: RequestInit = {
|
const defaultOptions: RequestInit = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -49,62 +34,9 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestV2<T>(
|
|
||||||
url: string,
|
|
||||||
options: RequestInit = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const defaultOptions: RequestInit = {
|
|
||||||
credentials: 'include',
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, { ...defaultOptions, ...options })
|
|
||||||
const envelope = (await response.json().catch(() => null)) as
|
|
||||||
| V2Envelope<T>
|
|
||||||
| null
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new HTTPError(
|
|
||||||
response.status,
|
|
||||||
response.statusText,
|
|
||||||
envelope?.msg || `HTTP ${response.status}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!envelope || typeof envelope.code !== 'number') {
|
|
||||||
throw new Error('Invalid API v2 response')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (envelope.code >= 400) {
|
|
||||||
throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return envelope.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buildQueryString = (
|
|
||||||
params: Record<string, QueryParamValue>,
|
|
||||||
): string => {
|
|
||||||
const query = new URLSearchParams()
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
if (value === null || value === undefined) continue
|
|
||||||
query.append(key, String(value))
|
|
||||||
}
|
|
||||||
const text = query.toString()
|
|
||||||
return text ? `?${text}` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export const http = {
|
export const http = {
|
||||||
get: <T>(url: string, options?: RequestInit) =>
|
get: <T>(url: string, options?: RequestInit) =>
|
||||||
request<T>(url, { ...options, method: 'GET' }),
|
request<T>(url, { ...options, method: 'GET' }),
|
||||||
getV2: <T>(url: string, options?: RequestInit) =>
|
|
||||||
requestV2<T>(url, { ...options, method: 'GET' }),
|
|
||||||
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,118 +1,26 @@
|
|||||||
import { buildQueryString, http } from './http'
|
import { http } from './http'
|
||||||
import { formatUnixSeconds } from '../utils/format'
|
|
||||||
import type { V2Page } from './http'
|
|
||||||
import type {
|
import type {
|
||||||
GetProxyResponse,
|
GetProxyResponse,
|
||||||
ProxyListV2Params,
|
|
||||||
ProxyStatsInfo,
|
ProxyStatsInfo,
|
||||||
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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
|
||||||
const page = await http.getV2<V2Page<ProxyV2Info>>(
|
|
||||||
`../api/v2/proxies${buildQueryString({
|
|
||||||
page: params.page,
|
|
||||||
pageSize: params.pageSize,
|
|
||||||
status:
|
|
||||||
params.status && params.status !== 'all' ? params.status : undefined,
|
|
||||||
q: params.q || undefined,
|
|
||||||
type: params.type || undefined,
|
|
||||||
user: params.user,
|
|
||||||
clientID: params.clientID || undefined,
|
|
||||||
})}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
items: page.items.map(toLegacyProxyStats),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActiveProxySpec = (
|
|
||||||
spec: ProxyV2Spec,
|
|
||||||
): ProxyV2SpecBlocks[ProxyV2Type] => {
|
|
||||||
switch (spec.type) {
|
|
||||||
case 'tcp':
|
|
||||||
return spec.tcp
|
|
||||||
case 'udp':
|
|
||||||
return spec.udp
|
|
||||||
case 'http':
|
|
||||||
return spec.http
|
|
||||||
case 'https':
|
|
||||||
return spec.https
|
|
||||||
case 'tcpmux':
|
|
||||||
return spec.tcpmux
|
|
||||||
case 'stcp':
|
|
||||||
return spec.stcp
|
|
||||||
case 'sudp':
|
|
||||||
return spec.sudp
|
|
||||||
case 'xtcp':
|
|
||||||
return spec.xtcp
|
|
||||||
default:
|
|
||||||
return assertNever(spec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const assertNever = (value: never): never => {
|
|
||||||
throw new Error(`Unsupported proxy spec: ${JSON.stringify(value)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => {
|
|
||||||
const type = proxy.spec.type
|
|
||||||
const activeSpec = getActiveProxySpec(proxy.spec)
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: proxy.name,
|
|
||||||
type,
|
|
||||||
conf: proxy.status.phase === 'offline' ? null : activeSpec,
|
|
||||||
user: proxy.user,
|
|
||||||
clientID: proxy.clientID,
|
|
||||||
todayTrafficIn: proxy.status.todayTrafficIn,
|
|
||||||
todayTrafficOut: proxy.status.todayTrafficOut,
|
|
||||||
curConns: proxy.status.curConns,
|
|
||||||
lastStartTime: formatUnixSeconds(proxy.status.lastStartAt),
|
|
||||||
lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt),
|
|
||||||
status: proxy.status.phase,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getProxy = (type: string, name: string) => {
|
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.getV2<TrafficResponse>(
|
return http.get<TrafficResponse>(`../api/traffic/${name}`)
|
||||||
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clearOfflineProxies = () => {
|
export const clearOfflineProxies = () => {
|
||||||
return http.postV2<SystemPruneResponse>(
|
return http.delete('../api/proxies?status=offline')
|
||||||
'../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.getV2<ServerInfo>('../api/v2/system/info')
|
return http.get<ServerInfo>('../api/serverinfo')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ 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
|
||||||
@@ -72,24 +71,41 @@ const chartData = ref<
|
|||||||
>([])
|
>([])
|
||||||
const maxVal = ref(0)
|
const maxVal = ref(0)
|
||||||
|
|
||||||
const formatDateLabel = (date: string) => {
|
const processData = (trafficIn: number[], trafficOut: number[]) => {
|
||||||
const parts = date.split('-')
|
// Ensure we have arrays and reverse them (server returns newest first)
|
||||||
if (parts.length !== 3) return date
|
const inArr = [...(trafficIn || [])].reverse()
|
||||||
return `${Number(parts[1])}-${Number(parts[2])}`
|
const outArr = [...(trafficOut || [])].reverse()
|
||||||
|
|
||||||
|
// Pad with zeros if less than 7 days
|
||||||
|
while (inArr.length < 7) inArr.unshift(0)
|
||||||
|
while (outArr.length < 7) outArr.unshift(0)
|
||||||
|
|
||||||
|
// Slice to last 7 entries just in case
|
||||||
|
const finalIn = inArr.slice(-7)
|
||||||
|
const finalOut = outArr.slice(-7)
|
||||||
|
|
||||||
|
// Calculate dates (last 7 days ending today)
|
||||||
|
const dates: string[] = []
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - 6)
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
|
||||||
|
d.setDate(d.getDate() + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const processData = (history: TrafficResponse['history'] = []) => {
|
// Find max value for scaling
|
||||||
const points = history || []
|
const maxIn = Math.max(...finalIn)
|
||||||
const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
|
const maxOut = Math.max(...finalOut)
|
||||||
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
|
|
||||||
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
|
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
|
||||||
|
|
||||||
chartData.value = points.map((item) => ({
|
// Build chart data
|
||||||
date: formatDateLabel(item.date),
|
chartData.value = dates.map((date, i) => ({
|
||||||
in: item.trafficIn,
|
date,
|
||||||
out: item.trafficOut,
|
in: finalIn[i],
|
||||||
inPercent: (item.trafficIn / maxVal.value) * 100,
|
out: finalOut[i],
|
||||||
outPercent: (item.trafficOut / maxVal.value) * 100,
|
inPercent: (finalIn[i] / maxVal.value) * 100,
|
||||||
|
outPercent: (finalOut[i] / maxVal.value) * 100,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +113,7 @@ const fetchData = () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
getProxyTraffic(props.proxyName)
|
getProxyTraffic(props.proxyName)
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
processData(json.history)
|
processData(json.trafficIn, json.trafficOut)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
|
|||||||
@@ -7,25 +7,9 @@ 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
|
||||||
online: boolean
|
online: boolean
|
||||||
status?: ClientStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientStatus {
|
|
||||||
phase: 'online' | 'offline'
|
|
||||||
curConns: number
|
|
||||||
proxyCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientListV2Params {
|
|
||||||
page?: number
|
|
||||||
pageSize?: number
|
|
||||||
status?: 'all' | 'online' | 'offline'
|
|
||||||
q?: string
|
|
||||||
user?: string
|
|
||||||
clientID?: string
|
|
||||||
runID?: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export interface ProxyStatsInfo {
|
export interface ProxyStatsInfo {
|
||||||
name: string
|
name: string
|
||||||
type?: string
|
|
||||||
conf: any
|
conf: any
|
||||||
user: string
|
user: string
|
||||||
clientID: string
|
clientID: string
|
||||||
@@ -16,114 +15,8 @@ export interface GetProxyResponse {
|
|||||||
proxies: ProxyStatsInfo[]
|
proxies: ProxyStatsInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProxyListV2Params {
|
|
||||||
page?: number
|
|
||||||
pageSize?: number
|
|
||||||
status?: 'all' | 'online' | 'offline'
|
|
||||||
q?: string
|
|
||||||
type?: string
|
|
||||||
user?: string
|
|
||||||
clientID?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyV2Info {
|
|
||||||
name: string
|
|
||||||
user: string
|
|
||||||
clientID: string
|
|
||||||
spec: ProxyV2Spec
|
|
||||||
status: ProxyV2Status
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyV2BaseSpec {
|
|
||||||
annotations?: Record<string, string>
|
|
||||||
metadatas?: Record<string, string>
|
|
||||||
transport?: {
|
|
||||||
useEncryption: boolean
|
|
||||||
useCompression: boolean
|
|
||||||
bandwidthLimit: string
|
|
||||||
bandwidthLimitMode: string
|
|
||||||
}
|
|
||||||
loadBalancer?: {
|
|
||||||
group: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyV2TCPBlock extends ProxyV2BaseSpec {
|
|
||||||
remotePort?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyV2UDPBlock extends ProxyV2BaseSpec {
|
|
||||||
remotePort?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyV2HTTPBlock extends ProxyV2BaseSpec {
|
|
||||||
customDomains?: string[]
|
|
||||||
subdomain?: string
|
|
||||||
locations?: string[]
|
|
||||||
hostHeaderRewrite?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyV2HTTPSBlock extends ProxyV2BaseSpec {
|
|
||||||
customDomains?: string[]
|
|
||||||
subdomain?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyV2TCPMuxBlock extends ProxyV2BaseSpec {
|
|
||||||
customDomains?: string[]
|
|
||||||
subdomain?: string
|
|
||||||
multiplexer?: string
|
|
||||||
routeByHTTPUser?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProxyV2STCPBlock = ProxyV2BaseSpec
|
|
||||||
|
|
||||||
export type ProxyV2SUDPBlock = ProxyV2BaseSpec
|
|
||||||
|
|
||||||
export type ProxyV2XTCPBlock = ProxyV2BaseSpec
|
|
||||||
|
|
||||||
export interface ProxyV2SpecBlocks {
|
|
||||||
tcp: ProxyV2TCPBlock
|
|
||||||
udp: ProxyV2UDPBlock
|
|
||||||
http: ProxyV2HTTPBlock
|
|
||||||
https: ProxyV2HTTPSBlock
|
|
||||||
tcpmux: ProxyV2TCPMuxBlock
|
|
||||||
stcp: ProxyV2STCPBlock
|
|
||||||
sudp: ProxyV2SUDPBlock
|
|
||||||
xtcp: ProxyV2XTCPBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProxyV2Type = keyof ProxyV2SpecBlocks
|
|
||||||
|
|
||||||
type ProxyV2SpecFor<T extends ProxyV2Type> = {
|
|
||||||
type: T
|
|
||||||
} & {
|
|
||||||
[K in T]: ProxyV2SpecBlocks[K]
|
|
||||||
} & {
|
|
||||||
[K in Exclude<ProxyV2Type, T>]?: never
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProxyV2Spec = {
|
|
||||||
[T in ProxyV2Type]: ProxyV2SpecFor<T>
|
|
||||||
}[ProxyV2Type]
|
|
||||||
|
|
||||||
export interface ProxyV2Status {
|
|
||||||
phase: 'online' | 'offline'
|
|
||||||
todayTrafficIn: number
|
|
||||||
todayTrafficOut: number
|
|
||||||
curConns: number
|
|
||||||
lastStartAt?: number
|
|
||||||
lastCloseAt?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrafficResponse {
|
export interface TrafficResponse {
|
||||||
name: string
|
name: string
|
||||||
unit: 'bytes'
|
trafficIn: number[]
|
||||||
granularity: 'day'
|
trafficOut: number[]
|
||||||
history: TrafficPoint[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrafficPoint {
|
|
||||||
date: string
|
|
||||||
trafficIn: number
|
|
||||||
trafficOut: number
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
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
|
||||||
@@ -17,9 +12,8 @@ export interface ServerInfoConfig {
|
|||||||
heartbeatTimeout: number
|
heartbeatTimeout: number
|
||||||
allowPortsStr: string
|
allowPortsStr: string
|
||||||
tlsForce: boolean
|
tlsForce: boolean
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerInfoStatus {
|
// Stats
|
||||||
totalTrafficIn: number
|
totalTrafficIn: number
|
||||||
totalTrafficOut: number
|
totalTrafficOut: number
|
||||||
curConns: number
|
curConns: number
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { formatDistanceToNow } from './format'
|
import { formatDistanceToNow } from './format'
|
||||||
import type { ClientInfoData, ClientStatus } from '../types/client'
|
import type { ClientInfoData } from '../types/client'
|
||||||
|
|
||||||
export class Client {
|
export class Client {
|
||||||
key: string
|
key: string
|
||||||
@@ -10,11 +10,11 @@ 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
|
||||||
online: boolean
|
online: boolean
|
||||||
status: ClientStatus
|
|
||||||
|
|
||||||
constructor(data: ClientInfoData) {
|
constructor(data: ClientInfoData) {
|
||||||
this.key = data.key
|
this.key = data.key
|
||||||
@@ -25,17 +25,18 @@ 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) {
|
||||||
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
|
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
|
||||||
}
|
}
|
||||||
this.online = data.online
|
this.online = data.online
|
||||||
this.status = data.status || {
|
|
||||||
phase: this.online ? 'online' : 'offline',
|
|
||||||
curConns: 0,
|
|
||||||
proxyCount: 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get displayName(): string {
|
get displayName(): string {
|
||||||
@@ -45,6 +46,10 @@ 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}`
|
||||||
@@ -62,4 +67,28 @@ 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,14 +19,6 @@ 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'
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">Connections</span>
|
<span class="info-label">Connections</span>
|
||||||
<span class="info-value">{{ client.status.curConns }}</span>
|
<span class="info-value">{{ totalConnections }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">Run ID</span>
|
<span class="info-label">Run ID</span>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<div class="proxies-header">
|
<div class="proxies-header">
|
||||||
<div class="proxies-title">
|
<div class="proxies-title">
|
||||||
<h2>Proxies</h2>
|
<h2>Proxies</h2>
|
||||||
<span class="proxies-count">{{ total }}</span>
|
<span class="proxies-count">{{ filteredProxies.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="proxySearch"
|
v-model="proxySearch"
|
||||||
@@ -100,32 +100,21 @@
|
|||||||
<el-icon class="is-loading"><Loading /></el-icon>
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
<span>Loading...</span>
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="proxies.length > 0" class="proxies-list">
|
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
v-for="proxy in proxies"
|
v-for="proxy in filteredProxies"
|
||||||
:key="`${proxy.type}:${proxy.name}`"
|
:key="proxy.name"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
show-type
|
show-type
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="proxySearch.trim()" class="empty-state">
|
<div v-else-if="clientProxies.length > 0" class="empty-state">
|
||||||
<p>No proxies match "{{ proxySearch }}"</p>
|
<p>No proxies match "{{ proxySearch }}"</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<p>No proxies found</p>
|
<p>No proxies found</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="total > 0" class="pagination-section">
|
|
||||||
<ElPagination
|
|
||||||
:current-page="page"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
:total="total"
|
|
||||||
layout="total, sizes, prev, pager, next"
|
|
||||||
@current-change="onPageChange"
|
|
||||||
@size-change="onPageSizeChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -141,13 +130,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElPagination } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
||||||
import { Client } from '../utils/client'
|
import { Client } from '../utils/client'
|
||||||
import { getClientV2 } from '../api/client'
|
import { getClient } from '../api/client'
|
||||||
import { getProxiesV2 } from '../api/proxy'
|
import { getProxiesByType } from '../api/proxy'
|
||||||
import {
|
import {
|
||||||
BaseProxy,
|
BaseProxy,
|
||||||
TCPProxy,
|
TCPProxy,
|
||||||
@@ -160,8 +149,6 @@ import {
|
|||||||
} from '../utils/proxy'
|
} from '../utils/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import ProxyCard from '../components/ProxyCard.vue'
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
import type { ProxyStatsInfo } from '../types/proxy'
|
|
||||||
import type { ServerInfo } from '../types/server'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -176,191 +163,119 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const proxiesLoading = ref(false)
|
const proxiesLoading = ref(false)
|
||||||
const proxies = ref<BaseProxy[]>([])
|
const allProxies = ref<BaseProxy[]>([])
|
||||||
const proxySearch = ref('')
|
const proxySearch = ref('')
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(10)
|
|
||||||
const total = ref(0)
|
|
||||||
let requestSeq = 0
|
|
||||||
let searchDebounceTimer: number | null = null
|
|
||||||
|
|
||||||
let serverInfoPromise: Promise<ServerInfo> | null = null
|
let serverInfo: {
|
||||||
|
vhostHTTPPort: number
|
||||||
|
vhostHTTPSPort: number
|
||||||
|
tcpmuxHTTPConnectPort: number
|
||||||
|
subdomainHost: string
|
||||||
|
} | null = null
|
||||||
|
|
||||||
const fetchServerInfo = (): Promise<ServerInfo> => {
|
const clientProxies = computed(() => {
|
||||||
if (!serverInfoPromise) {
|
if (!client.value) return []
|
||||||
serverInfoPromise = getServerInfo().catch((err) => {
|
return allProxies.value.filter(
|
||||||
serverInfoPromise = null
|
(p) =>
|
||||||
throw err
|
p.clientID === client.value!.clientID && p.user === client.value!.user,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
return serverInfoPromise
|
const filteredProxies = computed(() => {
|
||||||
|
if (!proxySearch.value) return clientProxies.value
|
||||||
|
const search = proxySearch.value.toLowerCase()
|
||||||
|
return clientProxies.value.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(search) ||
|
||||||
|
p.type.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalConnections = computed(() => {
|
||||||
|
return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchServerInfo = async () => {
|
||||||
|
if (serverInfo) return serverInfo
|
||||||
|
const res = await getServerInfo()
|
||||||
|
serverInfo = res
|
||||||
|
return serverInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchClient = async (): Promise<boolean> => {
|
const fetchClient = async () => {
|
||||||
const key = route.params.key as string
|
const key = route.params.key as string
|
||||||
if (!key) {
|
if (!key) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await getClientV2(key)
|
const data = await getClient(key)
|
||||||
client.value = new Client(data)
|
client.value = new Client(data)
|
||||||
return true
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error('Failed to fetch client: ' + error.message)
|
ElMessage.error('Failed to fetch client: ' + error.message)
|
||||||
return false
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertProxy = async (
|
|
||||||
proxy: ProxyStatsInfo,
|
|
||||||
): Promise<BaseProxy | null> => {
|
|
||||||
const type = proxy.type || ''
|
|
||||||
if (type === 'tcp') {
|
|
||||||
return new TCPProxy(proxy)
|
|
||||||
}
|
|
||||||
if (type === 'udp') {
|
|
||||||
return new UDPProxy(proxy)
|
|
||||||
}
|
|
||||||
if (type === 'http') {
|
|
||||||
const info = await fetchServerInfo()
|
|
||||||
if (info && info.config.vhostHTTPPort) {
|
|
||||||
return new HTTPProxy(
|
|
||||||
proxy,
|
|
||||||
info.config.vhostHTTPPort,
|
|
||||||
info.config.subdomainHost,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (type === 'https') {
|
|
||||||
const info = await fetchServerInfo()
|
|
||||||
if (info && info.config.vhostHTTPSPort) {
|
|
||||||
return new HTTPSProxy(
|
|
||||||
proxy,
|
|
||||||
info.config.vhostHTTPSPort,
|
|
||||||
info.config.subdomainHost,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (type === 'tcpmux') {
|
|
||||||
const info = await fetchServerInfo()
|
|
||||||
if (info && info.config.tcpmuxHTTPConnectPort) {
|
|
||||||
return new TCPMuxProxy(
|
|
||||||
proxy,
|
|
||||||
info.config.tcpmuxHTTPConnectPort,
|
|
||||||
info.config.subdomainHost,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (type === 'stcp') {
|
|
||||||
return new STCPProxy(proxy)
|
|
||||||
}
|
|
||||||
if (type === 'sudp') {
|
|
||||||
return new SUDPProxy(proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bp = new BaseProxy(proxy)
|
|
||||||
bp.type = type
|
|
||||||
return bp
|
|
||||||
}
|
|
||||||
|
|
||||||
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
|
||||||
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
|
||||||
return converted.filter((item): item is BaseProxy => item !== null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchProxies = async () => {
|
const fetchProxies = async () => {
|
||||||
if (!client.value) return
|
|
||||||
const seq = ++requestSeq
|
|
||||||
proxiesLoading.value = true
|
proxiesLoading.value = true
|
||||||
|
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
|
||||||
|
const proxies: BaseProxy[] = []
|
||||||
try {
|
try {
|
||||||
const q = proxySearch.value.trim()
|
const info = await fetchServerInfo()
|
||||||
const data = await getProxiesV2({
|
for (const type of proxyTypes) {
|
||||||
page: page.value,
|
try {
|
||||||
pageSize: pageSize.value,
|
const json = await getProxiesByType(type)
|
||||||
q: q || undefined,
|
if (!json.proxies) continue
|
||||||
clientID: client.value.clientID,
|
if (type === 'tcp') {
|
||||||
user: client.value.user,
|
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
|
||||||
})
|
} else if (type === 'udp') {
|
||||||
if (seq !== requestSeq) return
|
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
|
||||||
|
} else if (type === 'http' && info?.vhostHTTPPort) {
|
||||||
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
proxies.push(
|
||||||
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
...json.proxies.map(
|
||||||
page.value = maxPage
|
(p: any) =>
|
||||||
await fetchProxies()
|
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
||||||
return
|
),
|
||||||
|
)
|
||||||
|
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
||||||
|
proxies.push(
|
||||||
|
...json.proxies.map(
|
||||||
|
(p: any) =>
|
||||||
|
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
||||||
|
proxies.push(
|
||||||
|
...json.proxies.map(
|
||||||
|
(p: any) =>
|
||||||
|
new TCPMuxProxy(
|
||||||
|
p,
|
||||||
|
info.tcpmuxHTTPConnectPort,
|
||||||
|
info.subdomainHost,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (type === 'stcp') {
|
||||||
|
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
|
||||||
|
} else if (type === 'sudp') {
|
||||||
|
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
const converted = await convertProxies(data.items)
|
// Ignore
|
||||||
if (seq !== requestSeq) return
|
}
|
||||||
|
}
|
||||||
proxies.value = converted
|
allProxies.value = proxies
|
||||||
total.value = data.total
|
} catch {
|
||||||
page.value = data.page
|
// Ignore
|
||||||
pageSize.value = data.pageSize
|
|
||||||
} catch (error: any) {
|
|
||||||
if (seq !== requestSeq) return
|
|
||||||
ElMessage.error('Failed to fetch proxies: ' + error.message)
|
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === requestSeq) {
|
|
||||||
proxiesLoading.value = false
|
proxiesLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const clearSearchDebounce = () => {
|
|
||||||
if (searchDebounceTimer !== null) {
|
|
||||||
window.clearTimeout(searchDebounceTimer)
|
|
||||||
searchDebounceTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalidateProxyRequests = () => {
|
|
||||||
requestSeq++
|
|
||||||
proxiesLoading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetPageAndFetch = () => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
page.value = 1
|
|
||||||
fetchProxies()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPageChange = (value: number) => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
page.value = value
|
|
||||||
fetchProxies()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPageSizeChange = (value: number) => {
|
|
||||||
pageSize.value = value
|
|
||||||
resetPageAndFetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(proxySearch, () => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
invalidateProxyRequests()
|
|
||||||
page.value = 1
|
|
||||||
searchDebounceTimer = window.setTimeout(() => {
|
|
||||||
searchDebounceTimer = null
|
|
||||||
fetchProxies()
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const ok = await fetchClient()
|
|
||||||
if (!ok || !client.value) return
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchClient()
|
||||||
fetchProxies()
|
fetchProxies()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -568,12 +483,6 @@ html.dark .status-badge.online {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-section {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 20px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxies-list {
|
.proxies-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -16,9 +16,7 @@
|
|||||||
>
|
>
|
||||||
<span class="status-dot" :class="tab.value"></span>
|
<span class="status-dot" :class="tab.value"></span>
|
||||||
<span class="tab-label">{{ tab.label }}</span>
|
<span class="tab-label">{{ tab.label }}</span>
|
||||||
<span v-if="tab.count !== null" class="tab-count">{{
|
<span class="tab-count">{{ tab.count }}</span>
|
||||||
tab.count
|
|
||||||
}}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,9 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="clients-content">
|
<div v-loading="loading" class="clients-content">
|
||||||
<div v-if="clients.length > 0" class="clients-list">
|
<div v-if="filteredClients.length > 0" class="clients-list">
|
||||||
<ClientCard
|
<ClientCard
|
||||||
v-for="client in clients"
|
v-for="client in filteredClients"
|
||||||
:key="client.key"
|
:key="client.key"
|
||||||
:client="client"
|
:client="client"
|
||||||
/>
|
/>
|
||||||
@@ -46,123 +44,82 @@
|
|||||||
<el-empty description="No clients found" />
|
<el-empty description="No clients found" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="total > 0" class="pagination-section">
|
|
||||||
<ElPagination
|
|
||||||
:current-page="page"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
:total="total"
|
|
||||||
layout="total, sizes, prev, pager, next"
|
|
||||||
@current-change="onPageChange"
|
|
||||||
@size-change="onPageSizeChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage, ElPagination } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { Client } from '../utils/client'
|
import { Client } from '../utils/client'
|
||||||
import ClientCard from '../components/ClientCard.vue'
|
import ClientCard from '../components/ClientCard.vue'
|
||||||
import { getClientsV2 } from '../api/client'
|
import { getClients } from '../api/client'
|
||||||
|
|
||||||
const clients = ref<Client[]>([])
|
const clients = ref<Client[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
|
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(10)
|
|
||||||
const total = ref(0)
|
|
||||||
|
|
||||||
let refreshTimer: number | null = null
|
let refreshTimer: number | null = null
|
||||||
let searchDebounceTimer: number | null = null
|
|
||||||
let requestSeq = 0
|
const stats = computed(() => {
|
||||||
|
const total = clients.value.length
|
||||||
|
const online = clients.value.filter((c) => c.online).length
|
||||||
|
const offline = total - online
|
||||||
|
return { total, online, offline }
|
||||||
|
})
|
||||||
|
|
||||||
const statusTabs = computed(() => [
|
const statusTabs = computed(() => [
|
||||||
{
|
{ value: 'all' as const, label: 'All', count: stats.value.total },
|
||||||
value: 'all' as const,
|
{ value: 'online' as const, label: 'Online', count: stats.value.online },
|
||||||
label: 'All',
|
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
|
||||||
count: statusFilter.value === 'all' ? total.value : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'online' as const,
|
|
||||||
label: 'Online',
|
|
||||||
count: statusFilter.value === 'online' ? total.value : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'offline' as const,
|
|
||||||
label: 'Offline',
|
|
||||||
count: statusFilter.value === 'offline' ? total.value : null,
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const fetchData = async (silent = false) => {
|
const filteredClients = computed(() => {
|
||||||
const seq = ++requestSeq
|
let result = clients.value
|
||||||
if (!silent) loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await getClientsV2({
|
|
||||||
page: page.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
status: statusFilter.value,
|
|
||||||
q: searchText.value.trim(),
|
|
||||||
})
|
|
||||||
if (seq !== requestSeq) return
|
|
||||||
|
|
||||||
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
// Filter by status
|
||||||
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
if (statusFilter.value === 'online') {
|
||||||
page.value = maxPage
|
result = result.filter((c) => c.online)
|
||||||
await fetchData(silent)
|
} else if (statusFilter.value === 'offline') {
|
||||||
return
|
result = result.filter((c) => !c.online)
|
||||||
}
|
}
|
||||||
|
|
||||||
clients.value = data.items.map((item) => new Client(item))
|
// Filter by search text
|
||||||
total.value = data.total
|
if (searchText.value) {
|
||||||
page.value = data.page
|
result = result.filter((c) => c.matchesFilter(searchText.value))
|
||||||
pageSize.value = data.pageSize
|
}
|
||||||
|
|
||||||
|
// Sort: online first, then by display name
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (a.online !== b.online) {
|
||||||
|
return a.online ? -1 : 1
|
||||||
|
}
|
||||||
|
return a.displayName.localeCompare(b.displayName)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const json = await getClients()
|
||||||
|
clients.value = json.map((data) => new Client(data))
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (seq !== requestSeq) return
|
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
message: 'Failed to fetch clients: ' + error.message,
|
message: 'Failed to fetch clients: ' + error.message,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === requestSeq) {
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const clearSearchDebounce = () => {
|
|
||||||
if (searchDebounceTimer !== null) {
|
|
||||||
window.clearTimeout(searchDebounceTimer)
|
|
||||||
searchDebounceTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetPageAndFetch = () => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
page.value = 1
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPageChange = (value: number) => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
page.value = value
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPageSizeChange = (value: number) => {
|
|
||||||
pageSize.value = value
|
|
||||||
resetPageAndFetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
const startAutoRefresh = () => {
|
const startAutoRefresh = () => {
|
||||||
refreshTimer = window.setInterval(() => {
|
refreshTimer = window.setInterval(() => {
|
||||||
fetchData(true)
|
fetchData()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,19 +130,6 @@ const stopAutoRefresh = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(statusFilter, () => {
|
|
||||||
resetPageAndFetch()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(searchText, () => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
page.value = 1
|
|
||||||
searchDebounceTimer = window.setTimeout(() => {
|
|
||||||
searchDebounceTimer = null
|
|
||||||
fetchData()
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
@@ -193,7 +137,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
clearSearchDebounce()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -331,11 +274,6 @@ onUnmounted(() => {
|
|||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-section {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode adjustments */
|
/* Dark mode adjustments */
|
||||||
html.dark .status-tab {
|
html.dark .status-tab {
|
||||||
background: var(--el-bg-color-overlay);
|
background: var(--el-bg-color-overlay);
|
||||||
@@ -360,9 +298,5 @@ html.dark .status-tab.active {
|
|||||||
.status-tab {
|
.status-tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-section {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions-section">
|
<div class="actions-section">
|
||||||
<ActionButton variant="outline" size="small" @click="refreshData">
|
<ActionButton variant="outline" size="small" @click="fetchData">
|
||||||
Refresh
|
Refresh
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
@@ -27,6 +27,36 @@
|
|||||||
clearable
|
clearable
|
||||||
class="main-search"
|
class="main-search"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PopoverMenu
|
||||||
|
:model-value="selectedClientKey"
|
||||||
|
:width="220"
|
||||||
|
placement="bottom-end"
|
||||||
|
selectable
|
||||||
|
filterable
|
||||||
|
filter-placeholder="Search clients..."
|
||||||
|
:display-value="selectedClientLabel"
|
||||||
|
clearable
|
||||||
|
class="client-filter"
|
||||||
|
@update:model-value="onClientFilterChange($event as string)"
|
||||||
|
>
|
||||||
|
<template #default="{ filterText }">
|
||||||
|
<PopoverMenuItem value="">All Clients</PopoverMenuItem>
|
||||||
|
<PopoverMenuItem
|
||||||
|
v-if="clientIDFilter && !selectedClientInList"
|
||||||
|
:value="selectedClientKey"
|
||||||
|
>
|
||||||
|
{{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)
|
||||||
|
</PopoverMenuItem>
|
||||||
|
<PopoverMenuItem
|
||||||
|
v-for="client in filteredClientOptions(filterText)"
|
||||||
|
:key="client.key"
|
||||||
|
:value="client.key"
|
||||||
|
>
|
||||||
|
{{ client.label }}
|
||||||
|
</PopoverMenuItem>
|
||||||
|
</template>
|
||||||
|
</PopoverMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="type-tabs">
|
<div class="type-tabs">
|
||||||
@@ -44,9 +74,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="proxies-content">
|
<div v-loading="loading" class="proxies-content">
|
||||||
<div v-if="proxies.length > 0" class="proxies-list">
|
<div v-if="filteredProxies.length > 0" class="proxies-list">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
v-for="proxy in proxies"
|
v-for="proxy in filteredProxies"
|
||||||
:key="`${proxy.type}:${proxy.name}`"
|
:key="`${proxy.type}:${proxy.name}`"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
:show-type="activeType === 'all'"
|
:show-type="activeType === 'all'"
|
||||||
@@ -57,18 +87,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="total > 0" class="pagination-section">
|
|
||||||
<ElPagination
|
|
||||||
:current-page="page"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
:total="total"
|
|
||||||
layout="total, sizes, prev, pager, next"
|
|
||||||
@current-change="onPageChange"
|
|
||||||
@size-change="onPageSizeChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
v-model="showClearDialog"
|
v-model="showClearDialog"
|
||||||
title="Clear Offline"
|
title="Clear Offline"
|
||||||
@@ -81,9 +99,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onUnmounted } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElPagination } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import ActionButton from '@shared/components/ActionButton.vue'
|
import ActionButton from '@shared/components/ActionButton.vue'
|
||||||
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
|
||||||
@@ -98,13 +116,15 @@ import {
|
|||||||
SUDPProxy,
|
SUDPProxy,
|
||||||
} from '../utils/proxy'
|
} from '../utils/proxy'
|
||||||
import ProxyCard from '../components/ProxyCard.vue'
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
|
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
||||||
|
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
||||||
import {
|
import {
|
||||||
getProxiesV2,
|
getProxiesByType,
|
||||||
clearOfflineProxies as apiClearOfflineProxies,
|
clearOfflineProxies as apiClearOfflineProxies,
|
||||||
} from '../api/proxy'
|
} from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import type { ProxyStatsInfo } from '../types/proxy'
|
import { getClients } from '../api/client'
|
||||||
import type { ServerInfo } from '../types/server'
|
import { Client } from '../utils/client'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -121,162 +141,243 @@ const proxyTypes = [
|
|||||||
{ label: 'SUDP', value: 'sudp' },
|
{ label: 'SUDP', value: 'sudp' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeType = ref((route.params.type as string) || 'all')
|
const activeType = ref((route.params.type as string) || 'tcp')
|
||||||
const proxies = ref<BaseProxy[]>([])
|
const proxies = ref<BaseProxy[]>([])
|
||||||
|
const clients = ref<Client[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const showClearDialog = ref(false)
|
const showClearDialog = ref(false)
|
||||||
const page = ref(1)
|
const clientIDFilter = ref((route.query.clientID as string) || '')
|
||||||
const pageSize = ref(10)
|
const userFilter = ref((route.query.user as string) || '')
|
||||||
const total = ref(0)
|
|
||||||
let requestSeq = 0
|
|
||||||
let searchDebounceTimer: number | null = null
|
|
||||||
|
|
||||||
// Server info cache - cache the Promise itself so concurrent first calls
|
const clientOptions = computed(() => {
|
||||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
return clients.value
|
||||||
let serverInfoPromise: Promise<ServerInfo> | null = null
|
.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
clientID: c.clientID,
|
||||||
|
user: c.user,
|
||||||
|
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
})
|
||||||
|
|
||||||
const fetchServerInfo = (): Promise<ServerInfo> => {
|
// Compute selected client key for el-select v-model
|
||||||
if (!serverInfoPromise) {
|
const selectedClientKey = computed(() => {
|
||||||
serverInfoPromise = getServerInfo().catch((err) => {
|
if (!clientIDFilter.value) return ''
|
||||||
// Allow retry after failure
|
const client = clientOptions.value.find(
|
||||||
serverInfoPromise = null
|
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||||
throw err
|
)
|
||||||
|
// Return a synthetic key even if not found, so the select shows the filter is active
|
||||||
|
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedClientLabel = computed(() => {
|
||||||
|
if (!clientIDFilter.value) return 'All Clients'
|
||||||
|
const client = clientOptions.value.find(
|
||||||
|
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||||
|
)
|
||||||
|
return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredClientOptions = (filterText: string) => {
|
||||||
|
if (!filterText) return clientOptions.value
|
||||||
|
const search = filterText.toLowerCase()
|
||||||
|
return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the filtered client exists in the client list
|
||||||
|
const selectedClientInList = computed(() => {
|
||||||
|
if (!clientIDFilter.value) return true
|
||||||
|
return clientOptions.value.some(
|
||||||
|
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredProxies = computed(() => {
|
||||||
|
let result = proxies.value
|
||||||
|
|
||||||
|
// Filter by clientID and user if specified
|
||||||
|
if (clientIDFilter.value) {
|
||||||
|
result = result.filter(
|
||||||
|
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search text across multiple fields
|
||||||
|
if (searchText.value) {
|
||||||
|
const search = searchText.value.toLowerCase()
|
||||||
|
result = result.filter((p) => {
|
||||||
|
const fields: unknown[] = [
|
||||||
|
p.name,
|
||||||
|
p.type,
|
||||||
|
p.clientID,
|
||||||
|
p.user,
|
||||||
|
p.addr,
|
||||||
|
p.port,
|
||||||
|
p.customDomains,
|
||||||
|
p.subdomain,
|
||||||
|
]
|
||||||
|
return fields.some((v) => matchesSearch(v, search))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return serverInfoPromise
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Normalize a field of unknown shape (string / number / array / null) to a
|
||||||
|
// lowercase string for case-insensitive substring matching. Arrays are joined
|
||||||
|
// so e.g. customDomains: ["A.com","B.com"] is searchable as one blob.
|
||||||
|
const matchesSearch = (value: unknown, needle: string): boolean => {
|
||||||
|
if (value === null || value === undefined) return false
|
||||||
|
let str: string
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
str = value
|
||||||
|
.filter((v) => v !== null && v !== undefined)
|
||||||
|
.map((v) => String(v))
|
||||||
|
.join(' ')
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
if (value === 0) return false
|
||||||
|
str = String(value)
|
||||||
|
} else {
|
||||||
|
str = String(value)
|
||||||
|
}
|
||||||
|
if (!str) return false
|
||||||
|
return str.toLowerCase().includes(needle)
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertProxy = async (
|
const onClientFilterChange = (key: string) => {
|
||||||
proxy: ProxyStatsInfo,
|
if (key) {
|
||||||
): Promise<BaseProxy | null> => {
|
const client = clientOptions.value.find((c) => c.key === key)
|
||||||
const type = proxy.type || activeType.value
|
if (client) {
|
||||||
|
router.replace({
|
||||||
|
query: { ...route.query, clientID: client.clientID, user: client.user },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const query = { ...route.query }
|
||||||
|
delete query.clientID
|
||||||
|
delete query.user
|
||||||
|
router.replace({ query })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchClients = async () => {
|
||||||
|
try {
|
||||||
|
const json = await getClients()
|
||||||
|
clients.value = json.map((data) => new Client(data))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors when fetching clients
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server info cache
|
||||||
|
let serverInfo: {
|
||||||
|
vhostHTTPPort: number
|
||||||
|
vhostHTTPSPort: number
|
||||||
|
tcpmuxHTTPConnectPort: number
|
||||||
|
subdomainHost: string
|
||||||
|
} | null = null
|
||||||
|
|
||||||
|
const fetchServerInfo = async () => {
|
||||||
|
if (serverInfo) return serverInfo
|
||||||
|
const res = await getServerInfo()
|
||||||
|
serverInfo = res
|
||||||
|
return serverInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertProxies = async (
|
||||||
|
type: string,
|
||||||
|
json: any,
|
||||||
|
): Promise<BaseProxy[]> => {
|
||||||
if (type === 'tcp') {
|
if (type === 'tcp') {
|
||||||
return new TCPProxy(proxy)
|
return json.proxies.map((p: any) => new TCPProxy(p))
|
||||||
}
|
}
|
||||||
if (type === 'udp') {
|
if (type === 'udp') {
|
||||||
return new UDPProxy(proxy)
|
return json.proxies.map((p: any) => new UDPProxy(p))
|
||||||
}
|
}
|
||||||
if (type === 'http') {
|
if (type === 'http') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.config.vhostHTTPPort) {
|
if (info && info.vhostHTTPPort) {
|
||||||
return new HTTPProxy(
|
return json.proxies.map(
|
||||||
proxy,
|
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
||||||
info.config.vhostHTTPPort,
|
|
||||||
info.config.subdomainHost,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return []
|
||||||
}
|
}
|
||||||
if (type === 'https') {
|
if (type === 'https') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.config.vhostHTTPSPort) {
|
if (info && info.vhostHTTPSPort) {
|
||||||
return new HTTPSProxy(
|
return json.proxies.map(
|
||||||
proxy,
|
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
||||||
info.config.vhostHTTPSPort,
|
|
||||||
info.config.subdomainHost,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return []
|
||||||
}
|
}
|
||||||
if (type === 'tcpmux') {
|
if (type === 'tcpmux') {
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
if (info && info.config.tcpmuxHTTPConnectPort) {
|
if (info && info.tcpmuxHTTPConnectPort) {
|
||||||
return new TCPMuxProxy(
|
return json.proxies.map(
|
||||||
proxy,
|
(p: any) =>
|
||||||
info.config.tcpmuxHTTPConnectPort,
|
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
|
||||||
info.config.subdomainHost,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return []
|
||||||
}
|
}
|
||||||
if (type === 'stcp') {
|
if (type === 'stcp') {
|
||||||
return new STCPProxy(proxy)
|
return json.proxies.map((p: any) => new STCPProxy(p))
|
||||||
}
|
}
|
||||||
if (type === 'sudp') {
|
if (type === 'sudp') {
|
||||||
return new SUDPProxy(proxy)
|
return json.proxies.map((p: any) => new SUDPProxy(p))
|
||||||
}
|
}
|
||||||
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
||||||
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
||||||
const bp = new BaseProxy(proxy)
|
return json.proxies.map((p: any) => {
|
||||||
|
const bp = new BaseProxy(p)
|
||||||
bp.type = type
|
bp.type = type
|
||||||
return bp
|
return bp
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
|
const allProxyTypes = [
|
||||||
const converted = await Promise.all(items.map((item) => convertProxy(item)))
|
'tcp',
|
||||||
return converted.filter((item): item is BaseProxy => item !== null)
|
'udp',
|
||||||
}
|
'http',
|
||||||
|
'https',
|
||||||
|
'tcpmux',
|
||||||
|
'stcp',
|
||||||
|
'xtcp',
|
||||||
|
'sudp',
|
||||||
|
]
|
||||||
|
|
||||||
const fetchData = async (silent = false) => {
|
const fetchData = async () => {
|
||||||
const seq = ++requestSeq
|
loading.value = true
|
||||||
if (!silent) loading.value = true
|
proxies.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const q = searchText.value.trim()
|
const type = activeType.value
|
||||||
const data = await getProxiesV2({
|
|
||||||
page: page.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
type: activeType.value === 'all' ? undefined : activeType.value,
|
|
||||||
q: q || undefined,
|
|
||||||
})
|
|
||||||
if (seq !== requestSeq) return
|
|
||||||
|
|
||||||
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
|
if (type === 'all') {
|
||||||
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
|
const results = await Promise.all(
|
||||||
page.value = maxPage
|
allProxyTypes.map(async (t) => {
|
||||||
await fetchData(silent)
|
const json = await getProxiesByType(t)
|
||||||
return
|
return convertProxies(t, json)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
proxies.value = results.flat()
|
||||||
|
} else {
|
||||||
|
const json = await getProxiesByType(type)
|
||||||
|
proxies.value = await convertProxies(type, json)
|
||||||
}
|
}
|
||||||
|
|
||||||
const converted = await convertProxies(data.items)
|
|
||||||
if (seq !== requestSeq) return
|
|
||||||
|
|
||||||
proxies.value = converted
|
|
||||||
total.value = data.total
|
|
||||||
page.value = data.page
|
|
||||||
pageSize.value = data.pageSize
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (seq !== requestSeq) return
|
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
message: 'Failed to fetch proxies: ' + error.message,
|
message: 'Failed to fetch proxies: ' + error.message,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === requestSeq) {
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const clearSearchDebounce = () => {
|
|
||||||
if (searchDebounceTimer !== null) {
|
|
||||||
window.clearTimeout(searchDebounceTimer)
|
|
||||||
searchDebounceTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetPageAndFetch = () => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
page.value = 1
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshData = () => {
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPageChange = (value: number) => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
page.value = value
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPageSizeChange = (value: number) => {
|
|
||||||
pageSize.value = value
|
|
||||||
resetPageAndFetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearConfirm = async () => {
|
const handleClearConfirm = async () => {
|
||||||
showClearDialog.value = false
|
showClearDialog.value = false
|
||||||
@@ -299,45 +400,25 @@ const clearOfflineProxies = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizeClientQuery = () => {
|
|
||||||
const hasClientQuery =
|
|
||||||
Object.prototype.hasOwnProperty.call(route.query, 'clientID') ||
|
|
||||||
Object.prototype.hasOwnProperty.call(route.query, 'user')
|
|
||||||
if (!hasClientQuery) return
|
|
||||||
|
|
||||||
const query = { ...route.query }
|
|
||||||
delete query.clientID
|
|
||||||
delete query.user
|
|
||||||
router.replace({ query })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch for type changes
|
// Watch for type changes
|
||||||
watch(activeType, (newType) => {
|
watch(activeType, (newType) => {
|
||||||
clearSearchDebounce()
|
|
||||||
page.value = 1
|
|
||||||
// Update route but preserve query params
|
// Update route but preserve query params
|
||||||
router.replace({ params: { type: newType }, query: route.query })
|
router.replace({ params: { type: newType }, query: route.query })
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(searchText, () => {
|
// Watch for route query changes (client filter)
|
||||||
clearSearchDebounce()
|
watch(
|
||||||
page.value = 1
|
() => [route.query.clientID, route.query.user],
|
||||||
searchDebounceTimer = window.setTimeout(() => {
|
([newClientID, newUser]) => {
|
||||||
searchDebounceTimer = null
|
clientIDFilter.value = (newClientID as string) || ''
|
||||||
fetchData()
|
userFilter.value = (newUser as string) || ''
|
||||||
}, 300)
|
},
|
||||||
})
|
)
|
||||||
|
|
||||||
watch(() => route.query, sanitizeClientQuery)
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
clearSearchDebounce()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
sanitizeClientQuery()
|
|
||||||
fetchData()
|
fetchData()
|
||||||
|
fetchClients()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -404,11 +485,16 @@ fetchData()
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-search :deep(.el-input__wrapper) {
|
.main-search :deep(.el-input__wrapper),
|
||||||
|
.client-filter :deep(.el-input__wrapper) {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-filter {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
.type-tabs {
|
.type-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -453,18 +539,13 @@ fetchData()
|
|||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-section {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.search-row {
|
.search-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-section {
|
.client-filter {
|
||||||
justify-content: center;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ import {
|
|||||||
Tickets,
|
Tickets,
|
||||||
Location,
|
Location,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { getProxyByNameV2 } from '../api/proxy'
|
import { getProxyByName } from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import {
|
import {
|
||||||
BaseProxy,
|
BaseProxy,
|
||||||
@@ -254,7 +254,6 @@ 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()
|
||||||
@@ -276,7 +275,12 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let serverInfo: ServerInfo | null = null
|
let serverInfo: {
|
||||||
|
vhostHTTPPort: number
|
||||||
|
vhostHTTPSPort: number
|
||||||
|
tcpmuxHTTPConnectPort: number
|
||||||
|
subdomainHost: string
|
||||||
|
} | null = null
|
||||||
|
|
||||||
const clientLink = computed(() => {
|
const clientLink = computed(() => {
|
||||||
if (!proxy.value) return ''
|
if (!proxy.value) return ''
|
||||||
@@ -361,32 +365,27 @@ const fetchProxy = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getProxyByNameV2(name)
|
const data = await getProxyByName(name)
|
||||||
const info = await fetchServerInfo()
|
const info = await fetchServerInfo()
|
||||||
const config = info.config
|
const type = data.conf?.type || ''
|
||||||
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' && config.vhostHTTPPort) {
|
} else if (type === 'http' && info?.vhostHTTPPort) {
|
||||||
proxy.value = new HTTPProxy(
|
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
|
||||||
data,
|
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
||||||
config.vhostHTTPPort,
|
|
||||||
config.subdomainHost,
|
|
||||||
)
|
|
||||||
} else if (type === 'https' && config.vhostHTTPSPort) {
|
|
||||||
proxy.value = new HTTPSProxy(
|
proxy.value = new HTTPSProxy(
|
||||||
data,
|
data,
|
||||||
config.vhostHTTPSPort,
|
info.vhostHTTPSPort,
|
||||||
config.subdomainHost,
|
info.subdomainHost,
|
||||||
)
|
)
|
||||||
} else if (type === 'tcpmux' && config.tcpmuxHTTPConnectPort) {
|
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
||||||
proxy.value = new TCPMuxProxy(
|
proxy.value = new TCPMuxProxy(
|
||||||
data,
|
data,
|
||||||
config.tcpmuxHTTPConnectPort,
|
info.tcpmuxHTTPConnectPort,
|
||||||
config.subdomainHost,
|
info.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.status.clientCounts"
|
:value="data.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="proxyCounts"
|
:value="data.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.status.curConns"
|
:value="data.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.status.totalTrafficIn) }}
|
{{ formatFileSize(data.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.status.totalTrafficOut) }}
|
{{ formatFileSize(data.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.status.proxyTypeCount"
|
v-for="(count, type) in data.proxyTypeCounts"
|
||||||
: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.config.bindPort }}</span>
|
<span class="config-value">{{ data.bindPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.config.kcpBindPort != 0">
|
<div class="config-item" v-if="data.kcpBindPort != 0">
|
||||||
<span class="config-label">KCP Port</span>
|
<span class="config-label">KCP Port</span>
|
||||||
<span class="config-value">{{ data.config.kcpBindPort }}</span>
|
<span class="config-value">{{ data.kcpBindPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.config.quicBindPort != 0">
|
<div class="config-item" v-if="data.quicBindPort != 0">
|
||||||
<span class="config-label">QUIC Port</span>
|
<span class="config-label">QUIC Port</span>
|
||||||
<span class="config-value">{{ data.config.quicBindPort }}</span>
|
<span class="config-value">{{ data.quicBindPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.config.vhostHTTPPort != 0">
|
<div class="config-item" v-if="data.vhostHTTPPort != 0">
|
||||||
<span class="config-label">HTTP Port</span>
|
<span class="config-label">HTTP Port</span>
|
||||||
<span class="config-value">{{ data.config.vhostHTTPPort }}</span>
|
<span class="config-value">{{ data.vhostHTTPPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.config.vhostHTTPSPort != 0">
|
<div class="config-item" v-if="data.vhostHTTPSPort != 0">
|
||||||
<span class="config-label">HTTPS Port</span>
|
<span class="config-label">HTTPS Port</span>
|
||||||
<span class="config-value">{{ data.config.vhostHTTPSPort }}</span>
|
<span class="config-value">{{ data.vhostHTTPSPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.config.tcpmuxHTTPConnectPort != 0">
|
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0">
|
||||||
<span class="config-label">TCPMux Port</span>
|
<span class="config-label">TCPMux Port</span>
|
||||||
<span class="config-value">{{ data.config.tcpmuxHTTPConnectPort }}</span>
|
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.config.subdomainHost != ''">
|
<div class="config-item" v-if="data.subdomainHost != ''">
|
||||||
<span class="config-label">Subdomain Host</span>
|
<span class="config-label">Subdomain Host</span>
|
||||||
<span class="config-value">{{ data.config.subdomainHost }}</span>
|
<span class="config-value">{{ data.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.config.maxPoolCount }}</span>
|
<span class="config-value">{{ data.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">{{ maxPortsPerClientLabel }}</span>
|
<span class="config-value">{{ data.maxPortsPerClient }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.config.allowPortsStr != ''">
|
<div class="config-item" v-if="data.allowPortsStr != ''">
|
||||||
<span class="config-label">Allow Ports</span>
|
<span class="config-label">Allow Ports</span>
|
||||||
<span class="config-value">{{ data.config.allowPortsStr }}</span>
|
<span class="config-value">{{ data.allowPortsStr }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item" v-if="data.config.tlsForce">
|
<div class="config-item" v-if="data.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.config.heartbeatTimeout }}s</span>
|
<span class="config-value">{{ data.heartbeatTimeout }}s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -167,11 +167,9 @@ 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<ServerInfo>({
|
const data = ref({
|
||||||
version: '',
|
version: '',
|
||||||
config: {
|
|
||||||
bindPort: 0,
|
bindPort: 0,
|
||||||
kcpBindPort: 0,
|
kcpBindPort: 0,
|
||||||
quicBindPort: 0,
|
quicBindPort: 0,
|
||||||
@@ -180,46 +178,58 @@ const data = ref<ServerInfo>({
|
|||||||
tcpmuxHTTPConnectPort: 0,
|
tcpmuxHTTPConnectPort: 0,
|
||||||
subdomainHost: '',
|
subdomainHost: '',
|
||||||
maxPoolCount: 0,
|
maxPoolCount: 0,
|
||||||
maxPortsPerClient: 0,
|
maxPortsPerClient: '',
|
||||||
allowPortsStr: '',
|
allowPortsStr: '',
|
||||||
tlsForce: false,
|
tlsForce: false,
|
||||||
heartbeatTimeout: 0,
|
heartbeatTimeout: 0,
|
||||||
},
|
|
||||||
status: {
|
|
||||||
clientCounts: 0,
|
clientCounts: 0,
|
||||||
curConns: 0,
|
curConns: 0,
|
||||||
|
proxyCounts: 0,
|
||||||
totalTrafficIn: 0,
|
totalTrafficIn: 0,
|
||||||
totalTrafficOut: 0,
|
totalTrafficOut: 0,
|
||||||
proxyTypeCount: {},
|
proxyTypeCounts: {} as Record<string, number>,
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActiveProxies = computed(() => {
|
const hasActiveProxies = computed(() => {
|
||||||
return Object.values(data.value.status.proxyTypeCount).some((c) => c > 0)
|
return Object.values(data.value.proxyTypeCounts).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 =
|
const total = data.value.totalTrafficIn + data.value.totalTrafficOut
|
||||||
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 = json
|
data.value.version = json.version
|
||||||
|
data.value.bindPort = json.bindPort
|
||||||
|
data.value.kcpBindPort = json.kcpBindPort
|
||||||
|
data.value.quicBindPort = json.quicBindPort
|
||||||
|
data.value.vhostHTTPPort = json.vhostHTTPPort
|
||||||
|
data.value.vhostHTTPSPort = json.vhostHTTPSPort
|
||||||
|
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
|
||||||
|
data.value.subdomainHost = json.subdomainHost
|
||||||
|
data.value.maxPoolCount = json.maxPoolCount
|
||||||
|
data.value.maxPortsPerClient = String(json.maxPortsPerClient)
|
||||||
|
if (data.value.maxPortsPerClient == '0') {
|
||||||
|
data.value.maxPortsPerClient = 'no limit'
|
||||||
|
}
|
||||||
|
data.value.allowPortsStr = json.allowPortsStr
|
||||||
|
data.value.tlsForce = json.tlsForce
|
||||||
|
data.value.heartbeatTimeout = json.heartbeatTimeout
|
||||||
|
data.value.clientCounts = json.clientCounts
|
||||||
|
data.value.curConns = json.curConns
|
||||||
|
data.value.totalTrafficIn = json.totalTrafficIn
|
||||||
|
data.value.totalTrafficOut = json.totalTrafficOut
|
||||||
|
data.value.proxyTypeCounts = json.proxyTypeCount || {}
|
||||||
|
|
||||||
|
data.value.proxyCounts = 0
|
||||||
|
if (json.proxyTypeCount != null) {
|
||||||
|
Object.values(json.proxyTypeCount).forEach((count: any) => {
|
||||||
|
data.value.proxyCounts += count || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
showClose: true,
|
showClose: true,
|
||||||
|
|||||||
403
web/package-lock.json
generated
403
web/package-lock.json
generated
@@ -147,13 +147,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
@@ -163,13 +164,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -179,13 +181,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -195,13 +198,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -211,13 +215,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -227,13 +232,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -243,13 +249,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -259,13 +266,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -275,13 +283,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -291,13 +300,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -307,13 +317,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -323,13 +334,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -339,13 +351,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -355,13 +368,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -371,13 +385,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -387,13 +402,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -403,13 +419,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -419,13 +436,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -435,13 +453,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -451,13 +470,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -467,13 +487,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -483,13 +504,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
@@ -499,13 +521,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
@@ -515,13 +538,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -531,13 +555,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -547,13 +572,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -614,11 +640,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
@@ -695,11 +720,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
@@ -930,6 +954,76 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nuxt/kit": {
|
||||||
|
"version": "3.21.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
|
||||||
|
"integrity": "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"c12": "^3.3.3",
|
||||||
|
"consola": "^3.4.2",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"destr": "^2.0.5",
|
||||||
|
"errx": "^0.1.0",
|
||||||
|
"exsolve": "^1.0.8",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"klona": "^2.0.6",
|
||||||
|
"knitwork": "^1.3.0",
|
||||||
|
"mlly": "^1.8.1",
|
||||||
|
"ohash": "^2.0.11",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"pkg-types": "^2.3.0",
|
||||||
|
"rc9": "^3.0.0",
|
||||||
|
"scule": "^1.3.0",
|
||||||
|
"semver": "^7.7.4",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"ufo": "^1.6.3",
|
||||||
|
"unctx": "^2.5.0",
|
||||||
|
"untyped": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nuxt/kit/node_modules/confbox": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@nuxt/kit/node_modules/ignore": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nuxt/kit/node_modules/pkg-types": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.2.2",
|
||||||
|
"exsolve": "^1.0.7",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@parcel/watcher": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
@@ -2473,9 +2567,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -3356,11 +3450,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.1",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
@@ -3368,32 +3463,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.28.1",
|
"@esbuild/aix-ppc64": "0.27.4",
|
||||||
"@esbuild/android-arm": "0.28.1",
|
"@esbuild/android-arm": "0.27.4",
|
||||||
"@esbuild/android-arm64": "0.28.1",
|
"@esbuild/android-arm64": "0.27.4",
|
||||||
"@esbuild/android-x64": "0.28.1",
|
"@esbuild/android-x64": "0.27.4",
|
||||||
"@esbuild/darwin-arm64": "0.28.1",
|
"@esbuild/darwin-arm64": "0.27.4",
|
||||||
"@esbuild/darwin-x64": "0.28.1",
|
"@esbuild/darwin-x64": "0.27.4",
|
||||||
"@esbuild/freebsd-arm64": "0.28.1",
|
"@esbuild/freebsd-arm64": "0.27.4",
|
||||||
"@esbuild/freebsd-x64": "0.28.1",
|
"@esbuild/freebsd-x64": "0.27.4",
|
||||||
"@esbuild/linux-arm": "0.28.1",
|
"@esbuild/linux-arm": "0.27.4",
|
||||||
"@esbuild/linux-arm64": "0.28.1",
|
"@esbuild/linux-arm64": "0.27.4",
|
||||||
"@esbuild/linux-ia32": "0.28.1",
|
"@esbuild/linux-ia32": "0.27.4",
|
||||||
"@esbuild/linux-loong64": "0.28.1",
|
"@esbuild/linux-loong64": "0.27.4",
|
||||||
"@esbuild/linux-mips64el": "0.28.1",
|
"@esbuild/linux-mips64el": "0.27.4",
|
||||||
"@esbuild/linux-ppc64": "0.28.1",
|
"@esbuild/linux-ppc64": "0.27.4",
|
||||||
"@esbuild/linux-riscv64": "0.28.1",
|
"@esbuild/linux-riscv64": "0.27.4",
|
||||||
"@esbuild/linux-s390x": "0.28.1",
|
"@esbuild/linux-s390x": "0.27.4",
|
||||||
"@esbuild/linux-x64": "0.28.1",
|
"@esbuild/linux-x64": "0.27.4",
|
||||||
"@esbuild/netbsd-arm64": "0.28.1",
|
"@esbuild/netbsd-arm64": "0.27.4",
|
||||||
"@esbuild/netbsd-x64": "0.28.1",
|
"@esbuild/netbsd-x64": "0.27.4",
|
||||||
"@esbuild/openbsd-arm64": "0.28.1",
|
"@esbuild/openbsd-arm64": "0.27.4",
|
||||||
"@esbuild/openbsd-x64": "0.28.1",
|
"@esbuild/openbsd-x64": "0.27.4",
|
||||||
"@esbuild/openharmony-arm64": "0.28.1",
|
"@esbuild/openharmony-arm64": "0.27.4",
|
||||||
"@esbuild/sunos-x64": "0.28.1",
|
"@esbuild/sunos-x64": "0.27.4",
|
||||||
"@esbuild/win32-arm64": "0.28.1",
|
"@esbuild/win32-arm64": "0.27.4",
|
||||||
"@esbuild/win32-ia32": "0.28.1",
|
"@esbuild/win32-ia32": "0.27.4",
|
||||||
"@esbuild/win32-x64": "0.28.1"
|
"@esbuild/win32-x64": "0.27.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
@@ -3653,11 +3748,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/brace-expansion": {
|
"node_modules/eslint/node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
@@ -4261,9 +4355,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.9",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
|
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -4765,20 +4859,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"license": "MIT",
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/puzrin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/nodeca"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -5179,11 +5264,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/npm-run-all/node_modules/brace-expansion": {
|
"node_modules/npm-run-all/node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
@@ -6192,10 +6276,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shell-quote": {
|
"node_modules/shell-quote": {
|
||||||
"version": "1.9.0",
|
"version": "1.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
@@ -6521,9 +6606,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svgo": {
|
"node_modules/svgo": {
|
||||||
"version": "3.3.4",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz",
|
||||||
"integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==",
|
"integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7052,11 +7137,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unplugin-auto-import/node_modules/brace-expansion": {
|
"node_modules/unplugin-auto-import/node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -7242,11 +7326,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unplugin-vue-components/node_modules/brace-expansion": {
|
"node_modules/unplugin-vue-components/node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -7377,12 +7460,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0 || ^0.28.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user