mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 17:29:16 +08:00
Compare commits
7 Commits
becee40715
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe79598ee4 | ||
|
|
269a26c5d5 | ||
|
|
2886393f5b | ||
|
|
5396947c7c | ||
|
|
fe61093bc2 | ||
|
|
54aeb2a7b0 | ||
|
|
84be1938e4 |
4
.github/workflows/build-and-push-image.yml
vendored
4
.github/workflows/build-and-push-image.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./dockerfiles/Dockerfile-for-frpc
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.TAG_FRPC }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./dockerfiles/Dockerfile-for-frps
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.TAG_FRPS }}
|
||||
|
||||
@@ -9,7 +9,7 @@ all: build
|
||||
build: app
|
||||
|
||||
app:
|
||||
@$(foreach n, $(os-archs), \
|
||||
@set -e; $(foreach n, $(os-archs), \
|
||||
os=$(shell echo "$(n)" | cut -d : -f 1); \
|
||||
arch=$(shell echo "$(n)" | cut -d : -f 2); \
|
||||
extra=$(shell echo "$(n)" | cut -d : -f 3); \
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
[](https://circleci.com/gh/fatedier/frp)
|
||||
[](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)
|
||||
|
||||
[README](README.md) | [中文文档](README_zh.md)
|
||||
|
||||
7
go.mod
7
go.mod
@@ -5,7 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/fatedier/golib v0.7.0
|
||||
github.com/fatedier/golib v0.7.2-0.20260715094513-2780ae1d8cdb
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
@@ -13,7 +13,6 @@ require (
|
||||
github.com/onsi/ginkgo/v2 v2.23.4
|
||||
github.com/onsi/gomega v1.36.3
|
||||
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/prometheus/client_golang v1.19.1
|
||||
github.com/quic-go/quic-go v0.55.0
|
||||
@@ -53,9 +52,6 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // 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/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
@@ -67,7 +63,6 @@ require (
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // 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
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
||||
14
go.sum
14
go.sum
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
||||
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||
github.com/fatedier/golib v0.7.2-0.20260715094513-2780ae1d8cdb h1:6gcaE769mFb1G/cNV6lgzmwIYkNXmhj4pmcddllfmB4=
|
||||
github.com/fatedier/golib v0.7.2-0.20260715094513-2780ae1d8cdb/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
@@ -78,14 +78,6 @@ github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/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/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/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -146,8 +138,6 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||
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/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||
|
||||
@@ -239,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
|
||||
}
|
||||
if !proxyStats.LastStartTime.IsZero() {
|
||||
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||
ps.LastStartAt = proxyStats.LastStartTime.Unix()
|
||||
}
|
||||
if !proxyStats.LastCloseTime.IsZero() {
|
||||
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
||||
clk.SetTime(closedAt)
|
||||
metrics.CloseProxy("proxy", "tcp")
|
||||
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
||||
|
||||
stats := metrics.GetProxyByName("proxy")
|
||||
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
|
||||
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
|
||||
require.Equal(start.Unix(), stats.LastStartAt)
|
||||
require.Equal(closedAt.Unix(), stats.LastCloseAt)
|
||||
}
|
||||
|
||||
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
|
||||
@@ -41,6 +41,8 @@ type ProxyStats struct {
|
||||
TodayTrafficOut int64
|
||||
LastStartTime string
|
||||
LastCloseTime string
|
||||
LastStartAt int64
|
||||
LastCloseAt int64
|
||||
CurConns int64
|
||||
}
|
||||
|
||||
|
||||
@@ -15,31 +15,24 @@
|
||||
package nathole
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/pion/stun/v3"
|
||||
"github.com/fatedier/golib/net/stun"
|
||||
)
|
||||
|
||||
var responseTimeout = 3 * time.Second
|
||||
|
||||
type Message struct {
|
||||
Body []byte
|
||||
Addr string
|
||||
}
|
||||
|
||||
// If the localAddr is empty, it will listen on a random port.
|
||||
func Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) {
|
||||
// create a discoverConn and get response from messageChan
|
||||
discoverConn, err := listen(localAddr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer discoverConn.Close()
|
||||
|
||||
go discoverConn.readLoop()
|
||||
|
||||
addresses := make([]string, 0, len(stunServers))
|
||||
for _, addr := range stunServers {
|
||||
// get external address from stun server
|
||||
@@ -58,10 +51,9 @@ type stunResponse struct {
|
||||
}
|
||||
|
||||
type discoverConn struct {
|
||||
conn *net.UDPConn
|
||||
|
||||
localAddr net.Addr
|
||||
messageChan chan *Message
|
||||
conn *net.UDPConn
|
||||
client *stun.Client
|
||||
localAddr net.Addr
|
||||
}
|
||||
|
||||
func listen(localAddr string) (*discoverConn, error) {
|
||||
@@ -77,82 +69,50 @@ func listen(localAddr string) (*discoverConn, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := stun.NewClient(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &discoverConn{
|
||||
conn: conn,
|
||||
localAddr: conn.LocalAddr(),
|
||||
messageChan: make(chan *Message, 10),
|
||||
conn: conn,
|
||||
client: client,
|
||||
localAddr: conn.LocalAddr(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *discoverConn) Close() error {
|
||||
if c.messageChan != nil {
|
||||
close(c.messageChan)
|
||||
c.messageChan = nil
|
||||
}
|
||||
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) {
|
||||
serverAddr, err := net.ResolveUDPAddr("udp4", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
transaction, err := stun.NewBindingTransaction(serverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = request.NewTransactionID(); err != nil {
|
||||
if err := c.conn.SetReadDeadline(time.Now().Add(responseTimeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := c.conn.WriteTo(request.Raw, serverAddr); err != nil {
|
||||
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
|
||||
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")
|
||||
}
|
||||
case <-time.After(responseTimeout):
|
||||
return nil, fmt.Errorf("wait response from stun server timeout")
|
||||
return nil, err
|
||||
}
|
||||
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 response.MappedAddr != nil {
|
||||
resp.externalAddr = response.MappedAddr.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()
|
||||
if response.OtherAddr != nil {
|
||||
resp.otherAddr = response.OtherAddr.String()
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
382
pkg/nathole/discovery_test.go
Normal file
382
pkg/nathole/discovery_test.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package nathole
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/net/stun"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testBindingRequest = 0x0001
|
||||
testBindingSuccess = 0x0101
|
||||
testBindingError = 0x0111
|
||||
testMagicCookie = 0x2112a442
|
||||
testAttrMapped = 0x0001
|
||||
testAttrChanged = 0x0005
|
||||
testAttrErrorCode = 0x0009
|
||||
testAttrXORMapped = 0x0020
|
||||
testAttrOther = 0x802c
|
||||
testSTUNHeaderSize = 20
|
||||
testSTUNServerLimit = time.Second
|
||||
)
|
||||
|
||||
type testSTUNAttribute struct {
|
||||
typ uint16
|
||||
value []byte
|
||||
}
|
||||
|
||||
type testSTUNExchange struct {
|
||||
source *net.UDPAddr
|
||||
err error
|
||||
}
|
||||
|
||||
func listenTestUDP4(t *testing.T) *net.UDPConn {
|
||||
t.Helper()
|
||||
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
func serveOneSTUNRequest(
|
||||
server *net.UDPConn,
|
||||
buildResponse func([]byte, *net.UDPAddr) ([]byte, error),
|
||||
) <-chan testSTUNExchange {
|
||||
done := make(chan testSTUNExchange, 1)
|
||||
go func() {
|
||||
if err := server.SetDeadline(time.Now().Add(testSTUNServerLimit)); err != nil {
|
||||
done <- testSTUNExchange{err: err}
|
||||
return
|
||||
}
|
||||
buffer := make([]byte, 1024)
|
||||
n, source, err := server.ReadFromUDP(buffer)
|
||||
if err == nil && buildResponse != nil {
|
||||
var response []byte
|
||||
response, err = buildResponse(buffer[:n], source)
|
||||
if err == nil && response != nil {
|
||||
_, err = server.WriteToUDP(response, source)
|
||||
}
|
||||
}
|
||||
done <- testSTUNExchange{source: source, err: err}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func waitSTUNExchange(t *testing.T, done <-chan testSTUNExchange) *net.UDPAddr {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case exchange := <-done:
|
||||
require.NoError(t, exchange.err)
|
||||
return exchange.source
|
||||
case <-time.After(testSTUNServerLimit):
|
||||
t.Fatal("timed out waiting for local STUN server")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func makeTestSTUNResponse(request []byte, typ uint16, attributes ...testSTUNAttribute) ([]byte, error) {
|
||||
if len(request) != testSTUNHeaderSize || binary.BigEndian.Uint16(request[0:2]) != testBindingRequest ||
|
||||
binary.BigEndian.Uint32(request[4:8]) != testMagicCookie {
|
||||
return nil, fmt.Errorf("invalid Binding request")
|
||||
}
|
||||
|
||||
length := 0
|
||||
for _, attribute := range attributes {
|
||||
length += 4 + (len(attribute.value)+3)&^3
|
||||
}
|
||||
response := make([]byte, testSTUNHeaderSize, testSTUNHeaderSize+length)
|
||||
binary.BigEndian.PutUint16(response[0:2], typ)
|
||||
binary.BigEndian.PutUint16(response[2:4], uint16(length))
|
||||
binary.BigEndian.PutUint32(response[4:8], testMagicCookie)
|
||||
copy(response[8:20], request[8:20])
|
||||
|
||||
for _, attribute := range attributes {
|
||||
start := len(response)
|
||||
paddedLength := (len(attribute.value) + 3) &^ 3
|
||||
response = append(response, make([]byte, 4+paddedLength)...)
|
||||
binary.BigEndian.PutUint16(response[start:start+2], attribute.typ)
|
||||
binary.BigEndian.PutUint16(response[start+2:start+4], uint16(len(attribute.value)))
|
||||
copy(response[start+4:], attribute.value)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func testIPv4AddressValue(ip net.IP, port int, xor bool) []byte {
|
||||
value := make([]byte, 8)
|
||||
value[1] = 0x01
|
||||
binary.BigEndian.PutUint16(value[2:4], uint16(port))
|
||||
copy(value[4:], ip.To4())
|
||||
if xor {
|
||||
binary.BigEndian.PutUint16(value[2:4], binary.BigEndian.Uint16(value[2:4])^uint16(testMagicCookie>>16))
|
||||
for i := range 4 {
|
||||
value[4+i] ^= byte(uint32(testMagicCookie) >> uint(24-8*i))
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestDiscoverReusesLocalPortAndPreservesNATClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secondMapped string
|
||||
secondMappedPort int
|
||||
wantNATType string
|
||||
wantBehavior string
|
||||
}{
|
||||
{
|
||||
name: "same mapped address",
|
||||
secondMapped: "198.51.100.10:40000",
|
||||
secondMappedPort: 40000,
|
||||
wantNATType: EasyNAT,
|
||||
wantBehavior: BehaviorNoChange,
|
||||
},
|
||||
{
|
||||
name: "different mapped port",
|
||||
secondMapped: "198.51.100.10:40001",
|
||||
secondMappedPort: 40001,
|
||||
wantNATType: HardNAT,
|
||||
wantBehavior: BehaviorPortChanged,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
primary := listenTestUDP4(t)
|
||||
alternate := listenTestUDP4(t)
|
||||
alternateAddr := alternate.LocalAddr().(*net.UDPAddr)
|
||||
|
||||
primaryDone := serveOneSTUNRequest(primary, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), 40000, true)},
|
||||
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(alternateAddr.IP, alternateAddr.Port, false)},
|
||||
)
|
||||
})
|
||||
alternateDone := serveOneSTUNRequest(alternate, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), tt.secondMappedPort, true)},
|
||||
)
|
||||
})
|
||||
|
||||
addresses, localAddr, err := Discover([]string{primary.LocalAddr().String()}, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"198.51.100.10:40000", tt.secondMapped}, addresses)
|
||||
|
||||
primarySource := waitSTUNExchange(t, primaryDone)
|
||||
alternateSource := waitSTUNExchange(t, alternateDone)
|
||||
require.Equal(t, primarySource.Port, alternateSource.Port)
|
||||
require.Equal(t, localAddr.(*net.UDPAddr).Port, primarySource.Port)
|
||||
|
||||
feature, err := ClassifyNATFeature(addresses, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantNATType, feature.NatType)
|
||||
require.Equal(t, tt.wantBehavior, feature.Behavior)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSTUNRequestMapsLegacyAndModernAddresses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attributes []testSTUNAttribute
|
||||
wantExternal string
|
||||
wantOther string
|
||||
}{
|
||||
{
|
||||
name: "legacy",
|
||||
attributes: []testSTUNAttribute{
|
||||
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||
},
|
||||
wantExternal: "192.0.2.1:1000",
|
||||
wantOther: "192.0.2.2:2000",
|
||||
},
|
||||
{
|
||||
name: "modern takes precedence",
|
||||
attributes: []testSTUNAttribute{
|
||||
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||
{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||
{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("198.51.100.2"), 4000, false)},
|
||||
},
|
||||
wantExternal: "198.51.100.1:3000",
|
||||
wantOther: "198.51.100.2:4000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess, tt.attributes...)
|
||||
})
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantExternal, response.externalAddr)
|
||||
require.Equal(t, tt.wantOther, response.otherAddr)
|
||||
waitSTUNExchange(t, done)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTUNResponseErrorsAndMissingAddresses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
buildResponse func([]byte, *net.UDPAddr) ([]byte, error)
|
||||
request func(*discoverConn, string) error
|
||||
checkError func(*testing.T, error)
|
||||
}{
|
||||
{
|
||||
name: "correlated malformed response",
|
||||
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
response, err := makeTestSTUNResponse(request, testBindingSuccess)
|
||||
if err == nil {
|
||||
binary.BigEndian.PutUint16(response[2:4], 4)
|
||||
}
|
||||
return response, err
|
||||
},
|
||||
request: func(conn *discoverConn, server string) error {
|
||||
_, err := conn.doSTUNRequest(server)
|
||||
return err
|
||||
},
|
||||
checkError: func(t *testing.T, err error) {
|
||||
require.ErrorIs(t, err, stun.ErrMalformedResponse)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Binding error response",
|
||||
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingError, testSTUNAttribute{
|
||||
typ: testAttrErrorCode,
|
||||
value: []byte{0, 0, 4, 20, 'U', 'n', 'k', 'n', 'o', 'w', 'n'},
|
||||
})
|
||||
},
|
||||
request: func(conn *discoverConn, server string) error {
|
||||
_, err := conn.doSTUNRequest(server)
|
||||
return err
|
||||
},
|
||||
checkError: func(t *testing.T, err error) {
|
||||
var responseErr *stun.ResponseError
|
||||
require.ErrorAs(t, err, &responseErr)
|
||||
require.Equal(t, 420, responseErr.Code)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing mapped address",
|
||||
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||
)
|
||||
},
|
||||
request: func(conn *discoverConn, server string) error {
|
||||
_, err := conn.discoverFromStunServer(server)
|
||||
return err
|
||||
},
|
||||
checkError: func(t *testing.T, err error) {
|
||||
require.EqualError(t, err, "no external address found")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, tt.buildResponse)
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
err = tt.request(conn, server.LocalAddr().String())
|
||||
tt.checkError(t, err)
|
||||
waitSTUNExchange(t, done)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("missing other address", func(t *testing.T) {
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||
)
|
||||
})
|
||||
|
||||
_, err := Prepare([]string{server.LocalAddr().String()}, PrepareOptions{})
|
||||
require.EqualError(t, err, "discover error: not enough addresses")
|
||||
waitSTUNExchange(t, done)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSTUNTimeoutUsesCallerDeadlineWithoutRetry(t *testing.T) {
|
||||
originalTimeout := responseTimeout
|
||||
responseTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, nil)
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
_, err = conn.doSTUNRequest(server.LocalAddr().String())
|
||||
require.EqualError(t, err, "wait response from stun server timeout")
|
||||
waitSTUNExchange(t, done)
|
||||
|
||||
require.NoError(t, server.SetReadDeadline(time.Now().Add(50*time.Millisecond)))
|
||||
_, _, err = server.ReadFromUDP(make([]byte, 1))
|
||||
var netErr net.Error
|
||||
require.ErrorAs(t, err, &netErr)
|
||||
require.True(t, netErr.Timeout())
|
||||
}
|
||||
|
||||
func TestSTUNClientLeavesSocketAndDeadlineWithCaller(t *testing.T) {
|
||||
originalTimeout := responseTimeout
|
||||
responseTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||
|
||||
server := listenTestUDP4(t)
|
||||
unrelated := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, func(request []byte, source *net.UDPAddr) ([]byte, error) {
|
||||
response, err := makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.5"), 5000, true)},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := unrelated.WriteToUDP(response, source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
})
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "198.51.100.5:5000", response.externalAddr)
|
||||
waitSTUNExchange(t, done)
|
||||
|
||||
_, _, err = conn.conn.ReadFromUDP(make([]byte, 1))
|
||||
var netErr net.Error
|
||||
require.True(t, errors.As(err, &netErr))
|
||||
require.True(t, netErr.Timeout())
|
||||
|
||||
require.NoError(t, conn.conn.SetDeadline(time.Time{}))
|
||||
require.NoError(t, server.SetReadDeadline(time.Now().Add(testSTUNServerLimit)))
|
||||
_, err = conn.conn.WriteToUDP([]byte{1}, server.LocalAddr().(*net.UDPAddr))
|
||||
require.NoError(t, err)
|
||||
_, source, err := server.ReadFromUDP(make([]byte, 1))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, conn.localAddr.(*net.UDPAddr).Port, source.Port)
|
||||
}
|
||||
@@ -18,10 +18,8 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/fatedier/golib/crypto"
|
||||
"github.com/pion/stun/v3"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
@@ -48,20 +46,6 @@ func DecodeMessageInto(data, key []byte, m msg.Message) error {
|
||||
return msg.ReadMsgInto(bytes.NewReader(buf), m)
|
||||
}
|
||||
|
||||
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) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
package version
|
||||
|
||||
var version = "0.69.1"
|
||||
var version = "0.70.0"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
@@ -17,6 +17,7 @@ package http
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -255,7 +256,7 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||
}
|
||||
|
||||
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
|
||||
if v := cmp.Compare(a.Type, b.Type); v != 0 {
|
||||
if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
|
||||
return v
|
||||
}
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
@@ -435,26 +436,36 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
|
||||
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
|
||||
values := []string{
|
||||
item.Name,
|
||||
item.Type,
|
||||
item.Spec.Type,
|
||||
item.User,
|
||||
item.ClientID,
|
||||
item.Status.State,
|
||||
}
|
||||
|
||||
switch spec := item.Spec.(type) {
|
||||
case *model.TCPOutConf:
|
||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||
case *model.UDPOutConf:
|
||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||
case *model.HTTPOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
case *model.HTTPSOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
case *model.TCPMuxOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
switch item.Spec.Type {
|
||||
case string(v1.ProxyTypeTCP):
|
||||
if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
|
||||
values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
|
||||
}
|
||||
case string(v1.ProxyTypeUDP):
|
||||
if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
|
||||
values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
|
||||
}
|
||||
case string(v1.ProxyTypeHTTP):
|
||||
if item.Spec.HTTP != nil {
|
||||
values = append(values, item.Spec.HTTP.CustomDomains...)
|
||||
values = append(values, item.Spec.HTTP.Subdomain)
|
||||
}
|
||||
case string(v1.ProxyTypeHTTPS):
|
||||
if item.Spec.HTTPS != nil {
|
||||
values = append(values, item.Spec.HTTPS.CustomDomains...)
|
||||
values = append(values, item.Spec.HTTPS.Subdomain)
|
||||
}
|
||||
case string(v1.ProxyTypeTCPMUX):
|
||||
if item.Spec.TCPMux != nil {
|
||||
values = append(values, item.Spec.TCPMux.CustomDomains...)
|
||||
values = append(values, item.Spec.TCPMux.Subdomain)
|
||||
}
|
||||
}
|
||||
|
||||
return containsV2Query(q, values...)
|
||||
@@ -526,27 +537,111 @@ func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2Clien
|
||||
|
||||
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
|
||||
state := "offline"
|
||||
var spec any
|
||||
var cfg v1.ProxyConfigurer
|
||||
if c.pxyManager != nil {
|
||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||
state = "online"
|
||||
spec = getConfFromConfigurer(pxy.GetConfigurer())
|
||||
cfg = pxy.GetConfigurer()
|
||||
}
|
||||
}
|
||||
|
||||
return model.V2ProxyResp{
|
||||
Name: ps.Name,
|
||||
Type: ps.Type,
|
||||
User: ps.User,
|
||||
ClientID: ps.ClientID,
|
||||
Spec: spec,
|
||||
Spec: buildV2ProxySpec(ps.Type, cfg),
|
||||
Status: model.V2ProxyStatusResp{
|
||||
State: state,
|
||||
TodayTrafficIn: ps.TodayTrafficIn,
|
||||
TodayTrafficOut: ps.TodayTrafficOut,
|
||||
CurConns: ps.CurConns,
|
||||
LastStartTime: ps.LastStartTime,
|
||||
LastCloseTime: ps.LastCloseTime,
|
||||
LastStartAt: ps.LastStartAt,
|
||||
LastCloseAt: ps.LastCloseAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
|
||||
spec := model.V2ProxySpec{Type: proxyType}
|
||||
|
||||
switch proxyType {
|
||||
case string(v1.ProxyTypeTCP):
|
||||
block := &model.V2TCPProxySpec{}
|
||||
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.RemotePort = &c.RemotePort
|
||||
}
|
||||
spec.TCP = block
|
||||
case string(v1.ProxyTypeUDP):
|
||||
block := &model.V2UDPProxySpec{}
|
||||
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.RemotePort = &c.RemotePort
|
||||
}
|
||||
spec.UDP = block
|
||||
case string(v1.ProxyTypeHTTP):
|
||||
block := &model.V2HTTPProxySpec{}
|
||||
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||
block.Subdomain = c.SubDomain
|
||||
block.Locations = slices.Clone(c.Locations)
|
||||
block.HostHeaderRewrite = c.HostHeaderRewrite
|
||||
}
|
||||
spec.HTTP = block
|
||||
case string(v1.ProxyTypeHTTPS):
|
||||
block := &model.V2HTTPSProxySpec{}
|
||||
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||
block.Subdomain = c.SubDomain
|
||||
}
|
||||
spec.HTTPS = block
|
||||
case string(v1.ProxyTypeTCPMUX):
|
||||
block := &model.V2TCPMuxProxySpec{}
|
||||
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
block.CustomDomains = slices.Clone(c.CustomDomains)
|
||||
block.Subdomain = c.SubDomain
|
||||
block.Multiplexer = c.Multiplexer
|
||||
block.RouteByHTTPUser = c.RouteByHTTPUser
|
||||
}
|
||||
spec.TCPMux = block
|
||||
case string(v1.ProxyTypeSTCP):
|
||||
block := &model.V2STCPProxySpec{}
|
||||
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
}
|
||||
spec.STCP = block
|
||||
case string(v1.ProxyTypeSUDP):
|
||||
block := &model.V2SUDPProxySpec{}
|
||||
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
}
|
||||
spec.SUDP = block
|
||||
case string(v1.ProxyTypeXTCP):
|
||||
block := &model.V2XTCPProxySpec{}
|
||||
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
|
||||
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
|
||||
}
|
||||
spec.XTCP = block
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
|
||||
return model.V2ProxyBaseSpec{
|
||||
Annotations: maps.Clone(base.Annotations),
|
||||
Metadatas: maps.Clone(base.Metadatas),
|
||||
Transport: &model.V2ProxyTransportSpec{
|
||||
UseEncryption: base.Transport.UseEncryption,
|
||||
UseCompression: base.Transport.UseCompression,
|
||||
BandwidthLimit: base.Transport.BandwidthLimit.String(),
|
||||
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
|
||||
},
|
||||
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
|
||||
Group: base.LoadBalancer.Group,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
393
server/http/controller_v2_proxy_spec_test.go
Normal file
393
server/http/controller_v2_proxy_spec_test.go
Normal file
@@ -0,0 +1,393 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configtypes "github.com/fatedier/frp/pkg/config/types"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
"github.com/fatedier/frp/server/http/model"
|
||||
)
|
||||
|
||||
func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
|
||||
tests := []struct {
|
||||
proxyType string
|
||||
cfg v1.ProxyConfigurer
|
||||
blockKeys []string
|
||||
}{
|
||||
{
|
||||
proxyType: "tcp",
|
||||
cfg: &v1.TCPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
|
||||
RemotePort: 6000,
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "udp",
|
||||
cfg: &v1.UDPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||
RemotePort: 7000,
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "http",
|
||||
cfg: &v1.HTTPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
|
||||
Locations: []string{"/api"},
|
||||
HTTPUser: "secret-http-user",
|
||||
HTTPPassword: "secret-http-password",
|
||||
HostHeaderRewrite: "backend.example.com",
|
||||
RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
|
||||
ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
|
||||
RouteByHTTPUser: "secret-http-route-user",
|
||||
},
|
||||
blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "https",
|
||||
cfg: &v1.HTTPSProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
|
||||
},
|
||||
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "tcpmux",
|
||||
cfg: &v1.TCPMuxProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
|
||||
HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
|
||||
HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
|
||||
RouteByHTTPUser: "displayed-mux-user",
|
||||
Multiplexer: "httpconnect",
|
||||
},
|
||||
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "stcp",
|
||||
cfg: &v1.STCPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
|
||||
Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
|
||||
AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "sudp",
|
||||
cfg: &v1.SUDPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
|
||||
Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
|
||||
AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||
},
|
||||
{
|
||||
proxyType: "xtcp",
|
||||
cfg: &v1.XTCPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
|
||||
Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
|
||||
AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
|
||||
},
|
||||
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.proxyType, func(t *testing.T) {
|
||||
spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
|
||||
raw := mustMarshalJSON(t, spec)
|
||||
|
||||
var specObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &specObject); err != nil {
|
||||
t.Fatalf("unmarshal spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, specObject, tt.proxyType, "type")
|
||||
|
||||
var gotType string
|
||||
if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
|
||||
t.Fatalf("unmarshal spec type failed: %v", err)
|
||||
}
|
||||
if gotType != tt.proxyType {
|
||||
t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
|
||||
}
|
||||
|
||||
var block map[string]json.RawMessage
|
||||
if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
|
||||
t.Fatalf("unmarshal active block failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, block, tt.blockKeys...)
|
||||
assertV2ProxyCommonSpec(t, block)
|
||||
assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
|
||||
assertNoV2ProxySensitiveFields(t, block)
|
||||
|
||||
content := string(raw)
|
||||
for _, secret := range []string{
|
||||
"secret-proxy-name",
|
||||
"secret-group-key",
|
||||
"secret-local-host",
|
||||
"secret-plugin-user",
|
||||
"secret-plugin-password",
|
||||
"secret-health-path",
|
||||
"secret-http-user",
|
||||
"secret-http-password",
|
||||
"secret-request-header",
|
||||
"secret-response-header",
|
||||
"secret-http-route-user",
|
||||
"secret-mux-http-user",
|
||||
"secret-mux-http-password",
|
||||
"secret-stcp-key",
|
||||
"secret-stcp-user",
|
||||
"secret-sudp-key",
|
||||
"secret-sudp-user",
|
||||
"secret-xtcp-key",
|
||||
"secret-xtcp-user",
|
||||
} {
|
||||
if strings.Contains(content, secret) {
|
||||
t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
|
||||
t.Helper()
|
||||
|
||||
switch proxyType {
|
||||
case "tcp":
|
||||
var block model.V2TCPProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal tcp block failed: %v", err)
|
||||
}
|
||||
if block.RemotePort == nil || *block.RemotePort != 6000 {
|
||||
t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
|
||||
}
|
||||
case "udp":
|
||||
var block model.V2UDPProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal udp block failed: %v", err)
|
||||
}
|
||||
if block.RemotePort == nil || *block.RemotePort != 7000 {
|
||||
t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
|
||||
}
|
||||
case "http":
|
||||
var block model.V2HTTPProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal http block failed: %v", err)
|
||||
}
|
||||
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
|
||||
block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
|
||||
block.HostHeaderRewrite != "backend.example.com" {
|
||||
t.Fatalf("http fields mismatch: %#v", block)
|
||||
}
|
||||
case "https":
|
||||
var block model.V2HTTPSProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal https block failed: %v", err)
|
||||
}
|
||||
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
|
||||
t.Fatalf("https fields mismatch: %#v", block)
|
||||
}
|
||||
case "tcpmux":
|
||||
var block model.V2TCPMuxProxySpec
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
t.Fatalf("unmarshal tcpmux block failed: %v", err)
|
||||
}
|
||||
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
|
||||
block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
|
||||
t.Fatalf("tcpmux fields mismatch: %#v", block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
|
||||
for _, proxyType := range apiV2ProxyTypes {
|
||||
t.Run(proxyType, func(t *testing.T) {
|
||||
resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
|
||||
Name: "offline-" + proxyType,
|
||||
Type: proxyType,
|
||||
})
|
||||
if resp.Status.State != "offline" {
|
||||
t.Fatalf("offline phase mismatch: %#v", resp.Status)
|
||||
}
|
||||
|
||||
var specObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
|
||||
t.Fatalf("unmarshal offline spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, specObject, proxyType, "type")
|
||||
assertRawJSONKeysFromMessage(t, specObject[proxyType])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
|
||||
spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
|
||||
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
|
||||
RemotePort: 7000,
|
||||
})
|
||||
|
||||
var specObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
|
||||
t.Fatalf("unmarshal mismatched spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, specObject, "tcp", "type")
|
||||
assertRawJSONKeysFromMessage(t, specObject["tcp"])
|
||||
}
|
||||
|
||||
func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
|
||||
t.Helper()
|
||||
|
||||
bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
|
||||
if err != nil {
|
||||
t.Fatalf("create bandwidth limit failed: %v", err)
|
||||
}
|
||||
enabled := false
|
||||
return v1.ProxyBaseConfig{
|
||||
Name: "secret-proxy-name",
|
||||
Type: proxyType,
|
||||
Enabled: &enabled,
|
||||
Annotations: map[string]string{"annotation-key": "annotation-value"},
|
||||
Metadatas: map[string]string{"metadata-key": "metadata-value"},
|
||||
Transport: v1.ProxyTransport{
|
||||
UseEncryption: true,
|
||||
UseCompression: true,
|
||||
BandwidthLimit: bandwidthLimit,
|
||||
BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
|
||||
ProxyProtocolVersion: "v2",
|
||||
},
|
||||
LoadBalancer: v1.LoadBalancerConfig{
|
||||
Group: "public-group",
|
||||
GroupKey: "secret-group-key",
|
||||
},
|
||||
HealthCheck: v1.HealthCheckConfig{
|
||||
Type: "http",
|
||||
Path: "secret-health-path",
|
||||
},
|
||||
ProxyBackend: v1.ProxyBackend{
|
||||
LocalIP: "secret-local-host",
|
||||
LocalPort: 8080,
|
||||
Plugin: v1.TypedClientPluginOptions{
|
||||
Type: v1.PluginHTTPProxy,
|
||||
ClientPluginOptions: &v1.HTTPProxyPluginOptions{
|
||||
Type: v1.PluginHTTPProxy,
|
||||
HTTPUser: "secret-plugin-user",
|
||||
HTTPPassword: "secret-plugin-password",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
|
||||
t.Helper()
|
||||
|
||||
var annotations map[string]string
|
||||
if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
|
||||
t.Fatalf("unmarshal annotations failed: %v", err)
|
||||
}
|
||||
if annotations["annotation-key"] != "annotation-value" {
|
||||
t.Fatalf("annotations mismatch: %#v", annotations)
|
||||
}
|
||||
|
||||
var metadatas map[string]string
|
||||
if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
|
||||
t.Fatalf("unmarshal metadatas failed: %v", err)
|
||||
}
|
||||
if metadatas["metadata-key"] != "metadata-value" {
|
||||
t.Fatalf("metadatas mismatch: %#v", metadatas)
|
||||
}
|
||||
|
||||
assertRawJSONKeysFromMessage(t, block["transport"],
|
||||
"bandwidthLimit",
|
||||
"bandwidthLimitMode",
|
||||
"useCompression",
|
||||
"useEncryption",
|
||||
)
|
||||
var transport model.V2ProxyTransportSpec
|
||||
if err := json.Unmarshal(block["transport"], &transport); err != nil {
|
||||
t.Fatalf("unmarshal transport failed: %v", err)
|
||||
}
|
||||
if !transport.UseEncryption || !transport.UseCompression ||
|
||||
transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
|
||||
t.Fatalf("transport mismatch: %#v", transport)
|
||||
}
|
||||
|
||||
assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
|
||||
var loadBalancer model.V2ProxyLoadBalancerSpec
|
||||
if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
|
||||
t.Fatalf("unmarshal load balancer failed: %v", err)
|
||||
}
|
||||
if loadBalancer.Group != "public-group" {
|
||||
t.Fatalf("load balancer mismatch: %#v", loadBalancer)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
|
||||
forbidden := map[string]struct{}{
|
||||
"allowUsers": {},
|
||||
"enabled": {},
|
||||
"groupKey": {},
|
||||
"healthCheck": {},
|
||||
"httpPassword": {},
|
||||
"httpUser": {},
|
||||
"localIP": {},
|
||||
"localPort": {},
|
||||
"name": {},
|
||||
"natTraversal": {},
|
||||
"plugin": {},
|
||||
"proxyProtocolVersion": {},
|
||||
"requestHeaders": {},
|
||||
"responseHeaders": {},
|
||||
"secretKey": {},
|
||||
"type": {},
|
||||
}
|
||||
|
||||
var walk func(any)
|
||||
walk = func(current any) {
|
||||
switch current := current.(type) {
|
||||
case map[string]any:
|
||||
for key, nested := range current {
|
||||
if _, ok := forbidden[key]; ok {
|
||||
t.Fatalf("sensitive field %q leaked in active block", key)
|
||||
}
|
||||
walk(nested)
|
||||
}
|
||||
case []any:
|
||||
for _, nested := range current {
|
||||
walk(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal active block failed: %v", err)
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("decode active block failed: %v", err)
|
||||
}
|
||||
walk(decoded)
|
||||
}
|
||||
@@ -416,15 +416,42 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
|
||||
}
|
||||
proxyItem := proxyResp.Data.Items[0]
|
||||
if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||
if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||
}
|
||||
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
|
||||
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
|
||||
var rawListSpec map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
|
||||
t.Fatalf("unmarshal list proxy spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
|
||||
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
||||
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
|
||||
"curConns",
|
||||
"lastCloseAt",
|
||||
"lastStartAt",
|
||||
"phase",
|
||||
"todayTrafficIn",
|
||||
"todayTrafficOut",
|
||||
)
|
||||
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
|
||||
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
|
||||
}
|
||||
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
|
||||
var rawDetailSpec map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
|
||||
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
|
||||
}
|
||||
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
|
||||
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
|
||||
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
|
||||
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
||||
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
||||
@@ -586,66 +613,85 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "tcp remote port",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||
RemotePort: 6000,
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||
}},
|
||||
q: "6000",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "udp remote port",
|
||||
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
|
||||
RemotePort: 7000,
|
||||
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "udp",
|
||||
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
|
||||
}},
|
||||
q: "7000",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "remote port does not match colon form",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||
RemotePort: 6000,
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||
}},
|
||||
q: ":6000",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "http custom domain",
|
||||
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
|
||||
item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "http",
|
||||
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
|
||||
}},
|
||||
q: "app.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "https subdomain",
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "https",
|
||||
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||
}},
|
||||
q: "portal",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subdomain does not match expanded host",
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "https",
|
||||
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
|
||||
}},
|
||||
q: "portal.example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "tcpmux custom domain",
|
||||
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
|
||||
item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcpmux",
|
||||
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
|
||||
}},
|
||||
q: "mux.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "nil spec does not match spec fields",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
||||
name: "offline shell does not match online spec fields",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{},
|
||||
}},
|
||||
q: "6000",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "offline shell does not contribute zero remote port",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{},
|
||||
}},
|
||||
q: "0",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -721,6 +767,10 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func v2TestIntPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newV2TestController(t *testing.T) *Controller {
|
||||
t.Helper()
|
||||
|
||||
@@ -744,6 +794,10 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
TodayTrafficIn: 30,
|
||||
TodayTrafficOut: 40,
|
||||
CurConns: 2,
|
||||
LastStartTime: "07-08 12:30:00",
|
||||
LastCloseTime: "07-08 12:31:40",
|
||||
LastStartAt: 1783504200,
|
||||
LastCloseAt: 1783504300,
|
||||
},
|
||||
"http-alice": {
|
||||
Name: "http-alice",
|
||||
|
||||
@@ -75,20 +75,94 @@ type V2ClientStatusResp struct {
|
||||
|
||||
type V2ProxyResp struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
User string `json:"user"`
|
||||
ClientID string `json:"clientID"`
|
||||
Spec any `json:"spec"`
|
||||
Spec V2ProxySpec `json:"spec"`
|
||||
Status V2ProxyStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2ProxySpec struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
|
||||
UDP *V2UDPProxySpec `json:"udp,omitempty"`
|
||||
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
|
||||
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
|
||||
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
|
||||
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
|
||||
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
|
||||
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyBaseSpec struct {
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
Metadatas map[string]string `json:"metadatas,omitempty"`
|
||||
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
|
||||
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyTransportSpec struct {
|
||||
UseEncryption bool `json:"useEncryption"`
|
||||
UseCompression bool `json:"useCompression"`
|
||||
BandwidthLimit string `json:"bandwidthLimit"`
|
||||
BandwidthLimitMode string `json:"bandwidthLimitMode"`
|
||||
}
|
||||
|
||||
type V2ProxyLoadBalancerSpec struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
type V2TCPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
RemotePort *int `json:"remotePort,omitempty"`
|
||||
}
|
||||
|
||||
type V2UDPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
RemotePort *int `json:"remotePort,omitempty"`
|
||||
}
|
||||
|
||||
type V2HTTPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
Locations []string `json:"locations,omitempty"`
|
||||
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
|
||||
}
|
||||
|
||||
type V2HTTPSProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
}
|
||||
|
||||
type V2TCPMuxProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
Multiplexer string `json:"multiplexer,omitempty"`
|
||||
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
|
||||
}
|
||||
|
||||
type V2STCPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
}
|
||||
|
||||
type V2SUDPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
}
|
||||
|
||||
type V2XTCPProxySpec struct {
|
||||
V2ProxyBaseSpec
|
||||
}
|
||||
|
||||
type V2ProxyStatusResp struct {
|
||||
State string `json:"phase"`
|
||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
LastStartTime string `json:"lastStartTime"`
|
||||
LastCloseTime string `json:"lastCloseTime"`
|
||||
LastStartAt int64 `json:"lastStartAt,omitempty"`
|
||||
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficResp struct {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { buildQueryString, http } from './http'
|
||||
import { formatUnixSeconds } from '../utils/format'
|
||||
import type { V2Page } from './http'
|
||||
import type {
|
||||
GetProxyResponse,
|
||||
ProxyListV2Params,
|
||||
ProxyStatsInfo,
|
||||
ProxyV2Info,
|
||||
ProxyV2Spec,
|
||||
ProxyV2SpecBlocks,
|
||||
ProxyV2Type,
|
||||
TrafficResponse,
|
||||
} from '../types/proxy'
|
||||
|
||||
@@ -38,19 +42,53 @@ export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
|
||||
name: proxy.name,
|
||||
type: proxy.type,
|
||||
conf: proxy.spec,
|
||||
user: proxy.user,
|
||||
clientID: proxy.clientID,
|
||||
todayTrafficIn: proxy.status.todayTrafficIn,
|
||||
todayTrafficOut: proxy.status.todayTrafficOut,
|
||||
curConns: proxy.status.curConns,
|
||||
lastStartTime: proxy.status.lastStartTime,
|
||||
lastCloseTime: proxy.status.lastCloseTime,
|
||||
status: proxy.status.phase,
|
||||
})
|
||||
const getActiveProxySpec = (
|
||||
spec: ProxyV2Spec,
|
||||
): ProxyV2SpecBlocks[ProxyV2Type] => {
|
||||
switch (spec.type) {
|
||||
case 'tcp':
|
||||
return spec.tcp
|
||||
case 'udp':
|
||||
return spec.udp
|
||||
case 'http':
|
||||
return spec.http
|
||||
case 'https':
|
||||
return spec.https
|
||||
case 'tcpmux':
|
||||
return spec.tcpmux
|
||||
case 'stcp':
|
||||
return spec.stcp
|
||||
case 'sudp':
|
||||
return spec.sudp
|
||||
case 'xtcp':
|
||||
return spec.xtcp
|
||||
default:
|
||||
return assertNever(spec)
|
||||
}
|
||||
}
|
||||
|
||||
const assertNever = (value: never): never => {
|
||||
throw new Error(`Unsupported proxy spec: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => {
|
||||
const type = proxy.spec.type
|
||||
const activeSpec = getActiveProxySpec(proxy.spec)
|
||||
|
||||
return {
|
||||
name: proxy.name,
|
||||
type,
|
||||
conf: proxy.status.phase === 'offline' ? null : activeSpec,
|
||||
user: proxy.user,
|
||||
clientID: proxy.clientID,
|
||||
todayTrafficIn: proxy.status.todayTrafficIn,
|
||||
todayTrafficOut: proxy.status.todayTrafficOut,
|
||||
curConns: proxy.status.curConns,
|
||||
lastStartTime: formatUnixSeconds(proxy.status.lastStartAt),
|
||||
lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt),
|
||||
status: proxy.status.phase,
|
||||
}
|
||||
}
|
||||
|
||||
export const getProxy = (type: string, name: string) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||
|
||||
@@ -28,20 +28,91 @@ export interface ProxyListV2Params {
|
||||
|
||||
export interface ProxyV2Info {
|
||||
name: string
|
||||
type: string
|
||||
user: string
|
||||
clientID: string
|
||||
spec: any
|
||||
spec: ProxyV2Spec
|
||||
status: ProxyV2Status
|
||||
}
|
||||
|
||||
export interface ProxyV2BaseSpec {
|
||||
annotations?: Record<string, string>
|
||||
metadatas?: Record<string, string>
|
||||
transport?: {
|
||||
useEncryption: boolean
|
||||
useCompression: boolean
|
||||
bandwidthLimit: string
|
||||
bandwidthLimitMode: string
|
||||
}
|
||||
loadBalancer?: {
|
||||
group: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProxyV2TCPBlock extends ProxyV2BaseSpec {
|
||||
remotePort?: number
|
||||
}
|
||||
|
||||
export interface ProxyV2UDPBlock extends ProxyV2BaseSpec {
|
||||
remotePort?: number
|
||||
}
|
||||
|
||||
export interface ProxyV2HTTPBlock extends ProxyV2BaseSpec {
|
||||
customDomains?: string[]
|
||||
subdomain?: string
|
||||
locations?: string[]
|
||||
hostHeaderRewrite?: string
|
||||
}
|
||||
|
||||
export interface ProxyV2HTTPSBlock extends ProxyV2BaseSpec {
|
||||
customDomains?: string[]
|
||||
subdomain?: string
|
||||
}
|
||||
|
||||
export interface ProxyV2TCPMuxBlock extends ProxyV2BaseSpec {
|
||||
customDomains?: string[]
|
||||
subdomain?: string
|
||||
multiplexer?: string
|
||||
routeByHTTPUser?: string
|
||||
}
|
||||
|
||||
export type ProxyV2STCPBlock = ProxyV2BaseSpec
|
||||
|
||||
export type ProxyV2SUDPBlock = ProxyV2BaseSpec
|
||||
|
||||
export type ProxyV2XTCPBlock = ProxyV2BaseSpec
|
||||
|
||||
export interface ProxyV2SpecBlocks {
|
||||
tcp: ProxyV2TCPBlock
|
||||
udp: ProxyV2UDPBlock
|
||||
http: ProxyV2HTTPBlock
|
||||
https: ProxyV2HTTPSBlock
|
||||
tcpmux: ProxyV2TCPMuxBlock
|
||||
stcp: ProxyV2STCPBlock
|
||||
sudp: ProxyV2SUDPBlock
|
||||
xtcp: ProxyV2XTCPBlock
|
||||
}
|
||||
|
||||
export type ProxyV2Type = keyof ProxyV2SpecBlocks
|
||||
|
||||
type ProxyV2SpecFor<T extends ProxyV2Type> = {
|
||||
type: T
|
||||
} & {
|
||||
[K in T]: ProxyV2SpecBlocks[K]
|
||||
} & {
|
||||
[K in Exclude<ProxyV2Type, T>]?: never
|
||||
}
|
||||
|
||||
export type ProxyV2Spec = {
|
||||
[T in ProxyV2Type]: ProxyV2SpecFor<T>
|
||||
}[ProxyV2Type]
|
||||
|
||||
export interface ProxyV2Status {
|
||||
phase: 'online' | 'offline'
|
||||
todayTrafficIn: number
|
||||
todayTrafficOut: number
|
||||
curConns: number
|
||||
lastStartTime: string
|
||||
lastCloseTime: string
|
||||
lastStartAt?: number
|
||||
lastCloseAt?: number
|
||||
}
|
||||
|
||||
export interface TrafficResponse {
|
||||
|
||||
@@ -19,6 +19,14 @@ export function formatDistanceToNow(date: Date): string {
|
||||
return Math.floor(seconds) + ' seconds ago'
|
||||
}
|
||||
|
||||
export function formatUnixSeconds(seconds?: number): string {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return ''
|
||||
|
||||
const date = new Date(seconds * 1000)
|
||||
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
Reference in New Issue
Block a user