mirror of
https://github.com/fatedier/frp.git
synced 2026-08-31 16:05:57 +08:00
Compare commits
8
Commits
71a2bf30f9
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
832df8dff6 | ||
|
|
ebb4773c2c | ||
|
|
23ec9b4979 | ||
|
|
40bb45d192 | ||
|
|
758f07d59e | ||
|
|
25a4ba306a | ||
|
|
6c8a8d0a97 | ||
|
|
da04e1e07a |
@@ -28,4 +28,4 @@ jobs:
|
|||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
||||||
version: v2.11
|
version: v2.12.2
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ 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-->
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
|
<br>
|
||||||
|
<b>The complete IDE crafted for professional Go developers</b>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/beclab/Olares" target="_blank">
|
<a href="https://github.com/beclab/Olares" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
||||||
@@ -32,13 +40,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
|
||||||
<br>
|
|
||||||
<b>The complete IDE crafted for professional Go developers</b>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## What is frp?
|
## What is frp?
|
||||||
|
|||||||
+8
-7
@@ -14,6 +14,14 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
|
<br>
|
||||||
|
<b>The complete IDE crafted for professional Go developers</b>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/beclab/Olares" target="_blank">
|
<a href="https://github.com/beclab/Olares" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
||||||
@@ -34,13 +42,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
|
||||||
<br>
|
|
||||||
<b>The complete IDE crafted for professional Go developers</b>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## 为什么使用 frp ?
|
## 为什么使用 frp ?
|
||||||
|
|||||||
+1
-7
@@ -1,9 +1,3 @@
|
|||||||
## Features
|
|
||||||
|
|
||||||
* UDP packet payloads for ordinary UDP proxies and SUDP now use a dedicated binary codec when frpc and frps successfully negotiate the capability under wire protocol v2, using a more compact wire representation. Wire protocol v1 remains JSON; wire protocol v2 falls back to JSON `UDPPacket` when the peer does not support or did not negotiate the capability.
|
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
|
|
||||||
* Fixed a server panic and remote denial of service caused by a client sending a negative `pool_count`. Negative values are now rejected before work-connection pool resources are allocated.
|
* Fixed VirtualNet route lifecycle issues during reconnect and shutdown, including stale route cleanup, shutdown races, and reconnect backoff overflow.
|
||||||
* Fixed `frpc verify` ignoring configured `featureGates`, which caused VirtualNet configurations to be rejected even when the feature was enabled.
|
|
||||||
* Fixed a case-insensitive validation bypass that allowed `customDomains` under the configured `subDomainHost` to be registered using mixed-case domain names.
|
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
libnet "github.com/fatedier/golib/net"
|
libnet "github.com/fatedier/golib/net"
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func (monitor *Monitor) checkWorker() {
|
|||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
xl.Tracef("do one health check success")
|
xl.Tracef("do one health check success")
|
||||||
|
monitor.failedTimes = 0
|
||||||
if !monitor.statusOK && monitor.statusNormalFn != nil {
|
if !monitor.statusOK && monitor.statusNormalFn != nil {
|
||||||
xl.Infof("health check status change to success")
|
xl.Infof("health check status change to success")
|
||||||
monitor.statusOK = true
|
monitor.statusOK = true
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// 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 health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMonitorResetsFailedTimesAfterSuccess(t *testing.T) {
|
||||||
|
var checkCount atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
count := checkCount.Add(1)
|
||||||
|
if count == 1 || count == 2 || count == 4 {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
var failedCount atomic.Int32
|
||||||
|
monitor := NewMonitor(
|
||||||
|
context.Background(),
|
||||||
|
v1.HealthCheckConfig{
|
||||||
|
Type: "http",
|
||||||
|
Path: "/health",
|
||||||
|
TimeoutSeconds: 1,
|
||||||
|
IntervalSeconds: 1,
|
||||||
|
MaxFailed: 3,
|
||||||
|
},
|
||||||
|
strings.TrimPrefix(server.URL, "http://"),
|
||||||
|
func() {},
|
||||||
|
func() { failedCount.Add(1) },
|
||||||
|
)
|
||||||
|
monitor.interval = 10 * time.Millisecond
|
||||||
|
monitor.Start()
|
||||||
|
defer monitor.Stop()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return checkCount.Load() >= 5
|
||||||
|
}, time.Second, 10*time.Millisecond)
|
||||||
|
require.Equal(t, int32(0), failedCount.Load())
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
"github.com/quic-go/quic-go"
|
"github.com/quic-go/quic-go"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
libio "github.com/fatedier/golib/io"
|
libio "github.com/fatedier/golib/io"
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ 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.18.0
|
||||||
github.com/fatedier/golib v0.8.1
|
github.com/fatedier/golib v0.8.2
|
||||||
|
github.com/fatedier/yamux v0.2.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
|
||||||
github.com/hashicorp/yamux v0.1.1
|
|
||||||
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
|
||||||
@@ -73,6 +73,3 @@ require (
|
|||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
|
|
||||||
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
|
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ 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.8.2 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww=
|
||||||
github.com/fatedier/golib v0.8.1/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.2.0 h1:H+2A9iBVh7aJlEOc1Ws1FXWOaecBf2nRv9zpFMPUWg8=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.2.0/go.mod h1:d4FtRDrC9sHvRpiDL6J5EnfjLhqzZZplHe5yToSn2Ac=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// 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 validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaxRunIDLength is the maximum number of bytes accepted for a control run ID.
|
||||||
|
MaxRunIDLength = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateIdentifier(value, kind string, maxLength int) error {
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("%s cannot be empty", kind)
|
||||||
|
}
|
||||||
|
if len(value) > maxLength {
|
||||||
|
return fmt.Errorf("%s is too long: length %d exceeds maximum %d", kind, len(value), maxLength)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(value) {
|
||||||
|
return fmt.Errorf("%s must be valid UTF-8", kind)
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if !unicode.IsPrint(r) {
|
||||||
|
return fmt.Errorf("%s contains non-printable character", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateRunID(runID string) error {
|
||||||
|
return validateIdentifier(runID, "run id", MaxRunIDLength)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// 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 validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateIdentifiers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
validate func(string) error
|
||||||
|
value string
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{name: "run id accepts printable values", validate: ValidateRunID, value: "run-%1000s-中文"},
|
||||||
|
{name: "run id rejects empty", validate: ValidateRunID, wantError: "cannot be empty"},
|
||||||
|
{name: "run id rejects control character", validate: ValidateRunID, value: "run\nforged", wantError: "non-printable"},
|
||||||
|
{name: "run id rejects invalid utf8", validate: ValidateRunID, value: string([]byte{0xff}), wantError: "valid UTF-8"},
|
||||||
|
{name: "run id rejects excessive length", validate: ValidateRunID, value: strings.Repeat("a", MaxRunIDLength+1), wantError: "too long"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.validate(tt.value)
|
||||||
|
if tt.wantError == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.ErrorContains(t, err, tt.wantError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -33,10 +34,16 @@ func init() {
|
|||||||
Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)
|
Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type clientRouteController interface {
|
||||||
|
RegisterClientRoute(context.Context, string, []net.IPNet, io.ReadWriteCloser)
|
||||||
|
UnregisterClientRoute(string, io.Writer) bool
|
||||||
|
}
|
||||||
|
|
||||||
type VirtualNetPlugin struct {
|
type VirtualNetPlugin struct {
|
||||||
pluginCtx PluginContext
|
pluginCtx PluginContext
|
||||||
|
|
||||||
routes []net.IPNet
|
routeController clientRouteController
|
||||||
|
routes []net.IPNet
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
controllerConn net.Conn
|
controllerConn net.Conn
|
||||||
@@ -48,6 +55,11 @@ type VirtualNetPlugin struct {
|
|||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
virtualNetReconnectBaseDelay = 60 * time.Second
|
||||||
|
virtualNetReconnectMaxDelay = 300 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {
|
func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.VirtualNetVisitorPluginOptions)
|
opts := options.(*v1.VirtualNetVisitorPluginOptions)
|
||||||
|
|
||||||
@@ -55,6 +67,9 @@ func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOption
|
|||||||
pluginCtx: pluginCtx,
|
pluginCtx: pluginCtx,
|
||||||
routes: make([]net.IPNet, 0),
|
routes: make([]net.IPNet, 0),
|
||||||
}
|
}
|
||||||
|
if pluginCtx.VnetController != nil {
|
||||||
|
p.routeController = pluginCtx.VnetController
|
||||||
|
}
|
||||||
|
|
||||||
p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)
|
p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)
|
||||||
|
|
||||||
@@ -85,7 +100,7 @@ func (p *VirtualNetPlugin) Name() string {
|
|||||||
|
|
||||||
func (p *VirtualNetPlugin) Start() {
|
func (p *VirtualNetPlugin) Start() {
|
||||||
xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
|
xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
|
||||||
if p.pluginCtx.VnetController == nil {
|
if p.routeController == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,16 +126,17 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
select {
|
select {
|
||||||
case <-p.ctx.Done():
|
case <-p.ctx.Done():
|
||||||
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name)
|
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name)
|
||||||
p.cleanupControllerConn(xl)
|
p.cleanupCurrentControllerConn(xl)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
controllerConn, pluginConn := net.Pipe()
|
controllerConn, pluginConn := net.Pipe()
|
||||||
|
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
|
||||||
p.mu.Lock()
|
if !p.registerControllerConn(controllerConn, pluginConn) {
|
||||||
p.controllerConn = controllerConn
|
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before route registration).", p.pluginCtx.Name)
|
||||||
p.mu.Unlock()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap with CloseNotifyConn which supports both close notification and error recording
|
// Wrap with CloseNotifyConn which supports both close notification and error recording
|
||||||
var closeErr error
|
var closeErr error
|
||||||
@@ -129,8 +145,6 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
close(currentCloseSignal) // Signal the run loop on close.
|
close(currentCloseSignal) // Signal the run loop on close.
|
||||||
})
|
})
|
||||||
|
|
||||||
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
|
|
||||||
p.pluginCtx.VnetController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
|
|
||||||
xl.Infof("successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name)
|
xl.Infof("successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name)
|
||||||
|
|
||||||
// Pass the CloseNotifyConn to the visitor for handling.
|
// Pass the CloseNotifyConn to the visitor for handling.
|
||||||
@@ -141,7 +155,7 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
select {
|
select {
|
||||||
case <-p.ctx.Done():
|
case <-p.ctx.Done():
|
||||||
xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name)
|
xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name)
|
||||||
p.cleanupControllerConn(xl)
|
p.cleanupControllerConn(xl, controllerConn)
|
||||||
return
|
return
|
||||||
case <-currentCloseSignal:
|
case <-currentCloseSignal:
|
||||||
// Determine reconnect delay based on error with exponential backoff
|
// Determine reconnect delay based on error with exponential backoff
|
||||||
@@ -152,8 +166,7 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
p.pluginCtx.Name, p.consecutiveErrors, closeErr)
|
p.pluginCtx.Name, p.consecutiveErrors, closeErr)
|
||||||
|
|
||||||
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
|
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
|
||||||
baseDelay := 60 * time.Second
|
reconnectDelay = virtualNetReconnectDelay(p.consecutiveErrors)
|
||||||
reconnectDelay = min(baseDelay*time.Duration(1<<uint(p.consecutiveErrors-1)), 300*time.Second)
|
|
||||||
} else {
|
} else {
|
||||||
// Reset consecutive errors on successful connection
|
// Reset consecutive errors on successful connection
|
||||||
if p.consecutiveErrors > 0 {
|
if p.consecutiveErrors > 0 {
|
||||||
@@ -167,7 +180,7 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The visitor closed the plugin side. Close the controller side.
|
// The visitor closed the plugin side. Close the controller side.
|
||||||
p.cleanupControllerConn(xl)
|
p.cleanupControllerConn(xl, controllerConn)
|
||||||
|
|
||||||
xl.Infof("waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name)
|
xl.Infof("waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name)
|
||||||
select {
|
select {
|
||||||
@@ -182,16 +195,66 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupControllerConn closes the current controllerConn (if it exists) under lock.
|
// registerControllerConn publishes and registers controllerConn atomically with
|
||||||
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) {
|
// respect to Close. A canceled plugin cannot register a new route.
|
||||||
|
func (p *VirtualNetPlugin) registerControllerConn(controllerConn, pluginConn net.Conn) bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.ctx.Err() != nil || p.routeController == nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
_ = controllerConn.Close()
|
||||||
|
_ = pluginConn.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
p.controllerConn = controllerConn
|
||||||
|
p.routeController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
|
||||||
|
p.mu.Unlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// virtualNetReconnectDelay returns a bounded reconnect delay without allowing
|
||||||
|
// the exponential shift to overflow for large consecutive error counts.
|
||||||
|
func virtualNetReconnectDelay(consecutiveErrors int) time.Duration {
|
||||||
|
if consecutiveErrors <= 1 {
|
||||||
|
return virtualNetReconnectBaseDelay
|
||||||
|
}
|
||||||
|
if consecutiveErrors >= 4 {
|
||||||
|
return virtualNetReconnectMaxDelay
|
||||||
|
}
|
||||||
|
return virtualNetReconnectBaseDelay * time.Duration(1<<uint(consecutiveErrors-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupControllerConn unregisters and closes one connection round without
|
||||||
|
// affecting a replacement route owned by another connection.
|
||||||
|
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger, controllerConn net.Conn) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
if p.controllerConn != nil {
|
p.cleanupControllerConnLocked(xl, controllerConn)
|
||||||
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
|
}
|
||||||
p.controllerConn.Close()
|
|
||||||
p.controllerConn = nil
|
func (p *VirtualNetPlugin) cleanupCurrentControllerConn(xl *xlog.Logger) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.cleanupControllerConnLocked(xl, p.controllerConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupControllerConnLocked must be called with p.mu held.
|
||||||
|
func (p *VirtualNetPlugin) cleanupControllerConnLocked(xl *xlog.Logger, controllerConn net.Conn) {
|
||||||
|
if controllerConn == nil {
|
||||||
|
p.closeSignal = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.routeController != nil &&
|
||||||
|
p.routeController.UnregisterClientRoute(p.pluginCtx.Name, controllerConn) {
|
||||||
|
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
|
||||||
|
}
|
||||||
|
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
|
||||||
|
_ = controllerConn.Close()
|
||||||
|
if p.controllerConn == controllerConn {
|
||||||
|
p.controllerConn = nil
|
||||||
|
p.closeSignal = nil
|
||||||
}
|
}
|
||||||
p.closeSignal = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close initiates the plugin shutdown.
|
// Close initiates the plugin shutdown.
|
||||||
@@ -202,15 +265,9 @@ func (p *VirtualNetPlugin) Close() error {
|
|||||||
// Signal the run loop goroutine to stop.
|
// Signal the run loop goroutine to stop.
|
||||||
p.cancel()
|
p.cancel()
|
||||||
|
|
||||||
// Unregister the route from the controller.
|
// Unregister and close the current connection while holding the same lock
|
||||||
if p.pluginCtx.VnetController != nil {
|
// used to check cancellation and register a route in run.
|
||||||
p.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name)
|
p.cleanupCurrentControllerConn(xl)
|
||||||
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicitly close the controller side of the pipe.
|
|
||||||
// This ensures the pipe is broken even if the run loop is stuck or the visitor hasn't closed its end.
|
|
||||||
p.cleanupControllerConn(xl)
|
|
||||||
xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name)
|
xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package visitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testVirtualNetVisitorName = "vnet-visitor"
|
||||||
|
|
||||||
|
type fakeClientRouteController struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
routes map[string]io.Writer
|
||||||
|
|
||||||
|
beforeRegister func()
|
||||||
|
registerCalls int
|
||||||
|
unregisterCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeClientRouteController() *fakeClientRouteController {
|
||||||
|
return &fakeClientRouteController{
|
||||||
|
routes: make(map[string]io.Writer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClientRouteController) RegisterClientRoute(
|
||||||
|
_ context.Context,
|
||||||
|
name string,
|
||||||
|
_ []net.IPNet,
|
||||||
|
conn io.ReadWriteCloser,
|
||||||
|
) {
|
||||||
|
if c.beforeRegister != nil {
|
||||||
|
c.beforeRegister()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.registerCalls++
|
||||||
|
c.routes[name] = conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClientRouteController) UnregisterClientRoute(name string, conn io.Writer) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.unregisterCalls++
|
||||||
|
owner, ok := c.routes[name]
|
||||||
|
if !ok || owner != conn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(c.routes, name)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClientRouteController) owner() io.Writer {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.routes[testVirtualNetVisitorName]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClientRouteController) callCounts() (register, unregister int) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.registerCalls, c.unregisterCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackedConn struct {
|
||||||
|
net.Conn
|
||||||
|
closed atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedConn) Close() error {
|
||||||
|
c.closed.Store(true)
|
||||||
|
return c.Conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrackedPipe(t *testing.T) (*trackedConn, *trackedConn) {
|
||||||
|
t.Helper()
|
||||||
|
left, right := net.Pipe()
|
||||||
|
trackedLeft := &trackedConn{Conn: left}
|
||||||
|
trackedRight := &trackedConn{Conn: right}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = trackedLeft.Close()
|
||||||
|
_ = trackedRight.Close()
|
||||||
|
})
|
||||||
|
return trackedLeft, trackedRight
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestVirtualNetPlugin(t *testing.T, controller *fakeClientRouteController) *VirtualNetPlugin {
|
||||||
|
t.Helper()
|
||||||
|
pluginCtx := context.Background()
|
||||||
|
ctx, cancel := context.WithCancel(pluginCtx)
|
||||||
|
p := &VirtualNetPlugin{
|
||||||
|
pluginCtx: PluginContext{
|
||||||
|
Name: testVirtualNetVisitorName,
|
||||||
|
Ctx: pluginCtx,
|
||||||
|
},
|
||||||
|
routeController: controller,
|
||||||
|
routes: []net.IPNet{{
|
||||||
|
IP: net.ParseIP("10.1.0.1"),
|
||||||
|
Mask: net.CIDRMask(32, 32),
|
||||||
|
}},
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = p.Close()
|
||||||
|
})
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitResult[T any](t *testing.T, ch <-chan T) T {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case result := <-ch:
|
||||||
|
return result
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for concurrent operation")
|
||||||
|
var zero T
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVirtualNetReconnectDelay verifies the documented exponential backoff and
|
||||||
|
// ensures large error counts remain capped instead of overflowing to zero.
|
||||||
|
func TestVirtualNetReconnectDelay(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
consecutiveErrors int
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{name: "first error", consecutiveErrors: 1, want: 60 * time.Second},
|
||||||
|
{name: "second error", consecutiveErrors: 2, want: 120 * time.Second},
|
||||||
|
{name: "third error", consecutiveErrors: 3, want: 240 * time.Second},
|
||||||
|
{name: "fourth error", consecutiveErrors: 4, want: 300 * time.Second},
|
||||||
|
{name: "shift width boundary", consecutiveErrors: 64, want: 300 * time.Second},
|
||||||
|
{name: "observed retry storm", consecutiveErrors: 329769, want: 300 * time.Second},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, virtualNetReconnectDelay(tt.consecutiveErrors))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVirtualNetPluginCloseBeforeRegisterDoesNotReplaceNewRoute(t *testing.T) {
|
||||||
|
controller := newFakeClientRouteController()
|
||||||
|
oldPlugin := newTestVirtualNetPlugin(t, controller)
|
||||||
|
newPlugin := newTestVirtualNetPlugin(t, controller)
|
||||||
|
oldControllerConn, oldPluginConn := newTrackedPipe(t)
|
||||||
|
newControllerConn, newPluginConn := newTrackedPipe(t)
|
||||||
|
|
||||||
|
allowOldRegister := make(chan struct{})
|
||||||
|
oldRegisterResult := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
<-allowOldRegister
|
||||||
|
oldRegisterResult <- oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn)
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.NoError(t, oldPlugin.Close())
|
||||||
|
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
|
||||||
|
close(allowOldRegister)
|
||||||
|
|
||||||
|
require.False(t, waitResult(t, oldRegisterResult))
|
||||||
|
require.Same(t, newControllerConn, controller.owner())
|
||||||
|
registerCalls, _ := controller.callCounts()
|
||||||
|
require.Equal(t, 1, registerCalls)
|
||||||
|
require.True(t, oldControllerConn.closed.Load())
|
||||||
|
require.True(t, oldPluginConn.closed.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVirtualNetPluginRegisterBeforeCloseIsCleanedUp(t *testing.T) {
|
||||||
|
controller := newFakeClientRouteController()
|
||||||
|
p := newTestVirtualNetPlugin(t, controller)
|
||||||
|
controllerConn, pluginConn := newTrackedPipe(t)
|
||||||
|
registerEntered := make(chan struct{})
|
||||||
|
var registerEnteredOnce sync.Once
|
||||||
|
controller.beforeRegister = func() {
|
||||||
|
registerEnteredOnce.Do(func() {
|
||||||
|
close(registerEntered)
|
||||||
|
})
|
||||||
|
<-p.ctx.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
registerResult := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
registerResult <- p.registerControllerConn(controllerConn, pluginConn)
|
||||||
|
}()
|
||||||
|
waitResult(t, registerEntered)
|
||||||
|
|
||||||
|
closeResult := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
closeResult <- p.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.NoError(t, waitResult(t, closeResult))
|
||||||
|
require.True(t, waitResult(t, registerResult))
|
||||||
|
require.Nil(t, controller.owner())
|
||||||
|
registerCalls, unregisterCalls := controller.callCounts()
|
||||||
|
require.Equal(t, 1, registerCalls)
|
||||||
|
require.Equal(t, 1, unregisterCalls)
|
||||||
|
require.True(t, controllerConn.closed.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVirtualNetPluginOldConnectionCleanupKeepsReplacementRoute(t *testing.T) {
|
||||||
|
controller := newFakeClientRouteController()
|
||||||
|
oldPlugin := newTestVirtualNetPlugin(t, controller)
|
||||||
|
newPlugin := newTestVirtualNetPlugin(t, controller)
|
||||||
|
oldControllerConn, oldPluginConn := newTrackedPipe(t)
|
||||||
|
newControllerConn, newPluginConn := newTrackedPipe(t)
|
||||||
|
|
||||||
|
require.True(t, oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn))
|
||||||
|
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
|
||||||
|
require.Same(t, newControllerConn, controller.owner())
|
||||||
|
|
||||||
|
oldPlugin.cleanupControllerConn(xlog.FromContextSafe(oldPlugin.ctx), oldControllerConn)
|
||||||
|
|
||||||
|
require.Same(t, newControllerConn, controller.owner())
|
||||||
|
require.True(t, oldControllerConn.closed.Load())
|
||||||
|
}
|
||||||
@@ -96,21 +96,21 @@ func (l *Logger) Spawn() *Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Errorf(format string, v ...any) {
|
func (l *Logger) Errorf(format string, v ...any) {
|
||||||
log.Logger.Errorf(l.prefixString+format, v...)
|
log.Logger.WithPrefix(l.prefixString).Errorf(format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Warnf(format string, v ...any) {
|
func (l *Logger) Warnf(format string, v ...any) {
|
||||||
log.Logger.Warnf(l.prefixString+format, v...)
|
log.Logger.WithPrefix(l.prefixString).Warnf(format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Infof(format string, v ...any) {
|
func (l *Logger) Infof(format string, v ...any) {
|
||||||
log.Logger.Infof(l.prefixString+format, v...)
|
log.Logger.WithPrefix(l.prefixString).Infof(format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Debugf(format string, v ...any) {
|
func (l *Logger) Debugf(format string, v ...any) {
|
||||||
log.Logger.Debugf(l.prefixString+format, v...)
|
log.Logger.WithPrefix(l.prefixString).Debugf(format, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Tracef(format string, v ...any) {
|
func (l *Logger) Tracef(format string, v ...any) {
|
||||||
log.Logger.Tracef(l.prefixString+format, v...)
|
log.Logger.WithPrefix(l.prefixString).Tracef(format, v...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 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 xlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
goliblog "github.com/fatedier/golib/log"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
frplog "github.com/fatedier/frp/pkg/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrefixIsNotPartOfFormatString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
log func(*Logger)
|
||||||
|
}{
|
||||||
|
{name: "error", log: func(xl *Logger) { xl.Errorf("value [%s]", "ok") }},
|
||||||
|
{name: "warn", log: func(xl *Logger) { xl.Warnf("value [%s]", "ok") }},
|
||||||
|
{name: "info", log: func(xl *Logger) { xl.Infof("value [%s]", "ok") }},
|
||||||
|
{name: "debug", log: func(xl *Logger) { xl.Debugf("value [%s]", "ok") }},
|
||||||
|
{name: "trace", log: func(xl *Logger) { xl.Tracef("value [%s]", "ok") }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output := captureLogs(t)
|
||||||
|
|
||||||
|
tt.log(New().AppendPrefix("%1000000s"))
|
||||||
|
|
||||||
|
require.Contains(t, output.String(), "[%1000000s] value [ok]")
|
||||||
|
require.Less(t, output.Len(), 1024)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormattingSemanticsArePreserved(t *testing.T) {
|
||||||
|
output := captureLogs(t)
|
||||||
|
xl := New().AppendPrefix("run")
|
||||||
|
|
||||||
|
xl.Infof("%[2]s %[1]s", "first", "second")
|
||||||
|
xl.Infof("100% complete")
|
||||||
|
|
||||||
|
require.Contains(t, output.String(), "[run] second first")
|
||||||
|
require.Contains(t, output.String(), "[run] 100% complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureLogs(t *testing.T) *bytes.Buffer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
output := bytes.NewBuffer(nil)
|
||||||
|
oldLogger := frplog.Logger
|
||||||
|
frplog.Logger = goliblog.New(
|
||||||
|
goliblog.WithOutput(output),
|
||||||
|
goliblog.WithLevel(goliblog.TraceLevel),
|
||||||
|
goliblog.WithCaller(false),
|
||||||
|
)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
frplog.Logger = oldLogger
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
}
|
||||||
@@ -246,9 +246,9 @@ func (c *Controller) RegisterClientRoute(ctx context.Context, name string, route
|
|||||||
go c.readLoopClient(ctx, conn)
|
go c.readLoopClient(ctx, conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnregisterClientRoute Remove client route from routing table
|
// UnregisterClientRoute removes a client route only when it is still owned by conn.
|
||||||
func (c *Controller) UnregisterClientRoute(name string) {
|
func (c *Controller) UnregisterClientRoute(name string, conn io.Writer) bool {
|
||||||
c.clientRouter.delRoute(name)
|
return c.clientRouter.delRoute(name, conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartServerConnReadLoop starts the read loop for a server connection
|
// StartServerConnReadLoop starts the read loop for a server connection
|
||||||
@@ -304,10 +304,15 @@ func (r *clientRouter) findConn(dst net.IP) (io.Writer, error) {
|
|||||||
return nil, fmt.Errorf("no route found for destination %s", dst)
|
return nil, fmt.Errorf("no route found for destination %s", dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *clientRouter) delRoute(name string) {
|
func (r *clientRouter) delRoute(name string, conn io.Writer) bool {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
re, ok := r.routes[name]
|
||||||
|
if !ok || re.conn != conn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
delete(r.routes, name)
|
delete(r.routes, name)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *clientRouter) removeConnRoute(conn io.Writer) {
|
func (r *clientRouter) removeConnRoute(conn io.Writer) {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 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 vnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestClientRouterDeleteRouteRequiresMatchingConnection verifies that a stale
|
||||||
|
// visitor cannot remove a replacement route registered under the same name.
|
||||||
|
func TestClientRouterDeleteRouteRequiresMatchingConnection(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
controller := NewController(v1.VirtualNetConfig{})
|
||||||
|
|
||||||
|
_, route, err := net.ParseCIDR("10.1.0.1/32")
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
oldConn, oldPeer := net.Pipe()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = oldConn.Close()
|
||||||
|
_ = oldPeer.Close()
|
||||||
|
})
|
||||||
|
replacementConn, replacementPeer := net.Pipe()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = replacementConn.Close()
|
||||||
|
_ = replacementPeer.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
controller.clientRouter.addRoute("vnet-visitor", []net.IPNet{*route}, oldConn)
|
||||||
|
controller.clientRouter.addRoute("vnet-visitor", []net.IPNet{*route}, replacementConn)
|
||||||
|
|
||||||
|
require.False(controller.UnregisterClientRoute("vnet-visitor", oldConn))
|
||||||
|
got, err := controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
|
||||||
|
require.NoError(err)
|
||||||
|
require.Same(replacementConn, got)
|
||||||
|
|
||||||
|
// The read loop for an old connection can exit after a replacement route
|
||||||
|
// has already been registered. Its deferred cleanup must keep the new owner.
|
||||||
|
controller.clientRouter.removeConnRoute(oldConn)
|
||||||
|
got, err = controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
|
||||||
|
require.NoError(err)
|
||||||
|
require.Same(replacementConn, got)
|
||||||
|
|
||||||
|
require.True(controller.UnregisterClientRoute("vnet-visitor", replacementConn))
|
||||||
|
_, err = controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
|
||||||
|
require.Error(err)
|
||||||
|
}
|
||||||
+5
-1
@@ -29,12 +29,13 @@ import (
|
|||||||
|
|
||||||
"github.com/fatedier/golib/crypto"
|
"github.com/fatedier/golib/crypto"
|
||||||
"github.com/fatedier/golib/net/mux"
|
"github.com/fatedier/golib/net/mux"
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/auth"
|
"github.com/fatedier/frp/pkg/auth"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
modelmetrics "github.com/fatedier/frp/pkg/metrics"
|
modelmetrics "github.com/fatedier/frp/pkg/metrics"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/nathole"
|
"github.com/fatedier/frp/pkg/nathole"
|
||||||
@@ -791,6 +792,9 @@ func (svr *Service) RegisterControl(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := validation.ValidateRunID(loginMsg.RunID); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid run id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := netpkg.NewContextFromConn(ctlConn)
|
ctx := netpkg.NewContextFromConn(ctlConn)
|
||||||
xl := xlog.FromContextSafe(ctx)
|
xl := xlog.FromContextSafe(ctx)
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -31,6 +33,7 @@ import (
|
|||||||
|
|
||||||
"github.com/fatedier/frp/pkg/auth"
|
"github.com/fatedier/frp/pkg/auth"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
||||||
"github.com/fatedier/frp/pkg/proto/wire"
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
@@ -638,6 +641,22 @@ func TestServiceRegisterControlRejectsInvalidCodecSelection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceRegisterControlRejectsInvalidRunID(t *testing.T) {
|
||||||
|
for _, runID := range []string{
|
||||||
|
"run\nforged",
|
||||||
|
strings.Repeat("a", validation.MaxRunIDLength+1),
|
||||||
|
} {
|
||||||
|
t.Run(fmt.Sprintf("run_id_%d", len(runID)), func(t *testing.T) {
|
||||||
|
svr := newControlTestService(t)
|
||||||
|
conn := newDeadlineReadConn()
|
||||||
|
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
||||||
|
ctl, err := svr.RegisterControl(msgConn, &msg.Login{RunID: runID}, true, wire.ProtocolV1, "")
|
||||||
|
require.Nil(t, ctl)
|
||||||
|
require.ErrorContains(t, err, "invalid run id")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceRegisterControlPoolCountBoundaries(t *testing.T) {
|
func TestServiceRegisterControlPoolCountBoundaries(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Generated
+12
-12
@@ -2654,9 +2654,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/test-utils/node_modules/brace-expansion": {
|
"node_modules/@vue/test-utils/node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3075,9 +3075,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5682,9 +5682,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5811,9 +5811,9 @@
|
|||||||
"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.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user