mirror of
https://github.com/fatedier/frp.git
synced 2026-10-24 07:33:27 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b52799bb | ||
|
|
8d5e9d2b21 | ||
|
|
a73e8a14fc | ||
|
|
69ec4f0145 | ||
|
|
b0bf32571a | ||
|
|
f13fdd58d4 | ||
|
|
00e4fc4dd3 | ||
|
|
585fdc5b83 | ||
|
|
228d23b3d5 | ||
|
|
399bf2ce4c | ||
|
|
26742060b1 | ||
|
|
7441443134 | ||
|
|
954f918d0c | ||
|
|
031716afd1 | ||
|
|
41ed2aecf8 | ||
|
|
12b0093ccf | ||
|
|
2542d11a62 | ||
|
|
c4d2db4177 | ||
|
|
71b31978b3 | ||
|
|
1cc2dd8a23 | ||
|
|
9c7d9898b3 | ||
|
|
ab0959868a | ||
|
|
1dbd44edd3 | ||
|
|
fccc7b0e9f | ||
|
|
55107d003d | ||
|
|
4cd4747525 | ||
|
|
f8be1cdfe2 | ||
|
|
d5636108e6 | ||
|
|
d20a232996 | ||
|
|
b0d840858f | ||
|
|
c5508627c4 | ||
|
|
832df8dff6 | ||
|
|
ebb4773c2c | ||
|
|
23ec9b4979 | ||
|
|
40bb45d192 | ||
|
|
758f07d59e | ||
|
|
25a4ba306a |
@@ -1,8 +1,8 @@
|
||||
version: 2
|
||||
version: 2.1
|
||||
jobs:
|
||||
go-version-latest:
|
||||
docker:
|
||||
- image: cimg/go:1.25-node
|
||||
- image: cimg/go:1.26-node
|
||||
resource_class: large
|
||||
steps:
|
||||
- checkout
|
||||
@@ -16,10 +16,11 @@ jobs:
|
||||
make env fmt
|
||||
git diff --exit-code
|
||||
make build
|
||||
- run: make alltest
|
||||
- run:
|
||||
name: Run all tests
|
||||
command: make alltest
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build_and_test:
|
||||
jobs:
|
||||
- go-version-latest
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
# environment
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: '0'
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: false
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Test and build web assets
|
||||
@@ -28,4 +28,4 @@ jobs:
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
# 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
|
||||
|
||||
@@ -8,15 +8,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: '1.25'
|
||||
- uses: actions/setup-node@v6
|
||||
go-version: '1.26'
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Build web assets (frps)
|
||||
|
||||
@@ -12,16 +12,6 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<p align="center">
|
||||
<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">
|
||||
<br>
|
||||
<b>The sovereign cloud that puts you in control</b>
|
||||
<br>
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
@@ -39,6 +29,17 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<b>The complete IDE crafted for professional Go developers</b>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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">
|
||||
<br>
|
||||
<b>The sovereign cloud that puts you in control</b>
|
||||
<br>
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## What is frp?
|
||||
@@ -615,13 +616,31 @@ Proxies and visitors managed through the Store are saved to disk and automatical
|
||||
|
||||
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
|
||||
|
||||
Prometheus is also supported.
|
||||
Both frps and frpc support a separate `metricsServer` listener for health checks and optional profiling, without enabling the dashboard or admin API. It is disabled by default (`metricsServer.port = 0`) and binds to `127.0.0.1` unless configured otherwise.
|
||||
|
||||
`/healthz` returns an empty HTTP 200 response without authentication. It checks that the HTTP server is responding; it does not indicate whether frpc is connected to frps or whether proxies are ready.
|
||||
|
||||
#### Prometheus
|
||||
|
||||
Enable dashboard first, then configure `enablePrometheus = true` in `frps.toml`.
|
||||
Configure a dedicated listener in `frps.toml`:
|
||||
|
||||
`http://{dashboard_addr}/metrics` will provide prometheus monitor data.
|
||||
```toml
|
||||
metricsServer.addr = "127.0.0.1"
|
||||
metricsServer.port = 7501
|
||||
metricsServer.user = "monitor"
|
||||
metricsServer.password = "change-me"
|
||||
# Optional TLS, independent of webServer:
|
||||
# metricsServer.tls.certFile = "metrics.crt"
|
||||
# metricsServer.tls.keyFile = "metrics.key"
|
||||
# Profiling is disabled unless explicitly enabled:
|
||||
# metricsServer.pprofEnable = true
|
||||
```
|
||||
|
||||
Scrape `http://127.0.0.1:7501/metrics` using these Basic Auth credentials. This automatically enables frps business metrics, even when `webServer` is disabled and `enablePrometheus` is unset. Credentials and TLS are independent of `webServer`; when credentials are configured, both `/metrics` and profiling endpoints require authentication. `/healthz` remains anonymous.
|
||||
|
||||
Set `metricsServer.pprofEnable = true` to expose `/debug/pprof/` and profiling endpoints on this listener. `/debug/pprof/cmdline` is excluded. frpc serves `/metrics` on this listener too, with Go runtime metrics only: no proxy, traffic or client data, and the same set of metrics on every platform.
|
||||
|
||||
The `/healthz`, `/metrics`, and pprof endpoints on `webServer` are deprecated but remain available. `enablePrometheus = true` still enables the legacy frps `/metrics` endpoint on the dashboard listener. Migrate health probes, Prometheus scrapes, and profiling to `metricsServer`; the dashboard and admin APIs continue to use `webServer`.
|
||||
|
||||
### Authenticating the Client
|
||||
|
||||
@@ -711,6 +730,10 @@ transport.tls.keyFile = "certificate.key"
|
||||
transport.tls.trustedCaFile = "ca.crt"
|
||||
```
|
||||
|
||||
By default, when neither `transport.tls.trustedCaFile` nor `transport.tls.useSystemCa` is set, `frpc` does not verify the server certificate. Set `transport.tls.useSystemCa = true` to verify it using the host's system CAs. If `trustedCaFile` is also set, its CA certificates are added to the system CA pool; setting only `trustedCaFile` trusts only the CAs in that file. CA loading or certificate verification failures cause the connection to fail without falling back to another trust source or skipping verification.
|
||||
|
||||
`useSystemCa` is supported only in `frpc`'s `transport.tls`, and requires `transport.tls.enable = true` unless `transport.protocol = "wss"`. With `wss`, the outer TLS connection is always enabled and verification applies to the TLS endpoint, which may be a reverse proxy. Setting `useSystemCa = true` in `frps`'s `transport.tls` or either process's `webServer.tls` or `metricsServer.tls` is a configuration error.
|
||||
|
||||
**`frps` TLS settings:**
|
||||
|
||||
```toml
|
||||
@@ -804,6 +827,15 @@ webServer.port = 7400
|
||||
|
||||
Then run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to let `frpc` create or update or remove proxies.
|
||||
|
||||
For unchanged proxies using `https2http` or `https2https`, `frpc reload` also rereads
|
||||
certificate and key files so externally renewed certificates take effect on new
|
||||
TLS handshakes without interrupting existing connections. Unchanged file contents
|
||||
leave the certificate untouched. If either file cannot be read or the pair is
|
||||
invalid, the proxy keeps its previous certificate and reports the error in its
|
||||
status and logs; reload still succeeds. A successful retry clears the error.
|
||||
Generated self-signed certificates are unchanged, and Store API updates do not
|
||||
refresh certificates on unchanged proxies.
|
||||
|
||||
**Note that global client parameters won't be modified except 'start'.**
|
||||
|
||||
`start` is a global allowlist evaluated after all sources are merged (config file/include/store).
|
||||
|
||||
+11
-10
@@ -14,16 +14,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<p align="center">
|
||||
<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">
|
||||
<br>
|
||||
<b>The sovereign cloud that puts you in control</b>
|
||||
<br>
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Recall.ai - API for meeting recordings
|
||||
@@ -41,6 +31,17 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<b>The complete IDE crafted for professional Go developers</b>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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">
|
||||
<br>
|
||||
<b>The sovereign cloud that puts you in control</b>
|
||||
<br>
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## 为什么使用 frp ?
|
||||
|
||||
+6
-4
@@ -1,9 +1,11 @@
|
||||
## 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.
|
||||
* frpc can verify server certificates using system CAs, optionally combined with CAs from `trustedCaFile`.
|
||||
* `frpc reload` now refreshes certificate files for unchanged `https2http` and `https2https` proxies without interrupting existing connections. Failed refreshes keep the previous certificate and report the error in proxy status and logs while reload still succeeds.
|
||||
* Added `metricsServer` to both frps and frpc, a separate listener for health checks, metrics and optional profiling that does not expose the dashboard or the admin API. It is disabled unless `metricsServer.port` is set, defaults to `127.0.0.1`, and has its own credentials and TLS. Setting the port serves `/metrics` on both: frps exposes runtime and business metrics, so `enablePrometheus` is not needed there, and frpc exposes Go runtime metrics only, without linking an extra metrics library.
|
||||
|
||||
The `/healthz`, `/metrics` and pprof endpoints on `webServer`, `webServer.pprofEnable` and `enablePrometheus` still work but are deprecated: configure `metricsServer` instead. They will continue to work until v0.80.0 is released, and may be removed in v0.80.0 or later.
|
||||
|
||||
## 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 `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.
|
||||
* Fixed proxy and visitor configuration changes being lost when reloading frpc during reconnection.
|
||||
|
||||
@@ -45,10 +45,29 @@ func (m *serviceConfigManager) ReloadFromFile(strict bool) error {
|
||||
return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
|
||||
}
|
||||
|
||||
if err := m.svr.UpdateConfigSource(result.Common, result.Proxies, result.Visitors); err != nil {
|
||||
var reloadPlugins func()
|
||||
err = func() error {
|
||||
m.svr.reloadMu.Lock()
|
||||
defer m.svr.reloadMu.Unlock()
|
||||
|
||||
// Snapshot plugins and apply config in the same transaction as control handoff.
|
||||
m.svr.ctlMu.RLock()
|
||||
ctl := m.svr.ctl
|
||||
m.svr.ctlMu.RUnlock()
|
||||
if ctl != nil {
|
||||
reloadPlugins = ctl.pm.PreparePluginReload()
|
||||
}
|
||||
return m.svr.updateConfigSourceLocked(result.Common, result.Proxies, result.Visitors)
|
||||
}()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", configmgmt.ErrApplyConfig, err)
|
||||
}
|
||||
|
||||
// Certificate file I/O must not hold the reload transaction lock.
|
||||
if reloadPlugins != nil {
|
||||
reloadPlugins()
|
||||
}
|
||||
|
||||
log.Infof("success reload conf")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// 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 client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/client/proxy"
|
||||
"github.com/fatedier/frp/client/visitor"
|
||||
"github.com/fatedier/frp/pkg/config"
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
"github.com/fatedier/frp/pkg/transport"
|
||||
)
|
||||
|
||||
type certificateReloadSender struct{ started chan string }
|
||||
|
||||
func (s *certificateReloadSender) Send(m msg.Message) error {
|
||||
if start, ok := m.(*msg.NewProxy); ok {
|
||||
s.started <- start.ProxyName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeReloadCertificate(t *testing.T, crtPath, keyPath string) []byte {
|
||||
t.Helper()
|
||||
cfg, err := transport.NewServerTLSConfig("", "", "")
|
||||
require.NoError(t, err)
|
||||
cert := cfg.Certificates[0]
|
||||
key, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(crtPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0o600))
|
||||
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600))
|
||||
return cert.Certificate[0]
|
||||
}
|
||||
|
||||
func TestFileReloadCertificatesAndStoreMutations(t *testing.T) {
|
||||
for _, pluginType := range []string{v1.PluginHTTPS2HTTP, v1.PluginHTTPS2HTTPS} {
|
||||
t.Run(pluginType, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
|
||||
original := writeReloadCertificate(t, crtPath, keyPath)
|
||||
configPath := filepath.Join(dir, "frpc.toml")
|
||||
content := fmt.Sprintf(`[[proxies]]
|
||||
name = "https"
|
||||
type = "tcp"
|
||||
remotePort = 8443
|
||||
[proxies.plugin]
|
||||
type = %q
|
||||
localAddr = "127.0.0.1:80"
|
||||
crtPath = %q
|
||||
keyPath = %q
|
||||
`, pluginType, crtPath, keyPath)
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(content), 0o600))
|
||||
result, err := config.LoadClientConfigResult(configPath, true)
|
||||
require.NoError(t, err)
|
||||
configSource := source.NewConfigSource()
|
||||
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{Path: filepath.Join(dir, "store.json")})
|
||||
require.NoError(t, err)
|
||||
aggregator := source.NewAggregator(configSource)
|
||||
aggregator.SetStoreSource(storeSource)
|
||||
sender := &certificateReloadSender{started: make(chan string, 4)}
|
||||
transporter := transport.NewMessageTransporter(sender)
|
||||
ctx := context.Background()
|
||||
pm := proxy.NewManager(ctx, result.Common, nil, transporter, nil, "")
|
||||
vm := visitor.NewManager(ctx, "", result.Common, nil, transporter, nil)
|
||||
t.Cleanup(pm.Close)
|
||||
t.Cleanup(vm.Close)
|
||||
svr := &Service{
|
||||
common: result.Common,
|
||||
configFilePath: configPath,
|
||||
unsafeFeatures: security.NewUnsafeFeatures(nil),
|
||||
aggregator: aggregator,
|
||||
configSource: configSource,
|
||||
storeSource: storeSource,
|
||||
ctl: &Control{pm: pm, vm: vm},
|
||||
}
|
||||
require.NoError(t, svr.UpdateConfigSource(result.Common, result.Proxies, nil))
|
||||
select {
|
||||
case name := <-sender.started:
|
||||
require.Equal(t, "https", name)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("proxy did not request startup")
|
||||
}
|
||||
require.NoError(t, pm.StartProxy("https", ":8443", ""))
|
||||
manager := &serviceConfigManager{svr: svr}
|
||||
|
||||
connect := func(wantCert []byte) *tls.Conn {
|
||||
t.Helper()
|
||||
clientRaw, serverRaw := net.Pipe()
|
||||
t.Cleanup(func() { _ = clientRaw.Close(); _ = serverRaw.Close() })
|
||||
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
pm.HandleWorkConn("https", serverRaw, &msg.StartWorkConn{})
|
||||
conn := tls.Client(clientRaw, &tls.Config{ServerName: "localhost", InsecureSkipVerify: true})
|
||||
require.NoError(t, conn.Handshake())
|
||||
require.Equal(t, wantCert, conn.ConnectionState().PeerCertificates[0].Raw)
|
||||
return conn
|
||||
}
|
||||
oldConn := connect(original)
|
||||
reader := bufio.NewReader(oldConn)
|
||||
checkExistingConnection := func() {
|
||||
t.Helper()
|
||||
require.NoError(t, oldConn.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
// A mismatched Host is handled locally, so no upstream network is needed.
|
||||
_, err := io.WriteString(oldConn, "GET / HTTP/1.1\r\nHost: other.example\r\n\r\n")
|
||||
require.NoError(t, err)
|
||||
resp, err := http.ReadResponse(reader, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusMisdirectedRequest, resp.StatusCode)
|
||||
_, err = io.Copy(io.Discard, resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
}
|
||||
checkExistingConnection()
|
||||
require.NoError(t, manager.ReloadFromFile(true))
|
||||
require.NoError(t, connect(original).NetConn().Close())
|
||||
|
||||
next := writeReloadCertificate(t, crtPath, keyPath)
|
||||
// Both store mutation helpers must leave unchanged proxies' certificates alone.
|
||||
_, err = manager.CreateStoreProxy(result.Proxies[0].Clone())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, connect(original).NetConn().Close())
|
||||
_, err = manager.UpdateStoreProxy("https", result.Proxies[0].Clone())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, connect(original).NetConn().Close())
|
||||
require.NoError(t, manager.DeleteStoreProxy("https"))
|
||||
require.NoError(t, connect(original).NetConn().Close())
|
||||
require.NoError(t, manager.ReloadFromFile(true))
|
||||
require.NoError(t, connect(next).NetConn().Close())
|
||||
checkExistingConnection()
|
||||
|
||||
for _, badFile := range []string{"missing", "invalid"} {
|
||||
saved, err := os.ReadFile(crtPath)
|
||||
require.NoError(t, err)
|
||||
if badFile == "missing" {
|
||||
require.NoError(t, os.Remove(crtPath))
|
||||
} else {
|
||||
require.NoError(t, os.WriteFile(crtPath, []byte("invalid"), 0o600))
|
||||
}
|
||||
require.NoError(t, manager.ReloadFromFile(true), "certificate errors must not fail reload")
|
||||
status, ok := pm.GetProxyStatus("https")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, proxy.ProxyPhaseRunning, status.Phase)
|
||||
require.Contains(t, status.Err, "reload plugin ")
|
||||
require.NoError(t, connect(next).NetConn().Close())
|
||||
checkExistingConnection()
|
||||
require.NoError(t, os.WriteFile(crtPath, saved, 0o600))
|
||||
require.NoError(t, manager.ReloadFromFile(true))
|
||||
status, _ = pm.GetProxyStatus("https")
|
||||
require.Empty(t, status.Err, "restoring the active bytes must clear the reload error")
|
||||
}
|
||||
|
||||
// A config change still replaces the proxy and uses normal startup errors.
|
||||
require.NoError(t, oldConn.NetConn().Close())
|
||||
require.NoError(t, os.WriteFile(crtPath, []byte("invalid"), 0o600))
|
||||
changedConfig := strings.Replace(content, "remotePort = 8443", "remotePort = 8444", 1)
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(changedConfig), 0o600))
|
||||
require.NoError(t, manager.ReloadFromFile(true))
|
||||
select {
|
||||
case name := <-sender.started:
|
||||
require.Equal(t, "https", name)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("replacement proxy did not request startup")
|
||||
}
|
||||
require.Error(t, pm.StartProxy("https", ":8444", ""))
|
||||
status, ok := pm.GetProxyStatus("https")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, proxy.ProxyPhaseStartErr, status.Phase)
|
||||
require.NotEmpty(t, status.Err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileReloadCertificatesDuringControlHandoff(t *testing.T) {
|
||||
for _, pluginType := range []string{v1.PluginHTTPS2HTTP, v1.PluginHTTPS2HTTPS} {
|
||||
t.Run(pluginType, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
|
||||
original := writeReloadCertificate(t, crtPath, keyPath)
|
||||
configPath := filepath.Join(dir, "frpc.toml")
|
||||
require.NoError(t, os.WriteFile(configPath, fmt.Appendf(nil, `[[proxies]]
|
||||
name = "https"
|
||||
type = "tcp"
|
||||
remotePort = 8443
|
||||
[proxies.plugin]
|
||||
type = %q
|
||||
localAddr = "127.0.0.1:80"
|
||||
crtPath = %q
|
||||
keyPath = %q
|
||||
`, pluginType, crtPath, keyPath), 0o600))
|
||||
result, err := config.LoadClientConfigResult(configPath, true)
|
||||
require.NoError(t, err)
|
||||
configSource := source.NewConfigSource()
|
||||
svr := &Service{
|
||||
common: result.Common,
|
||||
configFilePath: configPath,
|
||||
unsafeFeatures: security.NewUnsafeFeatures(nil),
|
||||
aggregator: source.NewAggregator(configSource),
|
||||
configSource: configSource,
|
||||
}
|
||||
require.NoError(t, svr.UpdateConfigSource(result.Common, result.Proxies, nil))
|
||||
sender := &certificateReloadSender{started: make(chan string, 1)}
|
||||
transporter := transport.NewMessageTransporter(sender)
|
||||
newControl := func() *Control {
|
||||
ctx := context.Background()
|
||||
pm := proxy.NewManager(ctx, result.Common, nil, transporter, nil, "")
|
||||
vm := visitor.NewManager(ctx, "", result.Common, nil, transporter, nil)
|
||||
t.Cleanup(pm.Close)
|
||||
t.Cleanup(vm.Close)
|
||||
return &Control{pm: pm, vm: vm}
|
||||
}
|
||||
previous := newControl()
|
||||
previous.pm.Close()
|
||||
svr.ctl = previous
|
||||
next := newControl()
|
||||
|
||||
// Model the handoff transaction: start the new control's proxies while
|
||||
// the published control is still the closed, empty previous manager.
|
||||
svr.reloadMu.Lock()
|
||||
releaseTransaction := sync.OnceFunc(svr.reloadMu.Unlock)
|
||||
defer releaseTransaction()
|
||||
next.pm.UpdateAll(svr.proxyCfgs)
|
||||
select {
|
||||
case name := <-sender.started:
|
||||
require.Equal(t, "https", name)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("replacement proxy did not request startup")
|
||||
}
|
||||
require.NoError(t, next.pm.StartProxy("https", ":8443", ""))
|
||||
checkCertificate := func(want []byte) {
|
||||
t.Helper()
|
||||
clientRaw, serverRaw := net.Pipe()
|
||||
defer clientRaw.Close()
|
||||
defer serverRaw.Close()
|
||||
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
next.pm.HandleWorkConn("https", serverRaw, &msg.StartWorkConn{})
|
||||
conn := tls.Client(clientRaw, &tls.Config{ServerName: "localhost", InsecureSkipVerify: true})
|
||||
require.NoError(t, conn.Handshake())
|
||||
require.Equal(t, want, conn.ConnectionState().PeerCertificates[0].Raw)
|
||||
}
|
||||
checkCertificate(original)
|
||||
renewed := writeReloadCertificate(t, crtPath, keyPath)
|
||||
|
||||
svr.ctlMu.Lock()
|
||||
releaseControl := sync.OnceFunc(svr.ctlMu.Unlock)
|
||||
defer releaseControl()
|
||||
reloadStarted, reloadDone := make(chan struct{}), make(chan struct{})
|
||||
var reloadErr error
|
||||
go func() {
|
||||
close(reloadStarted)
|
||||
reloadErr = (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
|
||||
close(reloadDone)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
releaseTransaction()
|
||||
releaseControl()
|
||||
waitLifecycleSignal(t, reloadDone)
|
||||
})
|
||||
waitLifecycleSignal(t, reloadStarted)
|
||||
svr.ctl = next
|
||||
releaseTransaction()
|
||||
|
||||
// Keep ctlMu as a read barrier after publication. Reload must acquire
|
||||
// reloadMu before selecting ctl, independently of goroutine scheduling.
|
||||
// The deadline only detects a broken lock order; it does not stage the race.
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for svr.reloadMu.TryLock() {
|
||||
svr.reloadMu.Unlock()
|
||||
select {
|
||||
case <-deadline.C:
|
||||
t.Fatal("reload selected ctl without first acquiring reloadMu")
|
||||
default:
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
releaseControl()
|
||||
waitLifecycleSignal(t, reloadDone)
|
||||
require.NoError(t, reloadErr)
|
||||
checkCertificate(renewed)
|
||||
})
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -24,7 +24,7 @@ import (
|
||||
"time"
|
||||
|
||||
libnet "github.com/fatedier/golib/net"
|
||||
fmux "github.com/hashicorp/yamux"
|
||||
fmux "github.com/fatedier/yamux"
|
||||
quic "github.com/quic-go/quic-go"
|
||||
"github.com/samber/lo"
|
||||
|
||||
@@ -109,11 +109,12 @@ func (c *defaultConnectorImpl) Open() error {
|
||||
sn = c.cfg.ServerAddr
|
||||
}
|
||||
if lo.FromPtr(c.cfg.Transport.TLS.Enable) {
|
||||
tlsConfig, err = transport.NewClientTLSConfig(
|
||||
tlsConfig, err = transport.NewClientTLSConfigWithSystemCA(
|
||||
c.cfg.Transport.TLS.CertFile,
|
||||
c.cfg.Transport.TLS.KeyFile,
|
||||
c.cfg.Transport.TLS.TrustedCaFile,
|
||||
sn)
|
||||
sn,
|
||||
c.cfg.Transport.TLS.UseSystemCa)
|
||||
} else {
|
||||
tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn)
|
||||
}
|
||||
@@ -194,11 +195,12 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) {
|
||||
sn = c.cfg.ServerAddr
|
||||
}
|
||||
|
||||
tlsConfig, err = transport.NewClientTLSConfig(
|
||||
tlsConfig, err = transport.NewClientTLSConfigWithSystemCA(
|
||||
c.cfg.Transport.TLS.CertFile,
|
||||
c.cfg.Transport.TLS.KeyFile,
|
||||
c.cfg.Transport.TLS.TrustedCaFile,
|
||||
sn)
|
||||
sn,
|
||||
c.cfg.Transport.TLS.UseSystemCa)
|
||||
if err != nil {
|
||||
xl.Warnf("fail to build tls configuration, err: %v", err)
|
||||
return nil, err
|
||||
|
||||
+59
-22
@@ -30,6 +30,11 @@ import (
|
||||
|
||||
var ErrHealthCheckType = errors.New("error health check type")
|
||||
|
||||
func newHealthTimer(interval time.Duration) (<-chan time.Time, func()) {
|
||||
timer := time.NewTimer(interval)
|
||||
return timer.C, func() { timer.Stop() }
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
checkType string
|
||||
interval time.Duration
|
||||
@@ -49,6 +54,9 @@ type Monitor struct {
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
doneCh chan struct{}
|
||||
|
||||
timerFactory func(time.Duration) (<-chan time.Time, func())
|
||||
}
|
||||
|
||||
func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
|
||||
@@ -91,6 +99,8 @@ func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
|
||||
statusFailedFn: statusFailedFn,
|
||||
ctx: newctx,
|
||||
cancel: cancel,
|
||||
doneCh: make(chan struct{}),
|
||||
timerFactory: newHealthTimer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,39 +112,66 @@ func (monitor *Monitor) Stop() {
|
||||
monitor.cancel()
|
||||
}
|
||||
|
||||
// Done is closed when the worker launched by Start has exited.
|
||||
func (monitor *Monitor) Done() <-chan struct{} {
|
||||
return monitor.doneCh
|
||||
}
|
||||
|
||||
func (monitor *Monitor) checkWorker() {
|
||||
xl := xlog.FromContextSafe(monitor.ctx)
|
||||
defer close(monitor.doneCh)
|
||||
|
||||
for {
|
||||
if monitor.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))
|
||||
err := monitor.doCheck(doCtx)
|
||||
cancel()
|
||||
|
||||
// check if this monitor has been closed
|
||||
select {
|
||||
case <-monitor.ctx.Done():
|
||||
cancel()
|
||||
if monitor.ctx.Err() != nil {
|
||||
return
|
||||
default:
|
||||
cancel()
|
||||
}
|
||||
monitor.handleCheckResult(err)
|
||||
|
||||
if err == nil {
|
||||
xl.Tracef("do one health check success")
|
||||
if !monitor.statusOK && monitor.statusNormalFn != nil {
|
||||
xl.Infof("health check status change to success")
|
||||
monitor.statusOK = true
|
||||
monitor.statusNormalFn()
|
||||
}
|
||||
} else {
|
||||
xl.Warnf("do one health check failed: %v", err)
|
||||
monitor.failedTimes++
|
||||
if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {
|
||||
xl.Warnf("health check status change to failed")
|
||||
monitor.statusOK = false
|
||||
monitor.statusFailedFn()
|
||||
}
|
||||
if !monitor.waitForNextCheck() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(monitor.interval)
|
||||
func (monitor *Monitor) handleCheckResult(err error) {
|
||||
xl := xlog.FromContextSafe(monitor.ctx)
|
||||
if err == nil {
|
||||
xl.Tracef("do one health check success")
|
||||
monitor.failedTimes = 0
|
||||
if !monitor.statusOK && monitor.statusNormalFn != nil {
|
||||
xl.Infof("health check status change to success")
|
||||
monitor.statusOK = true
|
||||
monitor.statusNormalFn()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
xl.Warnf("do one health check failed: %v", err)
|
||||
monitor.failedTimes++
|
||||
if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {
|
||||
xl.Warnf("health check status change to failed")
|
||||
monitor.statusOK = false
|
||||
monitor.statusFailedFn()
|
||||
}
|
||||
}
|
||||
|
||||
func (monitor *Monitor) waitForNextCheck() bool {
|
||||
timerC, stopTimer := monitor.timerFactory(monitor.interval)
|
||||
defer stopTimer()
|
||||
|
||||
select {
|
||||
case <-monitor.ctx.Done():
|
||||
return false
|
||||
case <-timerC:
|
||||
return monitor.ctx.Err() == nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
type tcpHealthBackend struct {
|
||||
listener net.Listener
|
||||
accepted chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newTCPHealthBackend(t *testing.T, addr string, accepted chan struct{}) *tcpHealthBackend {
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
require.NoError(t, err)
|
||||
|
||||
backend := &tcpHealthBackend{
|
||||
listener: listener,
|
||||
accepted: accepted,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
defer close(backend.done)
|
||||
for {
|
||||
conn, err := backend.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
select {
|
||||
case backend.accepted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return backend
|
||||
}
|
||||
|
||||
func (backend *tcpHealthBackend) Close() {
|
||||
_ = backend.listener.Close()
|
||||
<-backend.done
|
||||
}
|
||||
|
||||
func TestMonitorConsecutiveFailureWindows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*testing.T, func(), func()) (*Monitor, func(bool))
|
||||
}{
|
||||
{
|
||||
name: "HTTP",
|
||||
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
|
||||
var healthy atomic.Bool
|
||||
healthy.Store(true)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if !healthy.Load() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
monitor := NewMonitor(
|
||||
context.Background(),
|
||||
v1.HealthCheckConfig{
|
||||
Type: "http",
|
||||
Path: "/health",
|
||||
TimeoutSeconds: 1,
|
||||
IntervalSeconds: 1,
|
||||
MaxFailed: 3,
|
||||
},
|
||||
strings.TrimPrefix(server.URL, "http://"),
|
||||
normalFn,
|
||||
failedFn,
|
||||
)
|
||||
return monitor, healthy.Store
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TCP",
|
||||
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
monitor := NewMonitor(
|
||||
context.Background(),
|
||||
v1.HealthCheckConfig{
|
||||
Type: "tcp",
|
||||
TimeoutSeconds: 1,
|
||||
IntervalSeconds: 1,
|
||||
MaxFailed: 3,
|
||||
},
|
||||
listener.Addr().String(),
|
||||
normalFn,
|
||||
failedFn,
|
||||
)
|
||||
healthyAddr := monitor.addr
|
||||
return monitor, func(healthy bool) {
|
||||
if healthy {
|
||||
monitor.addr = healthyAddr
|
||||
} else {
|
||||
monitor.addr = "127.0.0.1:0"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var events []string
|
||||
monitor, setHealthy := test.setup(
|
||||
t,
|
||||
func() { events = append(events, "normal") },
|
||||
func() { events = append(events, "failed") },
|
||||
)
|
||||
t.Cleanup(monitor.Stop)
|
||||
|
||||
runCheck := func(healthy bool) {
|
||||
t.Helper()
|
||||
setHealthy(healthy)
|
||||
ctx, cancel := context.WithTimeout(monitor.ctx, time.Second)
|
||||
err := monitor.doCheck(ctx)
|
||||
cancel()
|
||||
if healthy {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
}
|
||||
monitor.handleCheckResult(err)
|
||||
}
|
||||
|
||||
runCheck(true)
|
||||
require.True(t, monitor.statusOK)
|
||||
require.Zero(t, monitor.failedTimes)
|
||||
require.Equal(t, []string{"normal"}, events)
|
||||
|
||||
runCheck(false)
|
||||
runCheck(false)
|
||||
require.True(t, monitor.statusOK)
|
||||
require.Equal(t, uint64(2), monitor.failedTimes)
|
||||
require.Equal(t, []string{"normal"}, events)
|
||||
|
||||
runCheck(true)
|
||||
require.True(t, monitor.statusOK)
|
||||
require.Zero(t, monitor.failedTimes)
|
||||
require.Equal(t, []string{"normal"}, events)
|
||||
|
||||
runCheck(false)
|
||||
require.True(t, monitor.statusOK)
|
||||
require.Equal(t, uint64(1), monitor.failedTimes)
|
||||
require.Equal(t, []string{"normal"}, events)
|
||||
|
||||
runCheck(false)
|
||||
require.True(t, monitor.statusOK)
|
||||
require.Equal(t, uint64(2), monitor.failedTimes)
|
||||
|
||||
runCheck(false)
|
||||
require.False(t, monitor.statusOK)
|
||||
require.Equal(t, uint64(3), monitor.failedTimes)
|
||||
require.Equal(t, []string{"normal", "failed"}, events)
|
||||
|
||||
runCheck(true)
|
||||
require.True(t, monitor.statusOK)
|
||||
require.Zero(t, monitor.failedTimes)
|
||||
require.Equal(t, []string{"normal", "failed", "normal"}, events)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type workerStatus struct {
|
||||
failedTimes uint64
|
||||
statusOK bool
|
||||
}
|
||||
|
||||
type workerInterval struct {
|
||||
status workerStatus
|
||||
timer chan time.Time
|
||||
}
|
||||
|
||||
func observeWorkerIntervals(monitor *Monitor) <-chan workerInterval {
|
||||
intervals := make(chan workerInterval, 1)
|
||||
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
|
||||
timer := make(chan time.Time, 1)
|
||||
// The worker takes this snapshot after handleCheckResult. Sending it
|
||||
// publishes the state to the test; no test goroutine reads live fields.
|
||||
interval := workerInterval{
|
||||
status: workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK},
|
||||
timer: timer,
|
||||
}
|
||||
select {
|
||||
case intervals <- interval:
|
||||
case <-monitor.ctx.Done():
|
||||
}
|
||||
return timer, func() {}
|
||||
}
|
||||
return intervals
|
||||
}
|
||||
|
||||
func awaitWorkerInterval(t *testing.T, intervals <-chan workerInterval, want workerStatus) chan time.Time {
|
||||
t.Helper()
|
||||
select {
|
||||
case interval := <-intervals:
|
||||
require.Equal(t, want, interval.status)
|
||||
return interval.timer
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("health worker did not reach the interval barrier")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitorWorker(t *testing.T, monitor *Monitor) {
|
||||
t.Helper()
|
||||
monitor.Stop()
|
||||
select {
|
||||
case <-monitor.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Error("health worker did not exit after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorWorkerProcessesResults(t *testing.T) {
|
||||
requestReady := make(chan struct{}, 1)
|
||||
responses := make(chan int)
|
||||
requestCanceled := make(chan struct{})
|
||||
var cancelOnce sync.Once
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case requestReady <- struct{}{}:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
select {
|
||||
case code := <-responses:
|
||||
w.WriteHeader(code)
|
||||
case <-r.Context().Done():
|
||||
cancelOnce.Do(func() { close(requestCanceled) })
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
events := make(chan string, 3)
|
||||
monitor := NewMonitor(
|
||||
context.Background(),
|
||||
v1.HealthCheckConfig{
|
||||
Type: "http",
|
||||
Path: "/health",
|
||||
TimeoutSeconds: 5,
|
||||
MaxFailed: 3,
|
||||
},
|
||||
strings.TrimPrefix(server.URL, "http://"),
|
||||
func() { events <- "normal" },
|
||||
func() { events <- "failed" },
|
||||
)
|
||||
intervals := observeWorkerIntervals(monitor)
|
||||
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
|
||||
|
||||
awaitRequest := func() {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-requestReady:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("health check request did not start")
|
||||
}
|
||||
}
|
||||
awaitEvent := func(want string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-events:
|
||||
require.Equal(t, want, got)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("health check callback %q was not called", want)
|
||||
}
|
||||
}
|
||||
checkResult := func(code int, want workerStatus, event string) {
|
||||
t.Helper()
|
||||
awaitRequest()
|
||||
select {
|
||||
case responses <- code:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("health check handler did not accept the response")
|
||||
}
|
||||
timer := awaitWorkerInterval(t, intervals, want)
|
||||
if event != "" {
|
||||
awaitEvent(event)
|
||||
}
|
||||
// The worker has finished this result and cannot emit another
|
||||
// callback until the test releases the next check.
|
||||
select {
|
||||
case got := <-events:
|
||||
t.Fatalf("unexpected health check callback %q", got)
|
||||
default:
|
||||
}
|
||||
timer <- time.Now()
|
||||
}
|
||||
|
||||
monitor.Start()
|
||||
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
|
||||
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
|
||||
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
|
||||
checkResult(http.StatusOK, workerStatus{statusOK: true}, "")
|
||||
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
|
||||
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
|
||||
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 3}, "failed")
|
||||
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
|
||||
|
||||
awaitRequest()
|
||||
stopMonitorWorker(t, monitor)
|
||||
select {
|
||||
case <-requestCanceled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("health check request context was not canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorTCPWorkerProcessesResults(t *testing.T) {
|
||||
initialAccepted := make(chan struct{}, 1)
|
||||
initialBackend := newTCPHealthBackend(t, "127.0.0.1:0", initialAccepted)
|
||||
addr := initialBackend.listener.Addr().String()
|
||||
recoveryAccepted := make(chan struct{}, 1)
|
||||
|
||||
normalCallbacks := make(chan workerStatus, 2)
|
||||
failedCallbacks := make(chan workerStatus, 2)
|
||||
var monitor *Monitor
|
||||
monitor = NewMonitor(
|
||||
context.Background(),
|
||||
v1.HealthCheckConfig{
|
||||
Type: "tcp",
|
||||
TimeoutSeconds: 1,
|
||||
MaxFailed: 3,
|
||||
},
|
||||
addr,
|
||||
func() {
|
||||
normalCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
|
||||
},
|
||||
func() {
|
||||
failedCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
|
||||
},
|
||||
)
|
||||
intervals := observeWorkerIntervals(monitor)
|
||||
recoveryBackend := (*tcpHealthBackend)(nil)
|
||||
t.Cleanup(func() {
|
||||
stopMonitorWorker(t, monitor)
|
||||
initialBackend.Close()
|
||||
if recoveryBackend != nil {
|
||||
recoveryBackend.Close()
|
||||
}
|
||||
})
|
||||
|
||||
awaitStatus := func(ch <-chan workerStatus, want workerStatus, message string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-ch:
|
||||
require.Equal(t, want, got)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal(message)
|
||||
}
|
||||
}
|
||||
|
||||
monitor.Start()
|
||||
select {
|
||||
case <-initialAccepted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("TCP health check did not reach the initial backend")
|
||||
}
|
||||
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report the initial success")
|
||||
|
||||
initialTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
|
||||
initialBackend.Close()
|
||||
initialTimer <- time.Now()
|
||||
|
||||
firstFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
|
||||
firstFailureTimer <- time.Now()
|
||||
|
||||
secondFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
|
||||
secondFailureTimer <- time.Now()
|
||||
|
||||
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check")
|
||||
thirdFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 3})
|
||||
|
||||
recoveryBackend = newTCPHealthBackend(t, addr, recoveryAccepted)
|
||||
thirdFailureTimer <- time.Now()
|
||||
select {
|
||||
case <-recoveryAccepted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("TCP health check did not reach the recovery backend")
|
||||
}
|
||||
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report recovery")
|
||||
|
||||
recoveryTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
|
||||
recoveryBackend.Close()
|
||||
recoveryTimer <- time.Now()
|
||||
|
||||
firstRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
|
||||
firstRecoveryFailureTimer <- time.Now()
|
||||
|
||||
secondRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
|
||||
secondRecoveryFailureTimer <- time.Now()
|
||||
|
||||
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check after recovery")
|
||||
stopMonitorWorker(t, monitor)
|
||||
}
|
||||
|
||||
func TestMonitorMaxFailedOne(t *testing.T) {
|
||||
var events []string
|
||||
monitor := NewMonitor(
|
||||
context.Background(),
|
||||
v1.HealthCheckConfig{Type: "tcp", MaxFailed: 1},
|
||||
"",
|
||||
func() { events = append(events, "normal") },
|
||||
func() { events = append(events, "failed") },
|
||||
)
|
||||
t.Cleanup(monitor.Stop)
|
||||
|
||||
checkErr := errors.New("health check failed")
|
||||
monitor.handleCheckResult(nil)
|
||||
monitor.handleCheckResult(checkErr)
|
||||
require.Equal(t, []string{"normal", "failed"}, events)
|
||||
require.False(t, monitor.statusOK)
|
||||
require.Equal(t, uint64(1), monitor.failedTimes)
|
||||
|
||||
monitor.handleCheckResult(checkErr)
|
||||
require.Equal(t, []string{"normal", "failed"}, events)
|
||||
|
||||
monitor.handleCheckResult(nil)
|
||||
monitor.handleCheckResult(checkErr)
|
||||
require.Equal(t, []string{"normal", "failed", "normal", "failed"}, events)
|
||||
}
|
||||
|
||||
func TestMonitorStopCancelsWork(t *testing.T) {
|
||||
t.Run("in-flight check", func(t *testing.T) {
|
||||
requestStarted := make(chan struct{})
|
||||
requestCanceled := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
close(requestStarted)
|
||||
<-r.Context().Done()
|
||||
close(requestCanceled)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
monitor := NewMonitor(
|
||||
context.Background(),
|
||||
v1.HealthCheckConfig{Type: "http", Path: "/health"},
|
||||
strings.TrimPrefix(server.URL, "http://"),
|
||||
func() {},
|
||||
func() {},
|
||||
)
|
||||
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
|
||||
monitor.Start()
|
||||
|
||||
select {
|
||||
case <-requestStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("health check request did not start")
|
||||
}
|
||||
stopMonitorWorker(t, monitor)
|
||||
select {
|
||||
case <-requestCanceled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("health check request context was not canceled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interval wait", func(t *testing.T) {
|
||||
monitor := NewMonitor(context.Background(), v1.HealthCheckConfig{Type: "tcp"}, "", nil, nil)
|
||||
intervals := observeWorkerIntervals(monitor)
|
||||
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
|
||||
monitor.Start()
|
||||
|
||||
// A real worker has completed its first check and installed a timer
|
||||
// that will never fire. Stop must release the wait and exit the loop.
|
||||
awaitWorkerInterval(t, intervals, workerStatus{})
|
||||
stopMonitorWorker(t, monitor)
|
||||
})
|
||||
}
|
||||
|
||||
type gatedErrContext struct {
|
||||
context.Context
|
||||
errCalled chan struct{}
|
||||
releaseErr chan struct{}
|
||||
}
|
||||
|
||||
func (ctx *gatedErrContext) Err() error {
|
||||
select {
|
||||
case ctx.errCalled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-ctx.releaseErr
|
||||
return ctx.Context.Err()
|
||||
}
|
||||
|
||||
func TestMonitorTimerCancellationAfterTimerFires(t *testing.T) {
|
||||
baseCtx, cancel := context.WithCancel(context.Background())
|
||||
ctx := &gatedErrContext{
|
||||
Context: baseCtx,
|
||||
errCalled: make(chan struct{}, 1),
|
||||
releaseErr: make(chan struct{}),
|
||||
}
|
||||
monitor := NewMonitor(
|
||||
context.Background(),
|
||||
v1.HealthCheckConfig{Type: "tcp"},
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
monitor.ctx = ctx
|
||||
monitor.cancel = cancel
|
||||
|
||||
timerReady := make(chan time.Time, 1)
|
||||
timerReady <- time.Now()
|
||||
var timerStopped atomic.Bool
|
||||
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
|
||||
return timerReady, func() { timerStopped.Store(true) }
|
||||
}
|
||||
|
||||
waitResult := make(chan bool, 1)
|
||||
go func() {
|
||||
waitResult <- monitor.waitForNextCheck()
|
||||
}()
|
||||
|
||||
// ctx.Done is not ready when select runs, so receiving timerReady is the
|
||||
// only possible branch. Err signals after that receive and blocks until the
|
||||
// test cancels the context, deterministically exercising the cancellation
|
||||
// re-check in the timer branch.
|
||||
select {
|
||||
case <-ctx.errCalled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timer branch did not re-check the monitor context")
|
||||
}
|
||||
cancel()
|
||||
close(ctx.releaseErr)
|
||||
|
||||
select {
|
||||
case shouldContinue := <-waitResult:
|
||||
require.False(t, shouldContinue)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timer wait did not observe context cancellation")
|
||||
}
|
||||
require.True(t, timerStopped.Load())
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.Pro
|
||||
}
|
||||
psr.Plugin = baseCfg.Plugin.Type
|
||||
|
||||
if status.Err == "" {
|
||||
if status.Err == "" || status.Phase == proxy.ProxyPhaseRunning {
|
||||
psr.RemoteAddr = status.RemoteAddr
|
||||
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
|
||||
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
|
||||
|
||||
@@ -213,6 +213,35 @@ func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyStatusRespRunningWithErrorKeepsRemoteAddr(t *testing.T) {
|
||||
const certificateErr = "reload plugin https2http: invalid certificate"
|
||||
controller := &Controller{serverAddr: "127.0.0.1", manager: &fakeConfigManager{}}
|
||||
for _, tc := range []struct {
|
||||
name, phase, err, remoteAddr string
|
||||
}{
|
||||
{"running", proxy.ProxyPhaseRunning, "", "127.0.0.1:43210"},
|
||||
{"running with error", proxy.ProxyPhaseRunning, certificateErr, "127.0.0.1:43210"},
|
||||
{"start error", proxy.ProxyPhaseStartErr, "start failed", ""},
|
||||
{"closed with error", proxy.ProxyPhaseClosed, certificateErr, ""},
|
||||
{"closed without error", proxy.ProxyPhaseClosed, "", "127.0.0.1:43210"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
status := &proxy.WorkingStatus{
|
||||
Name: "https", Type: "tcp", Phase: tc.phase, Err: tc.err,
|
||||
RemoteAddr: ":43210",
|
||||
Cfg: newRawTCPProxyConfig("https"), // RemotePort remains zero (allocated by frps).
|
||||
}
|
||||
resp := controller.buildProxyStatusResp(status)
|
||||
if resp.RemoteAddr != tc.remoteAddr {
|
||||
t.Fatalf("unexpected remote addr: got %q, want %q", resp.RemoteAddr, tc.remoteAddr)
|
||||
}
|
||||
if resp.Err != tc.err || resp.Status != tc.phase {
|
||||
t.Fatalf("status or error changed: %+v", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadErrorMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestNewServiceReleasesWebServerOnMetricsServerFailure(t *testing.T) {
|
||||
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
cfg := &v1.ClientCommonConfig{
|
||||
WebServer: v1.WebServerConfig{Port: getFreeTCPPort(t)},
|
||||
MetricsServer: v1.MetricsServerConfig{Port: ln.Addr().(*net.TCPAddr).Port},
|
||||
}
|
||||
if mode == "invalid TLS certificate" {
|
||||
cfg.MetricsServer.TLS = &v1.TLSConfig{
|
||||
CertFile: filepath.Join(t.TempDir(), "missing.crt"), KeyFile: "missing.key",
|
||||
}
|
||||
require.NoError(t, ln.Close())
|
||||
}
|
||||
svr, err := NewService(ServiceOptions{
|
||||
Common: cfg, ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
|
||||
})
|
||||
if svr != nil {
|
||||
svr.Close()
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Nil(t, svr)
|
||||
assertLifecycleAdminPort(t, net.JoinHostPort(cfg.WebServer.Addr, strconv.Itoa(cfg.WebServer.Port)), true)
|
||||
if mode == "invalid TLS certificate" {
|
||||
assertLifecycleAdminPort(t, ln.Addr().String(), true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMetricsServerLifecycle(t *testing.T) {
|
||||
svr, err := NewService(ServiceOptions{
|
||||
Common: &v1.ClientCommonConfig{
|
||||
LoginFailExit: new(false), MetricsServer: v1.MetricsServerConfig{Port: getFreeTCPPort(t)},
|
||||
},
|
||||
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
|
||||
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||
return &failingConnector{err: net.ErrClosed}
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, svr.webServer)
|
||||
address := svr.metricsServer.Address()
|
||||
done := make(chan struct{})
|
||||
go func() { _ = svr.Run(context.Background()); close(done) }()
|
||||
defer func() {
|
||||
svr.Close()
|
||||
waitLifecycleSignal(t, done)
|
||||
assertLifecycleAdminPort(t, address, true)
|
||||
}()
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get("http://" + address + "/healthz")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
// frpc serves Go runtime metrics only, and no credentials are configured
|
||||
// here, so this endpoint is reachable without authentication.
|
||||
resp, err = client.Get("http://" + address + "/metrics")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Contains(t, string(body), "go_goroutines")
|
||||
require.NotContains(t, string(body), "frp_")
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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 proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
plugin "github.com/fatedier/frp/pkg/plugin/client"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
)
|
||||
|
||||
type reloadError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type testReloadPlugin struct {
|
||||
calls atomic.Int32
|
||||
state atomic.Pointer[reloadError]
|
||||
reload func() error
|
||||
}
|
||||
|
||||
func (p *testReloadPlugin) Name() string { return "test" }
|
||||
func (p *testReloadPlugin) Handle(context.Context, *plugin.ConnectionInfo) {}
|
||||
func (p *testReloadPlugin) Close() error { return nil }
|
||||
|
||||
func (p *testReloadPlugin) Reload() error {
|
||||
p.calls.Add(1)
|
||||
err := p.reload()
|
||||
p.state.Store(&reloadError{err: err})
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *testReloadPlugin) ReloadError() error {
|
||||
if s := p.state.Load(); s != nil {
|
||||
return s.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadFixture(p *testReloadPlugin) (*Manager, *Wrapper, *BaseProxy) {
|
||||
base := &BaseProxy{proxyPlugin: p}
|
||||
pw := &Wrapper{
|
||||
WorkingStatus: WorkingStatus{Name: "same", Phase: ProxyPhaseRunning},
|
||||
pxy: &GeneralTCPProxy{BaseProxy: base},
|
||||
xl: xlog.New(),
|
||||
}
|
||||
return &Manager{proxies: map[string]*Wrapper{"same": pw}}, pw, base
|
||||
}
|
||||
|
||||
func TestPluginSnapshotSkipsReplacedAndStopped(t *testing.T) {
|
||||
p := &testReloadPlugin{reload: func() error { return nil }}
|
||||
pm, pw, base := reloadFixture(p)
|
||||
run := pm.PreparePluginReload()
|
||||
q := &testReloadPlugin{reload: func() error { return nil }}
|
||||
pw.mu.Lock()
|
||||
base.proxyPlugin = q
|
||||
pw.mu.Unlock()
|
||||
run()
|
||||
require.Zero(t, p.calls.Load())
|
||||
require.Zero(t, q.calls.Load())
|
||||
run = pm.PreparePluginReload()
|
||||
pw.mu.Lock()
|
||||
pw.Phase = ProxyPhaseClosed
|
||||
pw.mu.Unlock()
|
||||
run()
|
||||
require.Zero(t, q.calls.Load())
|
||||
}
|
||||
|
||||
func TestPluginErrorOwnershipAndLockIsolation(t *testing.T) {
|
||||
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
p := &testReloadPlugin{reload: func() error {
|
||||
close(started)
|
||||
<-release
|
||||
return errors.New("bad certificate")
|
||||
}}
|
||||
pm, pw, base := reloadFixture(p)
|
||||
other := &testReloadPlugin{reload: func() error { return nil }}
|
||||
pm2, _, _ := reloadFixture(other)
|
||||
run := pm.PreparePluginReload()
|
||||
releaseReload := sync.OnceFunc(func() { close(release) })
|
||||
t.Cleanup(func() {
|
||||
releaseReload()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("reload did not finish")
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
run()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("not started")
|
||||
}
|
||||
require.True(t, pm.mu.TryLock())
|
||||
pm.mu.Unlock()
|
||||
require.True(t, pw.mu.TryLock())
|
||||
pw.mu.Unlock()
|
||||
require.Empty(t, pw.GetStatus().Err)
|
||||
require.Zero(t, other.calls.Load(), "same name in another manager must be isolated")
|
||||
pm2.PreparePluginReload()()
|
||||
require.EqualValues(t, 1, other.calls.Load())
|
||||
q := &testReloadPlugin{reload: func() error { return nil }}
|
||||
pw.mu.Lock()
|
||||
base.proxyPlugin = q
|
||||
pw.mu.Unlock()
|
||||
releaseReload()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("not done")
|
||||
}
|
||||
require.Error(t, p.ReloadError())
|
||||
require.Empty(t, pw.GetStatus().Err, "retired plugin errors must not attach to its replacement")
|
||||
q.state.Store(&reloadError{err: errors.New("new plugin error")})
|
||||
require.Contains(t, pw.GetStatus().Err, "new plugin error")
|
||||
require.Empty(t, pw.Err, "plugin state must not overwrite lifecycle errors")
|
||||
pw.mu.Lock()
|
||||
pw.Err = "lifecycle"
|
||||
pw.mu.Unlock()
|
||||
require.Contains(t, pw.GetStatus().Err, "lifecycle; reload plugin")
|
||||
require.NoError(t, q.Reload())
|
||||
require.Equal(t, "lifecycle", pw.GetStatus().Err)
|
||||
}
|
||||
@@ -120,6 +120,9 @@ func (pxy *BaseProxy) Run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPlugin is accessed while the owning Wrapper holds its lifecycle lock.
|
||||
func (pxy *BaseProxy) getPlugin() plugin.Plugin { return pxy.proxyPlugin }
|
||||
|
||||
func (pxy *BaseProxy) Close() {
|
||||
if pxy.proxyPlugin != nil {
|
||||
pxy.proxyPlugin.Close()
|
||||
|
||||
@@ -139,6 +139,41 @@ func (pm *Manager) GetProxyStatus(name string) (*WorkingStatus, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// PreparePluginReload snapshots live plugin instances before applying config.
|
||||
// Invoke the returned function only after config application succeeds and all
|
||||
// configuration locks have been released. Replaced instances are skipped.
|
||||
func (pm *Manager) PreparePluginReload() func() {
|
||||
type target struct {
|
||||
pw *Wrapper
|
||||
p reloadablePlugin
|
||||
}
|
||||
pm.mu.RLock()
|
||||
targets := make([]target, 0, len(pm.proxies))
|
||||
for _, pw := range pm.proxies {
|
||||
pw.mu.RLock()
|
||||
if pw.Phase == ProxyPhaseRunning {
|
||||
if p := pw.reloadablePluginLocked(); p != nil {
|
||||
targets = append(targets, target{pw: pw, p: p})
|
||||
}
|
||||
}
|
||||
pw.mu.RUnlock()
|
||||
}
|
||||
pm.mu.RUnlock()
|
||||
return func() {
|
||||
for _, t := range targets {
|
||||
t.pw.mu.RLock()
|
||||
current := t.pw.Phase == ProxyPhaseRunning && t.pw.reloadablePluginLocked() == t.p
|
||||
t.pw.mu.RUnlock()
|
||||
if !current {
|
||||
continue
|
||||
}
|
||||
if err := t.p.Reload(); err != nil {
|
||||
t.pw.xl.Warnf("reload plugin %s: %v", t.p.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
||||
xl := xlog.FromContextSafe(pm.ctx)
|
||||
proxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/naming"
|
||||
plugin "github.com/fatedier/frp/pkg/plugin/client"
|
||||
"github.com/fatedier/frp/pkg/transport"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
"github.com/fatedier/frp/pkg/vnet"
|
||||
@@ -267,8 +268,9 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
|
||||
xl := pw.xl
|
||||
pw.mu.RLock()
|
||||
pxy := pw.pxy
|
||||
running := pw.Phase == ProxyPhaseRunning
|
||||
pw.mu.RUnlock()
|
||||
if pxy != nil && pw.Phase == ProxyPhaseRunning {
|
||||
if pxy != nil && running {
|
||||
xl.Debugf("start a new work connection, localAddr: %s remoteAddr: %s", workConn.LocalAddr().String(), workConn.RemoteAddr().String())
|
||||
go pxy.InWorkConn(workConn, m)
|
||||
} else {
|
||||
@@ -276,6 +278,25 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
// reloadablePlugin owns its reload serialization and error snapshot. ReloadError
|
||||
// must not block on I/O. Reload must be safe during Close, and retain usable
|
||||
// resources on failure. Existing plugins need not implement this capability.
|
||||
type reloadablePlugin interface {
|
||||
plugin.Plugin
|
||||
Reload() error
|
||||
ReloadError() error
|
||||
}
|
||||
|
||||
// Caller holds pw.mu. Concrete reloadable plugins are pointer instances.
|
||||
func (pw *Wrapper) reloadablePluginLocked() reloadablePlugin {
|
||||
owner, ok := pw.pxy.(interface{ getPlugin() plugin.Plugin })
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
p, _ := owner.getPlugin().(reloadablePlugin)
|
||||
return p
|
||||
}
|
||||
|
||||
func (pw *Wrapper) GetStatus() *WorkingStatus {
|
||||
pw.mu.RLock()
|
||||
defer pw.mu.RUnlock()
|
||||
@@ -287,5 +308,15 @@ func (pw *Wrapper) GetStatus() *WorkingStatus {
|
||||
Cfg: pw.Cfg,
|
||||
RemoteAddr: pw.RemoteAddr,
|
||||
}
|
||||
if pw.Phase == ProxyPhaseRunning {
|
||||
if p := pw.reloadablePluginLocked(); p != nil {
|
||||
if err := p.ReloadError(); err != nil {
|
||||
if ps.Err != "" {
|
||||
ps.Err += "; "
|
||||
}
|
||||
ps.Err += fmt.Sprintf("reload plugin %s: %v", p.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build !frps
|
||||
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
)
|
||||
|
||||
type closingWorkProxy struct {
|
||||
Proxy
|
||||
accepted atomic.Int64
|
||||
}
|
||||
|
||||
func (p *closingWorkProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
p.accepted.Add(1)
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func assertWrapperWorkConnClosed(t *testing.T, pw *Wrapper) {
|
||||
t.Helper()
|
||||
conn, peer := net.Pipe()
|
||||
defer conn.Close()
|
||||
defer peer.Close()
|
||||
require.NoError(t, peer.SetReadDeadline(time.Now().Add(time.Second)))
|
||||
pw.InWorkConn(conn, &msg.StartWorkConn{})
|
||||
_, err := peer.Read(make([]byte, 1))
|
||||
require.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
|
||||
func TestWrapperInWorkConnPhaseSnapshot(t *testing.T) {
|
||||
pxy := &closingWorkProxy{}
|
||||
pw := &Wrapper{pxy: pxy, xl: xlog.New()}
|
||||
for _, phase := range []string{ProxyPhaseNew, ProxyPhaseWaitStart, ProxyPhaseStartErr, ProxyPhaseCheckFailed, ProxyPhaseClosed} {
|
||||
pw.Phase = phase
|
||||
assertWrapperWorkConnClosed(t, pw)
|
||||
}
|
||||
require.Zero(t, pxy.accepted.Load(), "inactive proxy received a work connection")
|
||||
pw.Phase = ProxyPhaseRunning
|
||||
pw.pxy = nil
|
||||
assertWrapperWorkConnClosed(t, pw)
|
||||
pw.pxy = pxy
|
||||
assertWrapperWorkConnClosed(t, pw)
|
||||
require.Equal(t, int64(1), pxy.accepted.Load())
|
||||
|
||||
start, done := make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
<-start
|
||||
for range 20000 {
|
||||
pw.mu.Lock()
|
||||
pw.Phase = ProxyPhaseRunning
|
||||
pw.mu.Unlock()
|
||||
pw.mu.Lock()
|
||||
pw.Phase = ProxyPhaseClosed
|
||||
pw.mu.Unlock()
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
close(start)
|
||||
for range 10000 {
|
||||
assertWrapperWorkConnClosed(t, pw)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("phase writer did not finish")
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -108,8 +108,10 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
|
||||
pxy.mu.Lock()
|
||||
pxy.workConn = workConn
|
||||
pxy.readCh = make(chan *msg.UDPPacket, 1024)
|
||||
pxy.sendCh = make(chan msg.Message, 1024)
|
||||
readCh := make(chan *msg.UDPPacket, 1024)
|
||||
sendCh := make(chan msg.Message, 1024)
|
||||
pxy.readCh = readCh
|
||||
pxy.sendCh = sendCh
|
||||
pxy.closed = false
|
||||
pxy.mu.Unlock()
|
||||
|
||||
@@ -160,10 +162,10 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnSenderFn(payloadRW, pxy.sendCh)
|
||||
go workConnReaderFn(payloadRW, pxy.readCh)
|
||||
go heartbeatFn(pxy.sendCh)
|
||||
go workConnSenderFn(payloadRW, sendCh)
|
||||
go workConnReaderFn(payloadRW, readCh)
|
||||
go heartbeatFn(sendCh)
|
||||
|
||||
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
||||
udp.Forwarder(pxy.localAddr, pxy.readCh, pxy.sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
||||
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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 proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
)
|
||||
|
||||
func TestUDPProxyInWorkConnGenerationChannels(t *testing.T) {
|
||||
cfg := &v1.UDPProxyConfig{ProxyBaseConfig: v1.ProxyBaseConfig{
|
||||
Name: "udp", Type: "udp",
|
||||
ProxyBackend: v1.ProxyBackend{LocalIP: "127.0.0.1", LocalPort: 9},
|
||||
}}
|
||||
pxy := NewUDPProxy(&BaseProxy{
|
||||
baseCfg: &cfg.ProxyBaseConfig, clientCfg: &v1.ClientCommonConfig{UDPPacketSize: 1500},
|
||||
xl: xlog.New(), ctx: context.Background(),
|
||||
}, cfg).(*UDPProxy)
|
||||
require.NoError(t, pxy.Run())
|
||||
defer pxy.Close()
|
||||
|
||||
conn1, peer1 := net.Pipe()
|
||||
defer conn1.Close()
|
||||
defer peer1.Close()
|
||||
conn2, peer2 := net.Pipe()
|
||||
defer conn2.Close()
|
||||
defer peer2.Close()
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
inWorkConn := func(conn net.Conn) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
pxy.InWorkConn(conn, &msg.StartWorkConn{})
|
||||
}
|
||||
go inWorkConn(conn1)
|
||||
go inWorkConn(conn2)
|
||||
close(start)
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
fmux "github.com/hashicorp/yamux"
|
||||
fmux "github.com/fatedier/yamux"
|
||||
"github.com/quic-go/quic-go"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
|
||||
+113
-19
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||
"github.com/fatedier/frp/pkg/metrics/text"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
@@ -123,13 +124,14 @@ type Service struct {
|
||||
auth *auth.ClientAuth
|
||||
|
||||
// web server for admin UI and apis
|
||||
webServer *httppkg.Server
|
||||
webServer *httppkg.Server
|
||||
metricsServer *httppkg.Server
|
||||
|
||||
vnetController *vnet.Controller
|
||||
|
||||
cfgMu sync.RWMutex
|
||||
// reloadMu serializes reload transactions to keep reloadCommon and applied
|
||||
// config in sync across concurrent API operations.
|
||||
// reloadMu serializes config updates with control startup and publication,
|
||||
// keeping reloadCommon and applied config in sync across reloads and reconnects.
|
||||
reloadMu sync.Mutex
|
||||
common *v1.ClientCommonConfig
|
||||
// reloadCommon is used for filtering/defaulting during config-source reloads.
|
||||
@@ -155,6 +157,9 @@ type Service struct {
|
||||
ctx context.Context
|
||||
// call cancel to stop service
|
||||
cancel context.CancelCauseFunc
|
||||
// Protect cancel publication and shutdown before the first Run.
|
||||
lifecycleMu sync.Mutex
|
||||
closed bool
|
||||
|
||||
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
|
||||
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
|
||||
@@ -185,8 +190,8 @@ func NewService(options ServiceOptions) (*Service, error) {
|
||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
||||
|
||||
// Create the web server after all fallible steps so its listener is not
|
||||
// leaked when an earlier error causes NewService to return.
|
||||
// Create HTTP servers after all other fallible steps, rolling back the web
|
||||
// listener if the metrics server cannot be created.
|
||||
var webServer *httppkg.Server
|
||||
if options.Common.WebServer.Port > 0 {
|
||||
ws, err := httppkg.NewServer(options.Common.WebServer)
|
||||
@@ -195,11 +200,27 @@ func NewService(options ServiceOptions) (*Service, error) {
|
||||
}
|
||||
webServer = ws
|
||||
}
|
||||
// frpc exposes Go runtime metrics only. They are written by pkg/metrics/text
|
||||
// so that client_golang and protobuf stay out of the frpc binary.
|
||||
var metricsHandler http.Handler
|
||||
if options.Common.MetricsServer.Port > 0 {
|
||||
registry := text.NewRegistry()
|
||||
registry.RegisterRuntime()
|
||||
metricsHandler = registry.Handler()
|
||||
}
|
||||
metricsServer, err := httppkg.NewMetricsServer(options.Common.MetricsServer, metricsHandler)
|
||||
if err != nil {
|
||||
if webServer != nil {
|
||||
_ = webServer.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Service{
|
||||
ctx: context.Background(),
|
||||
auth: authRuntime,
|
||||
webServer: webServer,
|
||||
metricsServer: metricsServer,
|
||||
common: options.Common,
|
||||
reloadCommon: options.Common,
|
||||
configFilePath: options.ConfigFilePath,
|
||||
@@ -225,8 +246,20 @@ func NewService(options ServiceOptions) (*Service, error) {
|
||||
|
||||
func (svr *Service) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
svr.lifecycleMu.Lock()
|
||||
if svr.closed {
|
||||
svr.lifecycleMu.Unlock()
|
||||
cancel(nil)
|
||||
return ctx.Err()
|
||||
}
|
||||
svr.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx))
|
||||
svr.cancel = cancel
|
||||
svr.lifecycleMu.Unlock()
|
||||
defer cancel(nil)
|
||||
defer svr.stop()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set custom DNSServer
|
||||
if svr.common.DNSServer != "" {
|
||||
@@ -237,7 +270,6 @@ func (svr *Service) Run(ctx context.Context) error {
|
||||
vnetController := svr.vnetController
|
||||
if err := svr.vnetController.Init(); err != nil {
|
||||
log.Errorf("init virtual network controller error: %v", err)
|
||||
svr.stop()
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
@@ -249,6 +281,12 @@ func (svr *Service) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if svr.webServer != nil {
|
||||
if svr.common.WebServer.PprofEnable {
|
||||
log.Warnf("webServer pprof endpoints are deprecated; use metricsServer.pprofEnable instead")
|
||||
}
|
||||
if svr.common.MetricsServer.Port == 0 {
|
||||
log.Infof("webServer /healthz is deprecated; health probes can migrate to metricsServer.port")
|
||||
}
|
||||
webServer := svr.webServer
|
||||
go func() {
|
||||
log.Infof("admin server listen on %s", webServer.Address())
|
||||
@@ -258,24 +296,40 @@ func (svr *Service) Run(ctx context.Context) error {
|
||||
}()
|
||||
}
|
||||
|
||||
if svr.metricsServer != nil {
|
||||
metricsServer := svr.metricsServer
|
||||
go func() {
|
||||
log.Infof("metrics server listen on %s", metricsServer.Address())
|
||||
if err := metricsServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Warnf("metrics server exit with error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// first login to frps
|
||||
svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit))
|
||||
if svr.ctl == nil {
|
||||
cancelCause := cancelErr{}
|
||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
||||
svr.stop()
|
||||
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
||||
}
|
||||
|
||||
go svr.keepControllerWorking()
|
||||
|
||||
<-svr.ctx.Done()
|
||||
svr.stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svr *Service) keepControllerWorking() {
|
||||
<-svr.ctl.Done()
|
||||
// ctl is swapped under ctlMu in loopLoginUntilSuccess / stop; take a
|
||||
// snapshot before waiting so stop's nil write does not race this read.
|
||||
svr.ctlMu.RLock()
|
||||
ctl := svr.ctl
|
||||
svr.ctlMu.RUnlock()
|
||||
if ctl == nil {
|
||||
return
|
||||
}
|
||||
<-ctl.Done()
|
||||
|
||||
// There is a situation where the login is successful but due to certain reasons,
|
||||
// the control immediately exits. It is necessary to limit the frequency of reconnection in this case.
|
||||
@@ -285,8 +339,11 @@ func (svr *Service) keepControllerWorking() {
|
||||
// loopLoginUntilSuccess is another layer of loop that will continuously attempt to
|
||||
// login to the server until successful.
|
||||
svr.loopLoginUntilSuccess(20*time.Second, false)
|
||||
if svr.ctl != nil {
|
||||
<-svr.ctl.Done()
|
||||
svr.ctlMu.RLock()
|
||||
ctl := svr.ctl
|
||||
svr.ctlMu.RUnlock()
|
||||
if ctl != nil {
|
||||
<-ctl.Done()
|
||||
return false, errors.New("control is closed and try another loop")
|
||||
}
|
||||
// If the control is nil, it means that the login failed and the service is also closed.
|
||||
@@ -331,11 +388,6 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
||||
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
|
||||
xl.Infof("login to server success, get run id [%s]", svr.runID)
|
||||
|
||||
svr.cfgMu.RLock()
|
||||
proxyCfgs := svr.proxyCfgs
|
||||
visitorCfgs := svr.visitorCfgs
|
||||
svr.cfgMu.RUnlock()
|
||||
|
||||
ctl, err := NewControl(svr.ctx, sessionCtx)
|
||||
if err != nil {
|
||||
sessionCtx.Conn.Close()
|
||||
@@ -345,6 +397,15 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
||||
}
|
||||
ctl.SetInWorkConnCallback(svr.handleWorkConnCb)
|
||||
|
||||
// An update must either precede this snapshot or apply to the published
|
||||
// control. Keep dialing and login outside the reload transaction.
|
||||
svr.reloadMu.Lock()
|
||||
defer svr.reloadMu.Unlock()
|
||||
svr.cfgMu.RLock()
|
||||
proxyCfgs := svr.proxyCfgs
|
||||
visitorCfgs := svr.visitorCfgs
|
||||
svr.cfgMu.RUnlock()
|
||||
|
||||
ctl.Run(proxyCfgs, visitorCfgs)
|
||||
// close and replace previous control
|
||||
svr.ctlMu.Lock()
|
||||
@@ -367,6 +428,13 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
||||
}
|
||||
|
||||
func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {
|
||||
svr.reloadMu.Lock()
|
||||
defer svr.reloadMu.Unlock()
|
||||
return svr.updateAllConfigurerLocked(proxyCfgs, visitorCfgs)
|
||||
}
|
||||
|
||||
// Caller must hold reloadMu.
|
||||
func (svr *Service) updateAllConfigurerLocked(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {
|
||||
svr.cfgMu.Lock()
|
||||
svr.proxyCfgs = proxyCfgs
|
||||
svr.visitorCfgs = visitorCfgs
|
||||
@@ -377,7 +445,7 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
|
||||
svr.ctlMu.RUnlock()
|
||||
|
||||
if ctl != nil {
|
||||
return svr.ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
|
||||
return ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -390,6 +458,15 @@ func (svr *Service) UpdateConfigSource(
|
||||
svr.reloadMu.Lock()
|
||||
defer svr.reloadMu.Unlock()
|
||||
|
||||
return svr.updateConfigSourceLocked(common, proxyCfgs, visitorCfgs)
|
||||
}
|
||||
|
||||
// Caller must hold reloadMu.
|
||||
func (svr *Service) updateConfigSourceLocked(
|
||||
common *v1.ClientCommonConfig,
|
||||
proxyCfgs []v1.ProxyConfigurer,
|
||||
visitorCfgs []v1.VisitorConfigurer,
|
||||
) error {
|
||||
cfgSource := svr.configSource
|
||||
if cfgSource == nil {
|
||||
return fmt.Errorf("config source is not available")
|
||||
@@ -417,7 +494,20 @@ func (svr *Service) Close() {
|
||||
|
||||
func (svr *Service) GracefulClose(d time.Duration) {
|
||||
svr.gracefulShutdownDuration.Store(int64(d))
|
||||
svr.cancel(nil)
|
||||
svr.lifecycleMu.Lock()
|
||||
if svr.closed {
|
||||
svr.lifecycleMu.Unlock()
|
||||
return
|
||||
}
|
||||
svr.closed = true
|
||||
cancel := svr.cancel
|
||||
svr.lifecycleMu.Unlock()
|
||||
if cancel != nil {
|
||||
// Once Run has claimed startup, it also owns cleanup.
|
||||
cancel(nil)
|
||||
} else {
|
||||
svr.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (svr *Service) stop() {
|
||||
@@ -441,6 +531,10 @@ func (svr *Service) stop() {
|
||||
svr.webServer.Close()
|
||||
svr.webServer = nil
|
||||
}
|
||||
if svr.metricsServer != nil {
|
||||
svr.metricsServer.Close()
|
||||
svr.metricsServer = nil
|
||||
}
|
||||
if svr.vnetController != nil {
|
||||
_ = svr.vnetController.Stop()
|
||||
svr.vnetController = nil
|
||||
@@ -520,7 +614,7 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
|
||||
}
|
||||
|
||||
// Atomically replace the entire configuration
|
||||
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
|
||||
if err := svr.updateAllConfigurerLocked(proxies, visitors); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
|
||||
func newLifecycleTestService(t *testing.T, creator func(context.Context, *v1.ClientCommonConfig) Connector) (*Service, string) {
|
||||
t.Helper()
|
||||
svr, err := NewService(ServiceOptions{
|
||||
Common: &v1.ClientCommonConfig{
|
||||
LoginFailExit: new(false),
|
||||
WebServer: v1.WebServerConfig{Addr: "127.0.0.1", Port: getFreeTCPPort(t)},
|
||||
},
|
||||
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
|
||||
ConnectorCreator: creator,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = validation.NewConfigValidator(nil).ValidateClientCommonConfig(svr.common)
|
||||
require.NoError(t, err)
|
||||
address := svr.webServer.Address()
|
||||
assertLifecycleAdminPort(t, address, false)
|
||||
return svr, address
|
||||
}
|
||||
|
||||
func assertLifecycleAdminPort(t *testing.T, address string, released bool) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", address)
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
if released {
|
||||
require.NoError(t, err, "admin listener was not released")
|
||||
} else {
|
||||
require.Error(t, err, "admin listener was not bound")
|
||||
}
|
||||
}
|
||||
|
||||
func waitLifecycleSignal(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("service lifecycle operation did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRunAfterEarlyShutdown(t *testing.T) {
|
||||
for _, mode := range []string{"close before Run", "parent already cancelled"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
var attempts atomic.Int64
|
||||
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||
attempts.Add(1)
|
||||
return &failingConnector{err: net.ErrClosed}
|
||||
})
|
||||
// Detect startup's global DNS side effect without making DNS requests.
|
||||
resolver := net.DefaultResolver
|
||||
t.Cleanup(func() { net.DefaultResolver = resolver })
|
||||
svr.common.DNSServer = "127.0.0.1:53"
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if mode == "close before Run" {
|
||||
svr.Close()
|
||||
assertLifecycleAdminPort(t, address, true)
|
||||
} else {
|
||||
cancel()
|
||||
}
|
||||
done := make(chan struct{})
|
||||
var runErr error
|
||||
go func() {
|
||||
runErr = svr.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
waitLifecycleSignal(t, done)
|
||||
require.ErrorIs(t, runErr, context.Canceled)
|
||||
require.Zero(t, attempts.Load(), "closed service attempted login")
|
||||
require.Same(t, resolver, net.DefaultResolver, "closed service changed DNS")
|
||||
assertLifecycleAdminPort(t, address, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStartupShutdownLeavesCleanupToRun(t *testing.T) {
|
||||
for _, mode := range []string{"Close", "parent cancellation"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
entered, resume := make(chan struct{}), make(chan struct{})
|
||||
svr, address := newLifecycleTestService(t, func(ctx context.Context, _ *v1.ClientCommonConfig) Connector {
|
||||
close(entered)
|
||||
<-resume
|
||||
return &failingConnector{err: ctx.Err()}
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
defer close(resume)
|
||||
runDone := make(chan struct{})
|
||||
go func() { _ = svr.Run(ctx); close(runDone) }()
|
||||
waitLifecycleSignal(t, entered)
|
||||
closeDone := make(chan struct{})
|
||||
go func() {
|
||||
if mode == "Close" {
|
||||
svr.Close()
|
||||
} else {
|
||||
cancel()
|
||||
}
|
||||
close(closeDone)
|
||||
}()
|
||||
waitLifecycleSignal(t, closeDone)
|
||||
select {
|
||||
case <-runDone:
|
||||
t.Fatal("Run exited while startup still owned its resources")
|
||||
default:
|
||||
}
|
||||
assertLifecycleAdminPort(t, address, false)
|
||||
resume <- struct{}{}
|
||||
waitLifecycleSignal(t, runDone)
|
||||
assertLifecycleAdminPort(t, address, true)
|
||||
require.ErrorIs(t, svr.ctx.Err(), context.Canceled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceShutdownAfterLogin(t *testing.T) {
|
||||
for _, mode := range []string{"Close", "parent cancellation"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
defer serverConn.Close()
|
||||
connector := &testConnector{conn: clientConn}
|
||||
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||
return connector
|
||||
})
|
||||
defer svr.Close()
|
||||
peerDone := make(chan error, 1)
|
||||
go func() {
|
||||
rw := msg.NewV1ReadWriter(serverConn)
|
||||
var login msg.Login
|
||||
if err := rw.ReadMsgInto(&login); err != nil {
|
||||
peerDone <- err
|
||||
return
|
||||
}
|
||||
if err := rw.WriteMsg(&msg.LoginResp{RunID: "lifecycle-test"}); err != nil {
|
||||
peerDone <- err
|
||||
return
|
||||
}
|
||||
_, err := io.Copy(io.Discard, serverConn)
|
||||
peerDone <- err
|
||||
}()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
runDone := make(chan struct{})
|
||||
var runErr error
|
||||
go func() { runErr = svr.Run(ctx); close(runDone) }()
|
||||
require.Eventually(t, func() bool {
|
||||
svr.ctlMu.RLock()
|
||||
defer svr.ctlMu.RUnlock()
|
||||
return svr.ctl != nil
|
||||
}, 3*time.Second, time.Millisecond, "login did not establish control")
|
||||
if mode == "Close" {
|
||||
svr.Close()
|
||||
} else {
|
||||
cancel()
|
||||
}
|
||||
waitLifecycleSignal(t, runDone)
|
||||
require.NoError(t, runErr)
|
||||
require.True(t, connector.closed.Load())
|
||||
select {
|
||||
case err := <-peerDone:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("control connection was not released")
|
||||
}
|
||||
assertLifecycleAdminPort(t, address, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/client/proxy"
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
|
||||
// Block proxy construction after the service has read its config snapshot, but
|
||||
// before Control.Run returns and the replacement control is published.
|
||||
type handoffProxyConfig struct {
|
||||
*v1.TCPProxyConfig
|
||||
started chan struct{}
|
||||
resume chan struct{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
proxy.RegisterProxyFactory(reflect.TypeFor[*handoffProxyConfig](), func(base *proxy.BaseProxy, cfg v1.ProxyConfigurer) proxy.Proxy {
|
||||
blocking := cfg.(*handoffProxyConfig)
|
||||
close(blocking.started)
|
||||
<-blocking.resume
|
||||
return proxy.NewGeneralTCPProxy(base, blocking.TCPProxyConfig)
|
||||
})
|
||||
}
|
||||
|
||||
func newReloadTestService(t *testing.T, beforeLogin func()) *Service {
|
||||
t.Helper()
|
||||
svr, err := NewService(ServiceOptions{
|
||||
Common: &v1.ClientCommonConfig{
|
||||
Transport: v1.ClientTransportConfig{HeartbeatInterval: -1, HeartbeatTimeout: -1},
|
||||
},
|
||||
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
|
||||
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
peerDone := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
_ = clientConn.Close()
|
||||
_ = serverConn.Close()
|
||||
waitLifecycleSignal(t, peerDone)
|
||||
})
|
||||
go func() {
|
||||
defer close(peerDone)
|
||||
rw := msg.NewV1ReadWriter(serverConn)
|
||||
var login msg.Login
|
||||
if err := rw.ReadMsgInto(&login); err != nil {
|
||||
t.Errorf("read login: %v", err)
|
||||
return
|
||||
}
|
||||
if beforeLogin != nil {
|
||||
beforeLogin()
|
||||
}
|
||||
if err := rw.WriteMsg(&msg.LoginResp{RunID: "reload-test"}); err != nil {
|
||||
t.Errorf("write login response: %v", err)
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, serverConn)
|
||||
}()
|
||||
return &testConnector{conn: clientConn}
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
svr.ctx, svr.cancel = context.WithCancelCause(context.Background())
|
||||
t.Cleanup(func() {
|
||||
svr.cancel(nil)
|
||||
if svr.ctl != nil {
|
||||
_ = svr.ctl.Close()
|
||||
waitLifecycleSignal(t, svr.ctl.Done())
|
||||
}
|
||||
})
|
||||
return svr
|
||||
}
|
||||
|
||||
func TestServiceReloadDuringControlHandoff(t *testing.T) {
|
||||
for _, updateSource := range []bool{false, true} {
|
||||
name := "direct update"
|
||||
if updateSource {
|
||||
name = "config source reload"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svr := newReloadTestService(t, nil)
|
||||
svr.loopLoginUntilSuccess(time.Second, true)
|
||||
previous := svr.ctl
|
||||
require.NotNil(t, previous)
|
||||
require.NoError(t, previous.Close())
|
||||
waitLifecycleSignal(t, previous.Done())
|
||||
|
||||
oldProxy := &handoffProxyConfig{
|
||||
TCPProxyConfig: &v1.TCPProxyConfig{
|
||||
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
|
||||
RemotePort: 18001,
|
||||
},
|
||||
started: make(chan struct{}),
|
||||
resume: make(chan struct{}),
|
||||
}
|
||||
oldVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
|
||||
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "old-target",
|
||||
}}
|
||||
// Seed the desired config for the next connection without restarting
|
||||
// proxies on the already closed control.
|
||||
svr.proxyCfgs = []v1.ProxyConfigurer{oldProxy}
|
||||
svr.visitorCfgs = []v1.VisitorConfigurer{oldVisitor}
|
||||
loginDone, updateDone := make(chan struct{}), make(chan struct{})
|
||||
resume := sync.OnceFunc(func() { close(oldProxy.resume) })
|
||||
t.Cleanup(func() {
|
||||
resume()
|
||||
waitLifecycleSignal(t, loginDone)
|
||||
})
|
||||
go func() {
|
||||
svr.loopLoginUntilSuccess(time.Second, true)
|
||||
close(loginDone)
|
||||
}()
|
||||
waitLifecycleSignal(t, oldProxy.started)
|
||||
svr.ctlMu.RLock()
|
||||
current := svr.ctl
|
||||
svr.ctlMu.RUnlock()
|
||||
require.Same(t, previous, current, "replacement must still be unpublished")
|
||||
// Check before starting the updater so it cannot be the lock owner.
|
||||
unlocked := svr.reloadMu.TryLock()
|
||||
if unlocked {
|
||||
svr.reloadMu.Unlock()
|
||||
}
|
||||
|
||||
newProxy := &v1.TCPProxyConfig{
|
||||
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
|
||||
RemotePort: 18002,
|
||||
}
|
||||
newVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
|
||||
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "new-target",
|
||||
}}
|
||||
var updateErr error
|
||||
go func() {
|
||||
proxies, visitors := []v1.ProxyConfigurer{newProxy}, []v1.VisitorConfigurer{newVisitor}
|
||||
if updateSource {
|
||||
updateErr = svr.UpdateConfigSource(svr.common, proxies, visitors)
|
||||
} else {
|
||||
updateErr = svr.UpdateAllConfigurer(proxies, visitors)
|
||||
}
|
||||
close(updateDone)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
resume()
|
||||
waitLifecycleSignal(t, updateDone)
|
||||
})
|
||||
|
||||
// The gate fixes the handoff window. On the old implementation the
|
||||
// update completes against the retired control before this gate opens.
|
||||
select {
|
||||
case <-updateDone:
|
||||
t.Error("config update completed before control handoff")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
require.False(t, unlocked, "control startup must hold the reload transaction until publication")
|
||||
resume()
|
||||
waitLifecycleSignal(t, loginDone)
|
||||
waitLifecycleSignal(t, updateDone)
|
||||
require.NoError(t, updateErr)
|
||||
require.NotSame(t, previous, svr.ctl)
|
||||
status, ok := svr.getProxyStatus("proxy")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 18002, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
|
||||
visitor, ok := svr.getVisitorCfg("visitor")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "new-target", visitor.GetBaseConfig().ServerName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceReloadWhileLoginPending(t *testing.T) {
|
||||
loginStarted, allowLogin, loginDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
resume := sync.OnceFunc(func() { close(allowLogin) })
|
||||
svr := newReloadTestService(t, func() {
|
||||
close(loginStarted)
|
||||
<-allowLogin
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
resume()
|
||||
waitLifecycleSignal(t, loginDone)
|
||||
})
|
||||
go func() {
|
||||
svr.loopLoginUntilSuccess(time.Second, true)
|
||||
close(loginDone)
|
||||
}()
|
||||
waitLifecycleSignal(t, loginStarted)
|
||||
|
||||
proxyCfg := &v1.TCPProxyConfig{
|
||||
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "during-login", Type: "tcp"},
|
||||
RemotePort: 18003,
|
||||
}
|
||||
updateDone := make(chan struct{})
|
||||
var updateErr error
|
||||
go func() {
|
||||
updateErr = svr.UpdateAllConfigurer([]v1.ProxyConfigurer{proxyCfg}, nil)
|
||||
close(updateDone)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
resume()
|
||||
waitLifecycleSignal(t, updateDone)
|
||||
})
|
||||
// Config updates must complete even while the server has not replied to login.
|
||||
waitLifecycleSignal(t, updateDone)
|
||||
require.NoError(t, updateErr)
|
||||
resume()
|
||||
waitLifecycleSignal(t, loginDone)
|
||||
status, ok := svr.getProxyStatus("during-login")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 18003, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
@@ -49,7 +47,7 @@ func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
|
||||
|
||||
svr, err := NewService(ServiceOptions{
|
||||
Common: &v1.ClientCommonConfig{
|
||||
LoginFailExit: lo.ToPtr(true),
|
||||
LoginFailExit: new(true),
|
||||
WebServer: v1.WebServerConfig{
|
||||
Addr: "127.0.0.1",
|
||||
Port: port,
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
"time"
|
||||
|
||||
libio "github.com/fatedier/golib/io"
|
||||
fmux "github.com/hashicorp/yamux"
|
||||
fmux "github.com/fatedier/yamux"
|
||||
quic "github.com/quic-go/quic-go"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
|
||||
@@ -79,9 +79,23 @@ webServer.password = "admin"
|
||||
# Admin assets directory. By default, these assets are bundled with frpc.
|
||||
# webServer.assetsDir = "./static"
|
||||
|
||||
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
|
||||
# Enable golang pprof handlers in admin listener.
|
||||
webServer.pprofEnable = false
|
||||
|
||||
# Separate health, metrics and optional profiling listener.
|
||||
# /metrics serves Go runtime metrics only (no proxy or traffic data).
|
||||
# Port 0 disables it. The default address only accepts local connections.
|
||||
metricsServer.addr = "127.0.0.1"
|
||||
metricsServer.port = 0
|
||||
# Credentials and TLS are independent of webServer; /healthz stays anonymous.
|
||||
# metricsServer.user = "monitor"
|
||||
# metricsServer.password = "change-me"
|
||||
# metricsServer.tls.certFile = "metrics.crt"
|
||||
# metricsServer.tls.keyFile = "metrics.key"
|
||||
# Profiling requires explicit opt-in, uses the above credentials, and excludes cmdline.
|
||||
metricsServer.pprofEnable = false
|
||||
|
||||
# The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds.
|
||||
# transport.dialServerTimeout = 10
|
||||
|
||||
@@ -131,6 +145,12 @@ transport.tls.enable = true
|
||||
# transport.tls.trustedCaFile = "ca.crt"
|
||||
# transport.tls.serverName = "example.com"
|
||||
|
||||
# If useSystemCa is true, frpc verifies the server certificate using system CAs.
|
||||
# If trustedCaFile is also set, its CA certificates are added to the system CA pool.
|
||||
# Only supported in frpc transport.tls; requires tls.enable = true unless protocol is wss.
|
||||
# CA loading or certificate verification failures cause the connection to fail.
|
||||
# transport.tls.useSystemCa = false
|
||||
|
||||
# If the disableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the
|
||||
# first custom byte when tls is enabled.
|
||||
# Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default.
|
||||
|
||||
@@ -75,13 +75,27 @@ webServer.password = "admin"
|
||||
# dashboard assets directory(only for debug mode)
|
||||
# webServer.assetsDir = "./static"
|
||||
|
||||
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
|
||||
# Enable golang pprof handlers in dashboard listener.
|
||||
# Dashboard port must be set first
|
||||
webServer.pprofEnable = false
|
||||
|
||||
# enablePrometheus will export prometheus metrics on webServer in /metrics api.
|
||||
# Deprecated: enablePrometheus exports /metrics on webServer only.
|
||||
# Use metricsServer.port instead; it enables business metrics automatically.
|
||||
enablePrometheus = true
|
||||
|
||||
# Separate health, Prometheus metrics, and optional profiling listener.
|
||||
# Port 0 disables it. The default address only accepts local connections.
|
||||
metricsServer.addr = "127.0.0.1"
|
||||
metricsServer.port = 0
|
||||
# Credentials and TLS are independent of webServer; /healthz stays anonymous.
|
||||
# metricsServer.user = "monitor"
|
||||
# metricsServer.password = "change-me"
|
||||
# metricsServer.tls.certFile = "metrics.crt"
|
||||
# metricsServer.tls.keyFile = "metrics.key"
|
||||
# Profiling requires explicit opt-in, uses the above credentials, and excludes cmdline.
|
||||
metricsServer.pprofEnable = false
|
||||
|
||||
# console or real logFile path like ./frps.log
|
||||
log.to = "./frps.log"
|
||||
# trace, debug, info, warn, error
|
||||
|
||||
@@ -6,6 +6,21 @@ For the version compatibility policy that bounds these support windows, see the
|
||||
|
||||
## Active
|
||||
|
||||
### Observability endpoints on `webServer`
|
||||
|
||||
- **Deprecated since:** v0.72.0 (when `metricsServer` was added).
|
||||
- **Removal target:** v0.80.0 or later. v0.71.x is the last line without `metricsServer` and is supported until v0.80.0 is released, so v0.79.x is the last release that must keep these endpoints.
|
||||
- **Replacement:** `metricsServer`. Point health probes at its `/healthz`, scrapes at its `/metrics` (frps exposes business metrics there without `enablePrometheus`), and use `metricsServer.pprofEnable` for profiling.
|
||||
- **Deprecated items:** `/healthz` on `webServer` in both frps and frpc; `/metrics` on the frps `webServer` and the top-level `enablePrometheus` that gates it; the pprof handlers on `webServer` and `webServer.pprofEnable` in both frps and frpc.
|
||||
- **Code references:**
|
||||
- `server/api_router.go` — `/healthz`, and `/metrics` when `enablePrometheus` is set.
|
||||
- `client/api_router.go` — `/healthz`.
|
||||
- `pkg/util/http/server.go` — pprof handlers registered when `webServer.pprofEnable` is set.
|
||||
- `server/service.go` — startup deprecation warnings and `metricsCollectors`.
|
||||
- `client/service.go` — startup deprecation warnings.
|
||||
- `pkg/config/v1/server.go` — `EnablePrometheus`; `pkg/config/v1/common.go` — `WebServerConfig.PprofEnable`.
|
||||
- **Notes:** Removal drops the startup warnings and the deprecation notes in `README.md` and the two `conf/*_full_example.toml` files together. `webServer` then serves only the dashboard and the admin API.
|
||||
|
||||
### Wire protocol v1
|
||||
|
||||
- **Deprecated since:** v0.70.0 (planned, when v2 becomes the default).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -1,14 +1,16 @@
|
||||
FROM node:22 AS web-builder
|
||||
|
||||
COPY web/package.json /web/package.json
|
||||
COPY web/shared/ /web/shared/
|
||||
COPY web/frpc/ /web/frpc/
|
||||
WORKDIR /web
|
||||
RUN npm install
|
||||
WORKDIR /web/frpc
|
||||
RUN npm run build
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
COPY web/shared/package.json ./shared/package.json
|
||||
COPY web/frpc/package.json ./frpc/package.json
|
||||
COPY web/frps/package.json ./frps/package.json
|
||||
RUN npm ci
|
||||
COPY web/shared/ ./shared/
|
||||
COPY web/frpc/ ./frpc/
|
||||
RUN npm run build --workspace frpc
|
||||
|
||||
FROM golang:1.25 AS building
|
||||
FROM golang:1.26 AS building
|
||||
|
||||
COPY . /building
|
||||
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
FROM node:22 AS web-builder
|
||||
|
||||
COPY web/package.json /web/package.json
|
||||
COPY web/shared/ /web/shared/
|
||||
COPY web/frps/ /web/frps/
|
||||
WORKDIR /web
|
||||
RUN npm install
|
||||
WORKDIR /web/frps
|
||||
RUN npm run build
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
COPY web/shared/package.json ./shared/package.json
|
||||
COPY web/frpc/package.json ./frpc/package.json
|
||||
COPY web/frps/package.json ./frps/package.json
|
||||
RUN npm ci
|
||||
COPY web/shared/ ./shared/
|
||||
COPY web/frps/ ./frps/
|
||||
RUN npm run build --workspace frps
|
||||
|
||||
FROM golang:1.25 AS building
|
||||
FROM golang:1.26 AS building
|
||||
|
||||
COPY . /building
|
||||
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
module github.com/fatedier/frp
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/fatedier/golib v0.8.2
|
||||
github.com/fatedier/yamux v0.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
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/gomega v1.36.3
|
||||
github.com/pelletier/go-toml/v2 v2.2.0
|
||||
github.com/pires/go-proxyproto v0.15.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/prometheus/client_model v0.5.0
|
||||
github.com/prometheus/common v0.48.0
|
||||
github.com/quic-go/quic-go v0.60.0
|
||||
github.com/rodaine/table v1.2.0
|
||||
github.com/samber/lo v1.47.0
|
||||
@@ -25,8 +27,8 @@ require (
|
||||
github.com/tidwall/gjson v1.17.1
|
||||
github.com/vishvananda/netlink v1.3.0
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/crypto v0.56.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
@@ -39,7 +41,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
@@ -54,8 +56,6 @@ require (
|
||||
github.com/klauspost/reedsolomon v1.12.0 // 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
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/templexxx/cpu v0.1.1 // indirect
|
||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
||||
@@ -64,8 +64,8 @@ require (
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
@@ -73,6 +73,3 @@ require (
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
@@ -22,8 +22,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatedier/golib v0.8.2 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww=
|
||||
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.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||
github.com/fatedier/yamux v0.2.0 h1:H+2A9iBVh7aJlEOc1Ws1FXWOaecBf2nRv9zpFMPUWg8=
|
||||
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/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
@@ -151,8 +151,8 @@ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -163,8 +163,8 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
@@ -186,8 +186,8 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -195,8 +195,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
|
||||
@@ -17,8 +17,6 @@ package legacy
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/types"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
@@ -49,7 +47,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
|
||||
out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP
|
||||
out.Transport.ProxyURL = conf.HTTPProxy
|
||||
out.Transport.PoolCount = conf.PoolCount
|
||||
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
|
||||
out.Transport.TCPMux = new(conf.TCPMux)
|
||||
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
|
||||
out.Transport.Protocol = conf.Protocol
|
||||
out.Transport.HeartbeatInterval = conf.HeartbeatInterval
|
||||
@@ -57,8 +55,8 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
|
||||
out.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod
|
||||
out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout
|
||||
out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
|
||||
out.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable)
|
||||
out.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte)
|
||||
out.Transport.TLS.Enable = new(conf.TLSEnable)
|
||||
out.Transport.TLS.DisableCustomTLSFirstByte = new(conf.DisableCustomTLSFirstByte)
|
||||
out.Transport.TLS.CertFile = conf.TLSCertFile
|
||||
out.Transport.TLS.KeyFile = conf.TLSKeyFile
|
||||
out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile
|
||||
@@ -77,7 +75,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
|
||||
out.WebServer.PprofEnable = conf.PprofEnable
|
||||
|
||||
out.DNSServer = conf.DNSServer
|
||||
out.LoginFailExit = lo.ToPtr(conf.LoginFailExit)
|
||||
out.LoginFailExit = new(conf.LoginFailExit)
|
||||
out.Start = conf.Start
|
||||
out.UDPPacketSize = conf.UDPPacketSize
|
||||
out.Metadatas = conf.Metas
|
||||
@@ -134,14 +132,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
|
||||
out.Log.MaxDays = conf.LogMaxDays
|
||||
out.Log.DisablePrintColor = conf.DisableLogColor
|
||||
|
||||
out.DetailedErrorsToClient = lo.ToPtr(conf.DetailedErrorsToClient)
|
||||
out.DetailedErrorsToClient = new(conf.DetailedErrorsToClient)
|
||||
out.SubDomainHost = conf.SubDomainHost
|
||||
out.Custom404Page = conf.Custom404Page
|
||||
out.UserConnTimeout = conf.UserConnTimeout
|
||||
out.UDPPacketSize = conf.UDPPacketSize
|
||||
out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours
|
||||
|
||||
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
|
||||
out.Transport.TCPMux = new(conf.TCPMux)
|
||||
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
|
||||
out.Transport.TCPKeepAlive = conf.TCPKeepAlive
|
||||
out.Transport.MaxPoolCount = conf.MaxPoolCount
|
||||
|
||||
+10
-8
@@ -60,10 +60,11 @@ type ClientCommonConfig struct {
|
||||
// set.
|
||||
Start []string `json:"start,omitempty"`
|
||||
|
||||
Log LogConfig `json:"log,omitempty"`
|
||||
WebServer WebServerConfig `json:"webServer,omitempty"`
|
||||
Transport ClientTransportConfig `json:"transport,omitempty"`
|
||||
VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"`
|
||||
Log LogConfig `json:"log,omitempty"`
|
||||
WebServer WebServerConfig `json:"webServer,omitempty"`
|
||||
MetricsServer MetricsServerConfig `json:"metricsServer,omitempty"`
|
||||
Transport ClientTransportConfig `json:"transport,omitempty"`
|
||||
VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"`
|
||||
|
||||
// FeatureGates specifies a set of feature gates to enable or disable.
|
||||
// This can be used to enable alpha/beta features or disable default features.
|
||||
@@ -85,7 +86,7 @@ type ClientCommonConfig struct {
|
||||
func (c *ClientCommonConfig) Complete() error {
|
||||
c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0")
|
||||
c.ServerPort = util.EmptyOr(c.ServerPort, 7000)
|
||||
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, lo.ToPtr(true))
|
||||
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, new(true))
|
||||
c.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, "stun.easyvoip.com:3478")
|
||||
|
||||
if err := c.Auth.Complete(); err != nil {
|
||||
@@ -94,6 +95,7 @@ func (c *ClientCommonConfig) Complete() error {
|
||||
c.Log.Complete()
|
||||
c.Transport.Complete()
|
||||
c.WebServer.Complete()
|
||||
c.MetricsServer.Complete()
|
||||
|
||||
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
||||
return nil
|
||||
@@ -151,7 +153,7 @@ func (c *ClientTransportConfig) Complete() {
|
||||
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
|
||||
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
|
||||
c.PoolCount = util.EmptyOr(c.PoolCount, 1)
|
||||
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
|
||||
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
|
||||
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
|
||||
if lo.FromPtr(c.TCPMux) {
|
||||
// If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux.
|
||||
@@ -180,8 +182,8 @@ type TLSClientConfig struct {
|
||||
}
|
||||
|
||||
func (c *TLSClientConfig) Complete() {
|
||||
c.Enable = util.EmptyOr(c.Enable, lo.ToPtr(true))
|
||||
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, lo.ToPtr(true))
|
||||
c.Enable = util.EmptyOr(c.Enable, new(true))
|
||||
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, new(true))
|
||||
}
|
||||
|
||||
type AuthClientConfig struct {
|
||||
|
||||
@@ -72,6 +72,26 @@ func (c *WebServerConfig) Complete() {
|
||||
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
|
||||
}
|
||||
|
||||
// MetricsServerConfig configures a separate listener for health checks, metrics
|
||||
// (frps only), and optional pprof handlers. It does not serve the web interface.
|
||||
type MetricsServerConfig struct {
|
||||
// Addr defaults to "127.0.0.1".
|
||||
Addr string `json:"addr,omitempty"`
|
||||
// Port disables the listener when set to 0.
|
||||
Port int `json:"port,omitempty"`
|
||||
// User and Password protect metrics and pprof; health checks remain anonymous.
|
||||
User string `json:"user,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
// PprofEnable explicitly enables profiling, excluding cmdline.
|
||||
PprofEnable bool `json:"pprofEnable,omitempty"`
|
||||
// Enable TLS if TLS is not nil.
|
||||
TLS *TLSConfig `json:"tls,omitempty"`
|
||||
}
|
||||
|
||||
func (c *MetricsServerConfig) Complete() {
|
||||
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
// CertFile specifies the path of the cert file that client will load.
|
||||
CertFile string `json:"certFile,omitempty"`
|
||||
@@ -79,6 +99,10 @@ type TLSConfig struct {
|
||||
KeyFile string `json:"keyFile,omitempty"`
|
||||
// TrustedCaFile specifies the path of the trusted ca file that will load.
|
||||
TrustedCaFile string `json:"trustedCaFile,omitempty"`
|
||||
// UseSystemCa enables server certificate verification using system CAs.
|
||||
// If TrustedCaFile is also set, its certificates are added to the system CA pool.
|
||||
// Only supported in frpc's transport.tls. With neither trust source, frpc skips verification.
|
||||
UseSystemCa bool `json:"useSystemCa,omitempty"`
|
||||
// ServerName specifies the custom server name of tls certificate. By
|
||||
// default, server name if same to ServerAddr.
|
||||
ServerName string `json:"serverName,omitempty"`
|
||||
|
||||
@@ -17,8 +17,6 @@ package v1
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/fatedier/frp/pkg/util/jsonx"
|
||||
"github.com/fatedier/frp/pkg/util/util"
|
||||
)
|
||||
@@ -125,7 +123,7 @@ type HTTPS2HTTPPluginOptions struct {
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
||||
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
|
||||
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
|
||||
@@ -149,7 +147,7 @@ type HTTPS2HTTPSPluginOptions struct {
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
||||
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
|
||||
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
|
||||
}
|
||||
|
||||
func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
||||
|
||||
@@ -69,7 +69,8 @@ type ServerConfig struct {
|
||||
|
||||
SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"`
|
||||
|
||||
WebServer WebServerConfig `json:"webServer,omitempty"`
|
||||
WebServer WebServerConfig `json:"webServer,omitempty"`
|
||||
MetricsServer MetricsServerConfig `json:"metricsServer,omitempty"`
|
||||
// EnablePrometheus will export prometheus metrics on webserver address
|
||||
// in /metrics api.
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
@@ -105,6 +106,7 @@ func (c *ServerConfig) Complete() error {
|
||||
c.Log.Complete()
|
||||
c.Transport.Complete()
|
||||
c.WebServer.Complete()
|
||||
c.MetricsServer.Complete()
|
||||
c.SSHTunnelGateway.Complete()
|
||||
|
||||
c.BindAddr = util.EmptyOr(c.BindAddr, "0.0.0.0")
|
||||
@@ -118,7 +120,7 @@ func (c *ServerConfig) Complete() error {
|
||||
}
|
||||
|
||||
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
|
||||
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
|
||||
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, new(true))
|
||||
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
|
||||
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
||||
c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)
|
||||
@@ -180,7 +182,7 @@ type ServerTransportConfig struct {
|
||||
}
|
||||
|
||||
func (c *ServerTransportConfig) Complete() {
|
||||
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
|
||||
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
|
||||
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
|
||||
c.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200)
|
||||
c.MaxPoolCount = util.EmptyOr(c.MaxPoolCount, 5)
|
||||
|
||||
@@ -38,6 +38,7 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (
|
||||
func() (Warning, error) { return v.validateAuthConfig(&c.Auth) },
|
||||
func() (Warning, error) { return nil, validateLogConfig(&c.Log) },
|
||||
func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) },
|
||||
func() (Warning, error) { return nil, validateMetricsServerConfig(&c.MetricsServer, &c.WebServer) },
|
||||
func() (Warning, error) { return validateTransportConfig(&c.Transport) },
|
||||
func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) },
|
||||
}
|
||||
@@ -163,6 +164,10 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
|
||||
warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.TLS.CertFile))
|
||||
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile))
|
||||
warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.TLS.TrustedCaFile))
|
||||
// wss keeps TLS on regardless of transport.tls.enable.
|
||||
if c.TLS.UseSystemCa && c.Protocol != "wss" {
|
||||
errs = AppendError(errs, fmt.Errorf("transport.tls.useSystemCa requires transport.tls.enable to be true unless transport.protocol is wss"))
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
|
||||
func validateWebServerConfig(c *v1.WebServerConfig) error {
|
||||
if c.TLS != nil {
|
||||
if c.TLS.UseSystemCa {
|
||||
return fmt.Errorf("webServer.tls.useSystemCa is only supported in frpc transport.tls")
|
||||
}
|
||||
if c.TLS.CertFile == "" {
|
||||
return fmt.Errorf("tls.certFile must be specified when tls is enabled")
|
||||
}
|
||||
@@ -34,6 +37,27 @@ func validateWebServerConfig(c *v1.WebServerConfig) error {
|
||||
return ValidatePort(c.Port, "webServer.port")
|
||||
}
|
||||
|
||||
func validateMetricsServerConfig(c *v1.MetricsServerConfig, web *v1.WebServerConfig) error {
|
||||
if err := ValidatePort(c.Port, "metricsServer.port"); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.TLS != nil {
|
||||
if c.TLS.UseSystemCa {
|
||||
return fmt.Errorf("metricsServer.tls.useSystemCa is only supported in frpc transport.tls")
|
||||
}
|
||||
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" {
|
||||
return fmt.Errorf("metricsServer.tls.certFile and metricsServer.tls.keyFile must both be specified when tls is enabled")
|
||||
}
|
||||
}
|
||||
// Only reject an exact duplicate. Whether two different addresses can share
|
||||
// a port depends on the address family and the platform, so leave it to
|
||||
// net.Listen to decide.
|
||||
if c.Port > 0 && web.Port > 0 && c.Port == web.Port && c.Addr == web.Addr {
|
||||
return fmt.Errorf("metricsServer.addr:port conflicts with webServer.addr:port")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePort checks that the network port is in range
|
||||
func ValidatePort(port int, fieldPath string) error {
|
||||
if 0 <= port && port <= 65535 {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestValidateMetricsServerConfig(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
metrics v1.MetricsServerConfig
|
||||
web v1.WebServerConfig
|
||||
wantErr string
|
||||
}{
|
||||
{name: "disabled"},
|
||||
{name: "negative port", metrics: v1.MetricsServerConfig{Port: -1}, wantErr: "metricsServer.port"},
|
||||
{name: "overflow port", metrics: v1.MetricsServerConfig{Port: 65536}, wantErr: "metricsServer.port"},
|
||||
{name: "maximum port", metrics: v1.MetricsServerConfig{Port: 65535}},
|
||||
{name: "empty TLS", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{}}, wantErr: "metricsServer.tls.certFile"},
|
||||
{name: "only certificate", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "cert"}}, wantErr: "metricsServer.tls.keyFile"},
|
||||
{name: "only key", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{KeyFile: "key"}}, wantErr: "metricsServer.tls.certFile"},
|
||||
{name: "TLS pair", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "cert", KeyFile: "key"}}},
|
||||
{name: "system CA", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{UseSystemCa: true}}, wantErr: "metricsServer.tls.useSystemCa"},
|
||||
{
|
||||
name: "same address", metrics: v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
|
||||
web: v1.WebServerConfig{Addr: "127.0.0.1", Port: 7501}, wantErr: "conflicts with webServer",
|
||||
},
|
||||
{
|
||||
name: "different addresses", metrics: v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
|
||||
web: v1.WebServerConfig{Addr: "127.0.0.2", Port: 7501},
|
||||
},
|
||||
{
|
||||
name: "different ports", metrics: v1.MetricsServerConfig{Port: 7501},
|
||||
web: v1.WebServerConfig{Port: 7500},
|
||||
},
|
||||
{
|
||||
name: "web disabled", metrics: v1.MetricsServerConfig{Port: 7501},
|
||||
},
|
||||
{
|
||||
name: "metrics disabled", web: v1.WebServerConfig{Port: 7501},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
checkMetricsConfigBoth(t, tc.metrics, tc.web, tc.wantErr)
|
||||
})
|
||||
}
|
||||
// Two different addresses on the same port may or may not coexist depending
|
||||
// on the address family and the platform, so only an exact duplicate is a
|
||||
// configuration error here.
|
||||
for _, wildcard := range []string{"", "0.0.0.0", "::"} {
|
||||
t.Run("wildcard "+wildcard+" with loopback", func(t *testing.T) {
|
||||
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: wildcard, Port: 7501},
|
||||
v1.WebServerConfig{Addr: "127.0.0.1", Port: 7501}, "")
|
||||
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
|
||||
v1.WebServerConfig{Addr: wildcard, Port: 7501}, "")
|
||||
})
|
||||
t.Run("same wildcard "+wildcard, func(t *testing.T) {
|
||||
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: wildcard, Port: 7501},
|
||||
v1.WebServerConfig{Addr: wildcard, Port: 7501}, "conflicts with webServer")
|
||||
})
|
||||
}
|
||||
t.Run("different wildcards on the same port", func(t *testing.T) {
|
||||
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: "0.0.0.0", Port: 7501},
|
||||
v1.WebServerConfig{Addr: "::", Port: 7501}, "")
|
||||
})
|
||||
}
|
||||
|
||||
func checkMetricsConfigBoth(t *testing.T, metrics v1.MetricsServerConfig, web v1.WebServerConfig, wantErr string) {
|
||||
t.Helper()
|
||||
server := &v1.ServerConfig{}
|
||||
client := &v1.ClientCommonConfig{}
|
||||
require.NoError(t, server.Complete())
|
||||
require.NoError(t, client.Complete())
|
||||
// Assign after completion to also check raw empty (wildcard) addresses.
|
||||
server.MetricsServer, server.WebServer = metrics, web
|
||||
client.MetricsServer, client.WebServer = metrics, web
|
||||
validator := NewConfigValidator(nil)
|
||||
_, serverErr := validator.ValidateServerConfig(server)
|
||||
_, clientErr := validator.ValidateClientCommonConfig(client)
|
||||
for _, err := range []error{serverErr, clientErr} {
|
||||
if wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, wantErr)
|
||||
require.ErrorContains(t, err, "metricsServer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsServerDefaults(t *testing.T) {
|
||||
for _, port := range []int{0, 7501} {
|
||||
server := &v1.ServerConfig{MetricsServer: v1.MetricsServerConfig{Port: port}}
|
||||
client := &v1.ClientCommonConfig{MetricsServer: v1.MetricsServerConfig{Port: port}}
|
||||
require.NoError(t, server.Complete())
|
||||
require.NoError(t, client.Complete())
|
||||
for _, cfg := range []v1.MetricsServerConfig{server.MetricsServer, client.MetricsServer} {
|
||||
require.Equal(t, "127.0.0.1", cfg.Addr)
|
||||
require.Equal(t, port, cfg.Port)
|
||||
require.False(t, cfg.PprofEnable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,12 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
|
||||
if err := validateWebServerConfig(&c.WebServer); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
}
|
||||
if err := validateMetricsServerConfig(&c.MetricsServer, &c.WebServer); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
}
|
||||
if c.Transport.TLS.UseSystemCa {
|
||||
errs = AppendError(errs, fmt.Errorf("transport.tls.useSystemCa is only supported in frpc transport.tls"))
|
||||
}
|
||||
|
||||
errs = AppendError(errs, ValidatePort(c.BindPort, "bindPort"))
|
||||
errs = AppendError(errs, ValidatePort(c.KCPBindPort, "kcpBindPort"))
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestClientTLSCATrustSources(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
tlsJSON string
|
||||
useSystemCa bool
|
||||
caFile string
|
||||
}{
|
||||
{name: "default", tlsJSON: `{}`},
|
||||
{name: "file only", tlsJSON: `{"trustedCaFile":"ca.pem"}`, caFile: "ca.pem"},
|
||||
{name: "system only", tlsJSON: `{"useSystemCa":true}`, useSystemCa: true},
|
||||
{name: "combined", tlsJSON: `{"useSystemCa":true,"trustedCaFile":"ca.pem"}`, useSystemCa: true, caFile: "ca.pem"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := &v1.ClientCommonConfig{}
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"transport":{"tls":`+test.tlsJSON+`}}`), cfg))
|
||||
require.NoError(t, cfg.Complete())
|
||||
require.Equal(t, test.useSystemCa, cfg.Transport.TLS.UseSystemCa)
|
||||
require.Equal(t, test.caFile, cfg.Transport.TLS.TrustedCaFile)
|
||||
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
|
||||
require.NoError(t, warnings)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSystemCARequiresTLS(t *testing.T) {
|
||||
for _, protocol := range []string{"tcp", "kcp", "quic", "websocket", "wss"} {
|
||||
for _, state := range []struct {
|
||||
name string
|
||||
enable *bool
|
||||
}{
|
||||
{name: "default"},
|
||||
{name: "enabled", enable: new(true)},
|
||||
{name: "disabled", enable: new(false)},
|
||||
} {
|
||||
t.Run(protocol+"/"+state.name, func(t *testing.T) {
|
||||
cfg := &v1.ClientCommonConfig{}
|
||||
cfg.Transport.Protocol = protocol
|
||||
cfg.Transport.TLS.Enable = state.enable
|
||||
cfg.Transport.TLS.UseSystemCa = true
|
||||
require.NoError(t, cfg.Complete())
|
||||
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
|
||||
require.NoError(t, warnings, "useSystemCa must produce an error, not a warning")
|
||||
if state.name == "disabled" && protocol != "wss" {
|
||||
require.ErrorContains(t, err, "transport.tls.useSystemCa")
|
||||
require.ErrorContains(t, err, "transport.tls.enable")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledTLSFileWarnings(t *testing.T) {
|
||||
for _, protocol := range []string{"tcp", "kcp", "quic", "websocket", "wss"} {
|
||||
t.Run(protocol, func(t *testing.T) {
|
||||
cfg := &v1.ClientCommonConfig{}
|
||||
cfg.Transport.Protocol = protocol
|
||||
cfg.Transport.TLS.Enable = new(false)
|
||||
cfg.Transport.TLS.CertFile = "client.pem"
|
||||
cfg.Transport.TLS.KeyFile = "client.key"
|
||||
cfg.Transport.TLS.TrustedCaFile = "ca.pem"
|
||||
require.NoError(t, cfg.Complete())
|
||||
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
for _, field := range []string{"certFile", "keyFile", "trustedCaFile"} {
|
||||
require.ErrorContains(t, warnings, "transport.tls."+field+" is invalid when transport.tls.enable is false")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemCAUnsupportedLocations(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
server bool
|
||||
configJSON string
|
||||
fieldPath string
|
||||
}{
|
||||
{
|
||||
name: "frps transport", server: true,
|
||||
configJSON: `{"transport":{"tls":{"useSystemCa":%t}}}`, fieldPath: "transport.tls.useSystemCa",
|
||||
},
|
||||
{
|
||||
name: "frpc webServer", configJSON: `{"webServer":{"tls":{"certFile":"server.pem","keyFile":"server.key","useSystemCa":%t}}}`,
|
||||
fieldPath: "webServer.tls.useSystemCa",
|
||||
},
|
||||
{
|
||||
name: "frps webServer", server: true,
|
||||
configJSON: `{"webServer":{"tls":{"certFile":"server.pem","keyFile":"server.key","useSystemCa":%t}}}`,
|
||||
fieldPath: "webServer.tls.useSystemCa",
|
||||
},
|
||||
} {
|
||||
for _, enabled := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/%t", test.name, enabled), func(t *testing.T) {
|
||||
data := fmt.Appendf(nil, test.configJSON, enabled)
|
||||
var warnings Warning
|
||||
var err error
|
||||
if test.server {
|
||||
cfg := &v1.ServerConfig{}
|
||||
require.NoError(t, json.Unmarshal(data, cfg))
|
||||
require.NoError(t, cfg.Complete())
|
||||
warnings, err = NewConfigValidator(nil).ValidateServerConfig(cfg)
|
||||
} else {
|
||||
cfg := &v1.ClientCommonConfig{}
|
||||
require.NoError(t, json.Unmarshal(data, cfg))
|
||||
require.NoError(t, cfg.Complete())
|
||||
warnings, err = NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
|
||||
}
|
||||
require.NoError(t, warnings)
|
||||
if enabled {
|
||||
require.EqualError(t, err, test.fieldPath+" is only supported in frpc transport.tls")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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 text
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// collector returns the families of one scrape.
|
||||
type collector func() []family
|
||||
|
||||
// familiesPerCollector sizes the gather buffer; the runtime collector is the
|
||||
// only one today and emits the 22 memstats families plus a handful of others.
|
||||
const familiesPerCollector = 32
|
||||
|
||||
// Registry holds the collectors exposed on one listener.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
collectors []collector
|
||||
}
|
||||
|
||||
// NewRegistry returns a registry with no collectors.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{}
|
||||
}
|
||||
|
||||
// RegisterRuntime registers the Go runtime collector.
|
||||
func (r *Registry) RegisterRuntime() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.collectors = append(r.collectors, runtimeFamilies)
|
||||
}
|
||||
|
||||
// WritePrometheus writes every collected family in the text exposition format.
|
||||
func (r *Registry) WritePrometheus(w io.Writer) error {
|
||||
return encode(w, r.gather())
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler serving the registry.
|
||||
func (r *Registry) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Encode into memory first so that a failure never sends a truncated body.
|
||||
var b strings.Builder
|
||||
if err := r.WritePrometheus(&b); err != nil {
|
||||
http.Error(w, "metrics unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ContentType)
|
||||
_, _ = io.WriteString(w, b.String())
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Registry) gather() []family {
|
||||
r.mu.RLock()
|
||||
collectors := append([]collector(nil), r.collectors...)
|
||||
r.mu.RUnlock()
|
||||
|
||||
out := make([]family, 0, len(collectors)*familiesPerCollector)
|
||||
for _, c := range collectors {
|
||||
out = append(out, c()...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// 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 text
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"runtime/metrics"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memstats is one go_memstats_* family. The values are read the same way
|
||||
// client_golang reads them since Go 1.17: from runtime/metrics, which does not
|
||||
// stop the world, and then mapped onto runtime.MemStats fields.
|
||||
type memstats struct {
|
||||
name string
|
||||
help string
|
||||
typ metricType
|
||||
get func(*runtime.MemStats) float64
|
||||
}
|
||||
|
||||
// memstatsFamilies matches the go_memstats_* families of client_golang's default
|
||||
// registry, including their help strings and types.
|
||||
var memstatsFamilies = []memstats{
|
||||
{
|
||||
name: "go_memstats_alloc_bytes",
|
||||
help: "Number of bytes allocated and still in use.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Alloc) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_alloc_bytes_total",
|
||||
help: "Total number of bytes allocated, even if freed.",
|
||||
typ: typeCounter,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.TotalAlloc) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_buck_hash_sys_bytes",
|
||||
help: "Number of bytes used by the profiling bucket hash table.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.BuckHashSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_frees_total",
|
||||
help: "Total number of frees.",
|
||||
typ: typeCounter,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Frees) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_gc_sys_bytes",
|
||||
help: "Number of bytes used for garbage collection system metadata.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.GCSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_alloc_bytes",
|
||||
help: "Number of heap bytes allocated and still in use.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapAlloc) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_idle_bytes",
|
||||
help: "Number of heap bytes waiting to be used.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapIdle) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_inuse_bytes",
|
||||
help: "Number of heap bytes that are in use.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapInuse) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_objects",
|
||||
help: "Number of allocated objects.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapObjects) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_released_bytes",
|
||||
help: "Number of heap bytes released to OS.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapReleased) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_sys_bytes",
|
||||
help: "Number of heap bytes obtained from system.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_lookups_total",
|
||||
help: "Total number of pointer lookups.",
|
||||
typ: typeCounter,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Lookups) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mallocs_total",
|
||||
help: "Total number of mallocs.",
|
||||
typ: typeCounter,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Mallocs) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mcache_inuse_bytes",
|
||||
help: "Number of bytes in use by mcache structures.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheInuse) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mcache_sys_bytes",
|
||||
help: "Number of bytes used for mcache structures obtained from system.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mspan_inuse_bytes",
|
||||
help: "Number of bytes in use by mspan structures.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanInuse) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mspan_sys_bytes",
|
||||
help: "Number of bytes used for mspan structures obtained from system.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_next_gc_bytes",
|
||||
help: "Number of heap bytes when next garbage collection will take place.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.NextGC) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_other_sys_bytes",
|
||||
help: "Number of bytes used for other system allocations.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.OtherSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_stack_inuse_bytes",
|
||||
help: "Number of bytes in use by the stack allocator.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackInuse) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_stack_sys_bytes",
|
||||
help: "Number of bytes obtained from system for stack allocator.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_sys_bytes",
|
||||
help: "Number of bytes obtained from system.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Sys) },
|
||||
},
|
||||
}
|
||||
|
||||
// The runtime/metrics names below are the ones client_golang v1.19.1 reads to
|
||||
// build the go_memstats_* families. See prometheus/go_collector_latest.go in
|
||||
// that module (Apache-2.0).
|
||||
const (
|
||||
rmGCHeapTinyAllocsObjects = "/gc/heap/tiny/allocs:objects"
|
||||
rmGCHeapAllocsObjects = "/gc/heap/allocs:objects"
|
||||
rmGCHeapFreesObjects = "/gc/heap/frees:objects"
|
||||
rmGCHeapAllocsBytes = "/gc/heap/allocs:bytes"
|
||||
rmGCHeapObjects = "/gc/heap/objects:objects"
|
||||
rmGCHeapGoalBytes = "/gc/heap/goal:bytes"
|
||||
rmMemoryClassesTotalBytes = "/memory/classes/total:bytes"
|
||||
rmMemoryClassesHeapObjectsBytes = "/memory/classes/heap/objects:bytes"
|
||||
rmMemoryClassesHeapUnusedBytes = "/memory/classes/heap/unused:bytes"
|
||||
rmMemoryClassesHeapReleasedBytes = "/memory/classes/heap/released:bytes"
|
||||
rmMemoryClassesHeapFreeBytes = "/memory/classes/heap/free:bytes"
|
||||
rmMemoryClassesHeapStacksBytes = "/memory/classes/heap/stacks:bytes"
|
||||
rmMemoryClassesOSStacksBytes = "/memory/classes/os-stacks:bytes"
|
||||
rmMemoryClassesMetadataMSpanInuseBytes = "/memory/classes/metadata/mspan/inuse:bytes"
|
||||
rmMemoryClassesMetadataMSpanFreeBytes = "/memory/classes/metadata/mspan/free:bytes"
|
||||
rmMemoryClassesMetadataMCacheInuseBytes = "/memory/classes/metadata/mcache/inuse:bytes"
|
||||
rmMemoryClassesMetadataMCacheFreeBytes = "/memory/classes/metadata/mcache/free:bytes"
|
||||
rmMemoryClassesProfilingBucketsBytes = "/memory/classes/profiling/buckets:bytes"
|
||||
rmMemoryClassesMetadataOtherBytes = "/memory/classes/metadata/other:bytes"
|
||||
rmMemoryClassesOtherBytes = "/memory/classes/other:bytes"
|
||||
)
|
||||
|
||||
// memoryNames lists every runtime/metrics name memStatsFromRM reads.
|
||||
var memoryNames = []string{
|
||||
rmGCHeapTinyAllocsObjects,
|
||||
rmGCHeapAllocsObjects,
|
||||
rmGCHeapFreesObjects,
|
||||
rmGCHeapAllocsBytes,
|
||||
rmGCHeapObjects,
|
||||
rmGCHeapGoalBytes,
|
||||
rmMemoryClassesTotalBytes,
|
||||
rmMemoryClassesHeapObjectsBytes,
|
||||
rmMemoryClassesHeapUnusedBytes,
|
||||
rmMemoryClassesHeapReleasedBytes,
|
||||
rmMemoryClassesHeapFreeBytes,
|
||||
rmMemoryClassesHeapStacksBytes,
|
||||
rmMemoryClassesOSStacksBytes,
|
||||
rmMemoryClassesMetadataMSpanInuseBytes,
|
||||
rmMemoryClassesMetadataMSpanFreeBytes,
|
||||
rmMemoryClassesMetadataMCacheInuseBytes,
|
||||
rmMemoryClassesMetadataMCacheFreeBytes,
|
||||
rmMemoryClassesProfilingBucketsBytes,
|
||||
rmMemoryClassesMetadataOtherBytes,
|
||||
rmMemoryClassesOtherBytes,
|
||||
}
|
||||
|
||||
// memorySamples holds the sample buffer of one scrape and the index that maps a
|
||||
// runtime/metrics name to its slot.
|
||||
type memorySamples struct {
|
||||
samples []metrics.Sample
|
||||
index map[string]int
|
||||
}
|
||||
|
||||
// memStatsSnapshot takes the Go memory statistics frpc reports. It reads the
|
||||
// runtime/metrics package instead of calling runtime.ReadMemStats, which stops
|
||||
// the world on every call, and memStatsFromRM maps the samples onto the old
|
||||
// field names.
|
||||
//
|
||||
// The sample buffer is reused between scrapes, so concurrent scrapes take turns:
|
||||
// runtime/metrics.Read writes into the samples it is handed.
|
||||
func memStatsSnapshot() runtime.MemStats {
|
||||
memoryMu.Lock()
|
||||
defer memoryMu.Unlock()
|
||||
|
||||
if memoryBuffer == nil {
|
||||
memoryBuffer = newMemorySamples()
|
||||
}
|
||||
// metrics.Read indexes into the slice, so an empty buffer has to be skipped.
|
||||
if len(memoryBuffer.samples) > 0 {
|
||||
metrics.Read(memoryBuffer.samples)
|
||||
}
|
||||
|
||||
var ms runtime.MemStats
|
||||
memStatsFromRM(&ms, memoryBuffer.value)
|
||||
return ms
|
||||
}
|
||||
|
||||
var (
|
||||
memoryMu sync.Mutex
|
||||
memoryBuffer *memorySamples
|
||||
)
|
||||
|
||||
// newMemorySamples builds the sample buffer from the names this runtime supports.
|
||||
// A name the runtime does not know is left out and reads as zero, which is what
|
||||
// client_golang does as well.
|
||||
func newMemorySamples() *memorySamples {
|
||||
supported := make(map[string]struct{}, len(memoryNames))
|
||||
for _, d := range metrics.All() {
|
||||
supported[d.Name] = struct{}{}
|
||||
}
|
||||
s := &memorySamples{index: make(map[string]int, len(memoryNames))}
|
||||
for _, name := range memoryNames {
|
||||
if _, ok := supported[name]; !ok {
|
||||
continue
|
||||
}
|
||||
s.index[name] = len(s.samples)
|
||||
s.samples = append(s.samples, metrics.Sample{Name: name})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// value returns the sampled value of name, or zero when the runtime does not
|
||||
// expose it.
|
||||
func (s *memorySamples) value(name string) uint64 {
|
||||
i, ok := s.index[name]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return s.samples[i].Value.Uint64()
|
||||
}
|
||||
|
||||
// memStatsFromRM maps runtime/metrics samples onto a runtime.MemStats, using the
|
||||
// same field combinations as client_golang v1.19.1, so the go_memstats_* values
|
||||
// match the ones the official collector reports for the same process. GCCPUFraction
|
||||
// is left at zero, as client_golang omits it too.
|
||||
func memStatsFromRM(ms *runtime.MemStats, lookup func(string) uint64) {
|
||||
// MemStats counts tiny allocations in both Mallocs and Frees. client_golang
|
||||
// keeps that behavior because Mallocs-Frees is then a live object count.
|
||||
tinyAllocs := lookup(rmGCHeapTinyAllocsObjects)
|
||||
ms.Mallocs = lookup(rmGCHeapAllocsObjects) + tinyAllocs
|
||||
ms.Frees = lookup(rmGCHeapFreesObjects) + tinyAllocs
|
||||
|
||||
ms.TotalAlloc = lookup(rmGCHeapAllocsBytes)
|
||||
ms.Sys = lookup(rmMemoryClassesTotalBytes)
|
||||
ms.Lookups = 0 // Already always zero.
|
||||
ms.HeapAlloc = lookup(rmMemoryClassesHeapObjectsBytes)
|
||||
ms.Alloc = ms.HeapAlloc
|
||||
ms.HeapInuse = ms.HeapAlloc + lookup(rmMemoryClassesHeapUnusedBytes)
|
||||
ms.HeapReleased = lookup(rmMemoryClassesHeapReleasedBytes)
|
||||
ms.HeapIdle = ms.HeapReleased + lookup(rmMemoryClassesHeapFreeBytes)
|
||||
ms.HeapSys = ms.HeapInuse + ms.HeapIdle
|
||||
ms.HeapObjects = lookup(rmGCHeapObjects)
|
||||
ms.StackInuse = lookup(rmMemoryClassesHeapStacksBytes)
|
||||
ms.StackSys = ms.StackInuse + lookup(rmMemoryClassesOSStacksBytes)
|
||||
ms.MSpanInuse = lookup(rmMemoryClassesMetadataMSpanInuseBytes)
|
||||
ms.MSpanSys = ms.MSpanInuse + lookup(rmMemoryClassesMetadataMSpanFreeBytes)
|
||||
ms.MCacheInuse = lookup(rmMemoryClassesMetadataMCacheInuseBytes)
|
||||
ms.MCacheSys = ms.MCacheInuse + lookup(rmMemoryClassesMetadataMCacheFreeBytes)
|
||||
ms.BuckHashSys = lookup(rmMemoryClassesProfilingBucketsBytes)
|
||||
ms.GCSys = lookup(rmMemoryClassesMetadataOtherBytes)
|
||||
ms.OtherSys = lookup(rmMemoryClassesOtherBytes)
|
||||
ms.NextGC = lookup(rmGCHeapGoalBytes)
|
||||
}
|
||||
|
||||
// gcQuantileLabels matches the quantiles client_golang reports in the
|
||||
// go_gc_duration_seconds summary.
|
||||
var gcQuantileLabels = []string{"0", "0.25", "0.5", "0.75", "1"}
|
||||
|
||||
// runtimeFamilies reads the runtime once per scrape rather than once per metric.
|
||||
func runtimeFamilies() []family {
|
||||
ms := memStatsSnapshot()
|
||||
|
||||
out := make([]family, 0, len(memstatsFamilies)+5)
|
||||
for _, m := range memstatsFamilies {
|
||||
out = append(out, family{
|
||||
name: m.name,
|
||||
help: m.help,
|
||||
typ: m.typ,
|
||||
samples: []sample{{value: m.get(&ms)}},
|
||||
})
|
||||
}
|
||||
|
||||
// debug.ReadGCStats only takes the mheap lock; it does not stop the world.
|
||||
var gc debug.GCStats
|
||||
gc.PauseQuantiles = make([]time.Duration, len(gcQuantileLabels))
|
||||
debug.ReadGCStats(&gc)
|
||||
|
||||
gcSamples := make([]sample, 0, len(gcQuantileLabels)+2)
|
||||
for i, q := range gcQuantileLabels {
|
||||
var pause time.Duration
|
||||
if i < len(gc.PauseQuantiles) {
|
||||
pause = gc.PauseQuantiles[i]
|
||||
}
|
||||
gcSamples = append(gcSamples, sample{
|
||||
labels: []label{{name: "quantile", value: q}},
|
||||
value: pause.Seconds(),
|
||||
})
|
||||
}
|
||||
gcSamples = append(gcSamples,
|
||||
sample{name: "go_gc_duration_seconds_sum", value: gc.PauseTotal.Seconds()},
|
||||
sample{name: "go_gc_duration_seconds_count", value: float64(gc.NumGC)},
|
||||
)
|
||||
|
||||
return append(out,
|
||||
family{
|
||||
name: "go_gc_duration_seconds",
|
||||
help: "A summary of the pause duration of garbage collection cycles.",
|
||||
typ: typeSummary,
|
||||
samples: gcSamples,
|
||||
},
|
||||
family{
|
||||
name: "go_goroutines",
|
||||
help: "Number of goroutines that currently exist.",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{value: float64(runtime.NumGoroutine())}},
|
||||
},
|
||||
family{
|
||||
name: "go_info",
|
||||
help: "Information about the Go environment.",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{labels: []label{{name: "version", value: runtime.Version()}}, value: 1}},
|
||||
},
|
||||
family{
|
||||
name: "go_memstats_last_gc_time_seconds",
|
||||
help: "Number of seconds since 1970 of last garbage collection.",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{
|
||||
value: float64(gc.LastGC.UnixNano()) / float64(time.Second),
|
||||
}},
|
||||
},
|
||||
family{
|
||||
name: "go_threads",
|
||||
help: "Number of OS threads created.",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{value: float64(threadCount())}},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func threadCount() int {
|
||||
n, _ := runtime.ThreadCreateProfile(nil)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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 text
|
||||
|
||||
import (
|
||||
"math"
|
||||
"runtime"
|
||||
"runtime/metrics"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMemStatsMapping pins the runtime/metrics to MemStats mapping field by
|
||||
// field. The sample values are made up so that every combination shows up: tiny
|
||||
// allocations are added to both Mallocs and Frees, and the combined fields
|
||||
// (HeapInuse, HeapIdle, HeapSys, StackSys, MSpanSys, MCacheSys) add their parts.
|
||||
func TestMemStatsMapping(t *testing.T) {
|
||||
samples := map[string]uint64{
|
||||
rmGCHeapTinyAllocsObjects: 3,
|
||||
rmGCHeapAllocsObjects: 100,
|
||||
rmGCHeapFreesObjects: 40,
|
||||
rmGCHeapAllocsBytes: 1 << 20,
|
||||
rmGCHeapObjects: 900,
|
||||
rmGCHeapGoalBytes: 4 << 20,
|
||||
rmMemoryClassesTotalBytes: 50 << 20,
|
||||
rmMemoryClassesHeapObjectsBytes: 2 << 20,
|
||||
rmMemoryClassesHeapUnusedBytes: 3 << 20,
|
||||
rmMemoryClassesHeapReleasedBytes: 5 << 20,
|
||||
rmMemoryClassesHeapFreeBytes: 7 << 20,
|
||||
rmMemoryClassesHeapStacksBytes: 1 << 20,
|
||||
rmMemoryClassesOSStacksBytes: 2 << 20,
|
||||
rmMemoryClassesMetadataMSpanInuseBytes: 1 << 19,
|
||||
rmMemoryClassesMetadataMSpanFreeBytes: 1 << 18,
|
||||
rmMemoryClassesMetadataMCacheInuseBytes: 1 << 17,
|
||||
rmMemoryClassesMetadataMCacheFreeBytes: 1 << 16,
|
||||
rmMemoryClassesProfilingBucketsBytes: 1 << 15,
|
||||
rmMemoryClassesMetadataOtherBytes: 1 << 14,
|
||||
rmMemoryClassesOtherBytes: 1 << 13,
|
||||
}
|
||||
lookup := func(name string) uint64 { return samples[name] }
|
||||
|
||||
var ms runtime.MemStats
|
||||
memStatsFromRM(&ms, lookup)
|
||||
|
||||
// Expectations are keyed by family name, so this also checks that each family
|
||||
// reads the field the mapping fills.
|
||||
want := map[string]float64{
|
||||
"go_memstats_alloc_bytes": 2 << 20,
|
||||
"go_memstats_alloc_bytes_total": 1 << 20,
|
||||
"go_memstats_buck_hash_sys_bytes": 1 << 15,
|
||||
"go_memstats_frees_total": 43, // 40 frees + 3 tiny allocs
|
||||
"go_memstats_gc_sys_bytes": 1 << 14,
|
||||
"go_memstats_heap_alloc_bytes": 2 << 20,
|
||||
"go_memstats_heap_idle_bytes": 12 << 20, // released + free
|
||||
"go_memstats_heap_inuse_bytes": 5 << 20, // objects + unused
|
||||
"go_memstats_heap_objects": 900,
|
||||
"go_memstats_heap_released_bytes": 5 << 20,
|
||||
"go_memstats_heap_sys_bytes": 17 << 20, // inuse + idle
|
||||
"go_memstats_lookups_total": 0,
|
||||
"go_memstats_mallocs_total": 103, // 100 mallocs + 3 tiny allocs
|
||||
"go_memstats_mcache_inuse_bytes": 1 << 17,
|
||||
"go_memstats_mcache_sys_bytes": 3 << 16, // inuse + free
|
||||
"go_memstats_mspan_inuse_bytes": 1 << 19,
|
||||
"go_memstats_mspan_sys_bytes": 3 << 18, // inuse + free
|
||||
"go_memstats_next_gc_bytes": 4 << 20,
|
||||
"go_memstats_other_sys_bytes": 1 << 13,
|
||||
"go_memstats_stack_inuse_bytes": 1 << 20,
|
||||
"go_memstats_stack_sys_bytes": 3 << 20, // inuse + os-stacks
|
||||
"go_memstats_sys_bytes": 50 << 20,
|
||||
}
|
||||
require.Len(t, want, len(memstatsFamilies), "every family needs an expectation")
|
||||
for _, m := range memstatsFamilies {
|
||||
expected, ok := want[m.name]
|
||||
require.True(t, ok, "%s has no expectation", m.name)
|
||||
require.Equal(t, expected, m.get(&ms), "%s", m.name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemorySamplesMissingName checks that a runtime which does not expose a
|
||||
// name reads it as zero instead of failing, the way client_golang behaves.
|
||||
func TestMemorySamplesMissingName(t *testing.T) {
|
||||
s := &memorySamples{
|
||||
samples: []metrics.Sample{{Name: rmGCHeapObjects}},
|
||||
index: map[string]int{rmGCHeapObjects: 0},
|
||||
}
|
||||
require.Equal(t, uint64(0), s.value(rmMemoryClassesTotalBytes))
|
||||
}
|
||||
|
||||
// TestMemStatsSameOrderAsClientGolang compares what frpc reports with what
|
||||
// client_golang reads from the same process at about the same time. The check is
|
||||
// deliberately loose: the two collectors sample the runtime one after the other,
|
||||
// so counters move by whatever the test itself allocates in between, and an exact
|
||||
// comparison would flake.
|
||||
func TestMemStatsSameOrderAsClientGolang(t *testing.T) {
|
||||
reg := prometheus.NewRegistry()
|
||||
reg.MustRegister(collectors.NewGoCollector())
|
||||
mfs, err := reg.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
ours := parseFamilies(t, gather(t))
|
||||
checked := 0
|
||||
for _, mf := range mfs {
|
||||
name := mf.GetName()
|
||||
if !strings.HasPrefix(name, "go_memstats_") {
|
||||
continue
|
||||
}
|
||||
if name == "go_memstats_last_gc_time_seconds" {
|
||||
// A collection that starts between the two reads jumps this value from
|
||||
// zero to the current time; that is not a difference in behavior.
|
||||
continue
|
||||
}
|
||||
want, ok := ours[name]
|
||||
require.True(t, ok, "%s is missing", name)
|
||||
reference := metricValue(t, mf.GetMetric()[0])
|
||||
got := metricValue(t, want.GetMetric()[0])
|
||||
require.True(t, sameOrder(reference, got),
|
||||
"%s differs too much: ours %v, client_golang %v", name, got, reference)
|
||||
checked++
|
||||
}
|
||||
require.Greater(t, checked, 15, "comparison covered too few families")
|
||||
}
|
||||
|
||||
func metricValue(t *testing.T, m *dto.Metric) float64 {
|
||||
t.Helper()
|
||||
if g := m.GetGauge(); g != nil {
|
||||
return g.GetValue()
|
||||
}
|
||||
if c := m.GetCounter(); c != nil {
|
||||
return c.GetValue()
|
||||
}
|
||||
t.Fatalf("unexpected metric type for %s", m.String())
|
||||
return 0
|
||||
}
|
||||
|
||||
// sameOrder reports whether two readings of the same quantity are close enough
|
||||
// that they cannot be different quantities: equal, within 64 KiB (drift while
|
||||
// the test allocates), or within a factor of ten.
|
||||
func sameOrder(a, b float64) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if math.Abs(a-b) <= 1<<16 {
|
||||
return true
|
||||
}
|
||||
if a <= 0 || b <= 0 {
|
||||
return false
|
||||
}
|
||||
ratio := a / b
|
||||
return ratio > 0.1 && ratio < 10
|
||||
}
|
||||
|
||||
// TestConcurrentScrapes covers the shared runtime/metrics sample buffer: scrapes
|
||||
// run on the listener's goroutines and must not race. Run with -race to check it.
|
||||
func TestConcurrentScrapes(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.RegisterRuntime()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
for range 20 {
|
||||
var b strings.Builder
|
||||
if err := reg.WritePrometheus(&b); err != nil {
|
||||
t.Errorf("scrape failed: %v", err)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(b.String(), "go_memstats_heap_objects") {
|
||||
t.Error("scrape is missing go_memstats_heap_objects")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// 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 text writes Prometheus metrics in the text exposition format.
|
||||
//
|
||||
// frp uses it for frpc, which only exposes Go runtime metrics. Implementing
|
||||
// that subset here keeps client_golang and its protobuf dependency out of the
|
||||
// frpc binary; frps keeps using client_golang, which also gives it the
|
||||
// business metrics and the HTTP handler instrumentation.
|
||||
package text
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContentType is the content type of the text exposition format.
|
||||
const ContentType = "text/plain; version=0.0.4; charset=utf-8"
|
||||
|
||||
// metricType is the Prometheus metric type.
|
||||
type metricType string
|
||||
|
||||
const (
|
||||
typeCounter metricType = "counter"
|
||||
typeGauge metricType = "gauge"
|
||||
typeSummary metricType = "summary"
|
||||
)
|
||||
|
||||
type label struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
// sample is one time series. name is empty for plain families, where the
|
||||
// family name is used; summaries set it to carry the _sum and _count suffixes.
|
||||
type sample struct {
|
||||
name string
|
||||
labels []label
|
||||
value float64
|
||||
}
|
||||
|
||||
// family is one metric family ready to be encoded.
|
||||
type family struct {
|
||||
name string
|
||||
help string
|
||||
typ metricType
|
||||
samples []sample
|
||||
}
|
||||
|
||||
// encode writes families in the order they are given.
|
||||
func encode(w io.Writer, families []family) error {
|
||||
for _, f := range families {
|
||||
if _, err := fmt.Fprintf(w, "# HELP %s %s\n", f.name, escapeHelp(f.help)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "# TYPE %s %s\n", f.name, f.typ); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range f.samples {
|
||||
if err := encodeSample(w, f.name, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeSample(w io.Writer, familyName string, s sample) error {
|
||||
name := s.name
|
||||
if name == "" {
|
||||
name = familyName
|
||||
}
|
||||
if _, err := io.WriteString(w, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s.labels) > 0 {
|
||||
if err := encodeLabels(w, s.labels); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintf(w, " %s\n", formatValue(s.value))
|
||||
return err
|
||||
}
|
||||
|
||||
func encodeLabels(w io.Writer, labels []label) error {
|
||||
if _, err := io.WriteString(w, "{"); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, l := range labels {
|
||||
if i > 0 {
|
||||
if _, err := io.WriteString(w, ","); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, `%s="%s"`, l.name, escapeLabelValue(l.value)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := io.WriteString(w, "}")
|
||||
return err
|
||||
}
|
||||
|
||||
// escapeLabelValue applies the three escapes the text format defines for
|
||||
// label values.
|
||||
func escapeLabelValue(v string) string {
|
||||
if !strings.ContainsAny(v, "\\\"\n") {
|
||||
return v
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(v) + 8)
|
||||
for _, r := range v {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// escapeHelp applies the two escapes the text format defines for help text.
|
||||
func escapeHelp(v string) string {
|
||||
if !strings.ContainsAny(v, "\\\n") {
|
||||
return v
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(v) + 8)
|
||||
for _, r := range v {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formatValue renders a float the way the Prometheus text format does, so a
|
||||
// sample reads the same whichever implementation produced it.
|
||||
func formatValue(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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 text
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// gather returns what frpc would serve.
|
||||
func gather(t *testing.T) string {
|
||||
t.Helper()
|
||||
reg := NewRegistry()
|
||||
reg.RegisterRuntime()
|
||||
var b strings.Builder
|
||||
require.NoError(t, reg.WritePrometheus(&b))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseFamilies decodes the text exposition format with the Prometheus parser,
|
||||
// so anything a scraper would reject fails the test.
|
||||
func parseFamilies(t *testing.T, body string) map[string]*dto.MetricFamily {
|
||||
t.Helper()
|
||||
parser := expfmt.TextParser{}
|
||||
families, err := parser.TextToMetricFamilies(strings.NewReader(body))
|
||||
require.NoError(t, err, "output must be valid text exposition format")
|
||||
return families
|
||||
}
|
||||
|
||||
// TestMatchesClientGolang checks that every go_* family this package emits has
|
||||
// the same name, type and help text as client_golang's default Go collector. The
|
||||
// official collector is used as the oracle, so a difference shows up here rather
|
||||
// than in someone's dashboard.
|
||||
func TestMatchesClientGolang(t *testing.T) {
|
||||
reg := prometheus.NewRegistry()
|
||||
reg.MustRegister(collectors.NewGoCollector())
|
||||
mfs, err := reg.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
reference := make(map[string]*dto.MetricFamily, len(mfs))
|
||||
for _, mf := range mfs {
|
||||
reference[mf.GetName()] = mf
|
||||
}
|
||||
ours := parseFamilies(t, gather(t))
|
||||
|
||||
checked := 0
|
||||
for name, want := range reference {
|
||||
if !strings.HasPrefix(name, "go_") {
|
||||
continue
|
||||
}
|
||||
got, ok := ours[name]
|
||||
require.True(t, ok, "%s is missing", name)
|
||||
require.Equal(t, want.GetType(), got.GetType(), "%s type", name)
|
||||
require.Equal(t, want.GetHelp(), got.GetHelp(), "%s help", name)
|
||||
checked++
|
||||
}
|
||||
require.Greater(t, checked, 20, "comparison covered too few families")
|
||||
}
|
||||
|
||||
// TestNoBusinessMetrics pins what frpc must not expose: no frp_ business metrics,
|
||||
// none of the handler instrumentation client_golang adds, and no process_
|
||||
// families. frpc reports Go runtime metrics only, on every platform.
|
||||
func TestNoBusinessMetrics(t *testing.T) {
|
||||
body := gather(t)
|
||||
require.NotContains(t, body, "frp_")
|
||||
require.NotContains(t, body, "promhttp_")
|
||||
require.NotContains(t, body, "process_")
|
||||
}
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.RegisterRuntime()
|
||||
rec := httptest.NewRecorder()
|
||||
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, ContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
families := parseFamilies(t, rec.Body.String())
|
||||
require.Contains(t, families, "go_goroutines")
|
||||
gc, ok := families["go_gc_duration_seconds"]
|
||||
require.True(t, ok)
|
||||
require.Equal(t, dto.MetricType_SUMMARY, gc.GetType())
|
||||
require.Len(t, gc.GetMetric()[0].GetSummary().GetQuantile(), 5)
|
||||
}
|
||||
|
||||
func TestEmptyRegistry(t *testing.T) {
|
||||
var b strings.Builder
|
||||
require.NoError(t, NewRegistry().WritePrometheus(&b))
|
||||
require.Empty(t, b.String())
|
||||
}
|
||||
|
||||
func TestEscaping(t *testing.T) {
|
||||
var b strings.Builder
|
||||
require.NoError(t, encode(&b, []family{{
|
||||
name: "test_labels",
|
||||
help: "help with \\ and\nnewline",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{labels: []label{{name: "v", value: "a\"b\\c\nd"}}, value: 1}},
|
||||
}}))
|
||||
require.Equal(t, "# HELP test_labels help with \\\\ and\\nnewline\n"+
|
||||
"# TYPE test_labels gauge\n"+
|
||||
"test_labels{v=\"a\\\"b\\\\c\\nd\"} 1\n", b.String())
|
||||
}
|
||||
+56
-5
@@ -19,6 +19,8 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
@@ -111,14 +113,22 @@ type Dispatcher struct {
|
||||
|
||||
sendCh chan Message
|
||||
doneCh chan struct{}
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
msgHandlers map[reflect.Type]func(Message)
|
||||
}
|
||||
|
||||
// NewDispatcher uses SetReadDeadline, when supported, and Close, when rw
|
||||
// implements io.Closer, to interrupt ReadMsg after a write failure. Without a
|
||||
// working read deadline, Close or the caller must arrange for ReadMsg to return.
|
||||
// These methods must return in bounded time and must not wait for Done: Done
|
||||
// closes only after ReadMsg, any synchronous handler, and stop have returned.
|
||||
func NewDispatcher(rw ReadWriter) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
rw: rw,
|
||||
sendCh: make(chan Message, 100),
|
||||
doneCh: make(chan struct{}),
|
||||
stopCh: make(chan struct{}),
|
||||
msgHandlers: make(map[reflect.Type]func(Message)),
|
||||
}
|
||||
}
|
||||
@@ -129,37 +139,78 @@ func (d *Dispatcher) Run() {
|
||||
go d.readLoop()
|
||||
}
|
||||
|
||||
// stop rejects new sends and interrupts ReadMsg when supported by rw. Done remains
|
||||
// owned by readLoop so the control owner cannot clean up while a handler is running.
|
||||
func (d *Dispatcher) stop() {
|
||||
d.stopOnce.Do(func() {
|
||||
close(d.stopCh)
|
||||
if conn, ok := d.rw.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||
_ = conn.SetReadDeadline(time.Now())
|
||||
}
|
||||
if closer, ok := d.rw.(io.Closer); ok {
|
||||
_ = closer.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Dispatcher) sendLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-d.doneCh:
|
||||
case <-d.stopCh:
|
||||
return
|
||||
case m := <-d.sendCh:
|
||||
_ = d.rw.WriteMsg(m)
|
||||
if err := d.rw.WriteMsg(m); err != nil {
|
||||
d.stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) readLoop() {
|
||||
defer close(d.doneCh)
|
||||
defer d.stop()
|
||||
for {
|
||||
m, err := d.rw.ReadMsg()
|
||||
if err != nil {
|
||||
close(d.doneCh)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-d.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
|
||||
handler(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send queues m for writing. A nil error does not guarantee delivery.
|
||||
func (d *Dispatcher) Send(m Message) error {
|
||||
select {
|
||||
case <-d.doneCh:
|
||||
case <-d.stopCh:
|
||||
return io.EOF
|
||||
default:
|
||||
}
|
||||
return d.enqueue(m)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) enqueue(m Message) error {
|
||||
select {
|
||||
case <-d.stopCh:
|
||||
return io.EOF
|
||||
case d.sendCh <- m:
|
||||
return nil
|
||||
// Both cases can be ready if shutdown races with enqueueing. Do not
|
||||
// report success when shutdown is already observable after the send.
|
||||
select {
|
||||
case <-d.stopCh:
|
||||
return io.EOF
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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 msg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Preserve the original exported constructor function type.
|
||||
var _ func(ReadWriter) *Dispatcher = NewDispatcher
|
||||
|
||||
func TestDispatcherEnqueueRejectsStoppedDispatcher(t *testing.T) {
|
||||
c := newRegressionControl(t)
|
||||
d := NewDispatcher(c)
|
||||
d.stop()
|
||||
|
||||
// Model shutdown after Send's initial check: the stop and buffered send
|
||||
// cases are both ready. A send selected here must still return EOF.
|
||||
if err := d.enqueue(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("enqueue racing with shutdown: %v, want EOF", err)
|
||||
}
|
||||
// This single call checks that a stopped dispatcher's enqueue returns EOF; it
|
||||
// does not guarantee exercising the send case and post-enqueue check on every run.
|
||||
if len(d.sendCh) > 0 {
|
||||
t.Log("selected the send case; post-enqueue check returned EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherAcceptsReadWriterWithoutClose(t *testing.T) {
|
||||
t.Run("codec factory", func(t *testing.T) {
|
||||
d := NewDispatcher(NewV1ReadWriter(&bytes.Buffer{}))
|
||||
d.Run()
|
||||
regressionAwait(t, d.Done(), "non-closable codec EOF")
|
||||
})
|
||||
|
||||
t.Run("write failure", func(t *testing.T) {
|
||||
c := newRegressionControl(t)
|
||||
regressionWarm(t, c)
|
||||
// Hide Close to model an existing caller implementing only ReadWriter.
|
||||
d := NewDispatcher(struct{ ReadWriter }{c})
|
||||
d.Run()
|
||||
t.Cleanup(func() {
|
||||
c.transport.close()
|
||||
regressionAwait(t, d.Done(), "externally interrupted reader")
|
||||
})
|
||||
|
||||
c.transport.arm()
|
||||
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
|
||||
t.Fatalf("injected write failure: %v", err)
|
||||
}
|
||||
regressionAwait(t, d.stopCh, "write failure stops sends")
|
||||
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("Send after write failure: %v, want EOF", err)
|
||||
}
|
||||
if c.closeCalls.Load() != 0 {
|
||||
t.Fatal("dispatcher closed a connection hidden by ReadWriter")
|
||||
}
|
||||
select {
|
||||
case <-d.Done():
|
||||
t.Fatal("Done closed before the non-closable reader returned")
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// 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 msg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
libcrypto "github.com/fatedier/golib/crypto"
|
||||
)
|
||||
|
||||
var (
|
||||
errRegressionWriteTimeout = errors.New("injected recoverable connection write timeout")
|
||||
regressionKey = []byte("isolated-public-test-key")
|
||||
)
|
||||
|
||||
// regressionTransport fails exactly one Write, then accepts later writes. This
|
||||
// models an individual mux stream write timing out while its session survives.
|
||||
type regressionTransport struct {
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
calls int
|
||||
failures int
|
||||
failNext bool
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (w *regressionTransport) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.calls++
|
||||
if w.failNext {
|
||||
w.failNext = false
|
||||
w.failures++
|
||||
return 0, errRegressionWriteTimeout
|
||||
}
|
||||
select {
|
||||
case <-w.closed:
|
||||
return 0, io.ErrClosedPipe
|
||||
default:
|
||||
}
|
||||
return w.buffer.Write(p)
|
||||
}
|
||||
|
||||
func (w *regressionTransport) arm() {
|
||||
w.mu.Lock()
|
||||
w.failNext = true
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func (w *regressionTransport) snapshot() (calls, failures, size int) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.calls, w.failures, w.buffer.Len()
|
||||
}
|
||||
|
||||
func (w *regressionTransport) close() {
|
||||
w.once.Do(func() { close(w.closed) })
|
||||
}
|
||||
|
||||
// regressionControl uses the production FRP message encoder and production
|
||||
// golib crypto.Writer. Only the final transport and incoming messages are fake.
|
||||
type regressionControl struct {
|
||||
w *libcrypto.Writer
|
||||
transport *regressionTransport
|
||||
writes chan error
|
||||
incoming chan Message
|
||||
beforeRead func() error
|
||||
beforeWrite func()
|
||||
closeCalls atomic.Int32
|
||||
}
|
||||
|
||||
func newRegressionControl(t *testing.T) *regressionControl {
|
||||
t.Helper()
|
||||
transport := ®ressionTransport{closed: make(chan struct{})}
|
||||
w, err := libcrypto.NewWriter(transport, regressionKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ®ressionControl{
|
||||
w: w, transport: transport, writes: make(chan error, 100), incoming: make(chan Message, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *regressionControl) ReadMsg() (Message, error) {
|
||||
if c.beforeRead != nil {
|
||||
if err := c.beforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
select {
|
||||
case m := <-c.incoming:
|
||||
return m, nil
|
||||
case <-c.transport.closed:
|
||||
return nil, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
func (c *regressionControl) ReadMsgInto(Message) error {
|
||||
_, err := c.ReadMsg()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *regressionControl) WriteMsg(m Message) error {
|
||||
if c.beforeWrite != nil {
|
||||
c.beforeWrite()
|
||||
}
|
||||
err := WriteMsg(c.w, m)
|
||||
c.writes <- err
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *regressionControl) Close() error {
|
||||
c.closeCalls.Add(1)
|
||||
c.transport.close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func regressionReceiveWrite(t *testing.T, c *regressionControl) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-c.writes:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dispatcher did not attempt WriteMsg")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func regressionAwait(t *testing.T, ch <-chan struct{}, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
}
|
||||
|
||||
func regressionWarm(t *testing.T, c *regressionControl) {
|
||||
t.Helper()
|
||||
if err := c.WriteMsg(&ReqWorkConn{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-c.writes
|
||||
c.transport.mu.Lock()
|
||||
raw := append([]byte(nil), c.transport.buffer.Bytes()...)
|
||||
c.transport.mu.Unlock()
|
||||
m, err := ReadMsg(libcrypto.NewReader(bytes.NewReader(raw), regressionKey))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypted control roundtrip: %v", err)
|
||||
}
|
||||
if _, ok := m.(*ReqWorkConn); !ok {
|
||||
t.Fatalf("decoded %T", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlCryptoWriterKeepsErrorAfterTransportRecovery(t *testing.T) {
|
||||
c := newRegressionControl(t)
|
||||
regressionWarm(t, c)
|
||||
defer c.transport.close()
|
||||
c.transport.arm()
|
||||
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
|
||||
t.Fatalf("want timeout, got %v", err)
|
||||
}
|
||||
<-c.writes
|
||||
beforeCalls, failures, beforeBytes := c.transport.snapshot()
|
||||
for range 5 {
|
||||
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
|
||||
t.Fatalf("sticky error missing: %v", err)
|
||||
}
|
||||
<-c.writes
|
||||
}
|
||||
afterCalls, _, afterBytes := c.transport.snapshot()
|
||||
if beforeCalls != afterCalls || beforeBytes != afterBytes || failures != 1 {
|
||||
t.Fatalf("unexpected retry: calls %d -> %d, failures %d, bytes %d -> %d",
|
||||
beforeCalls, afterCalls, failures, beforeBytes, afterBytes)
|
||||
}
|
||||
if n, err := c.transport.Write([]byte("healthy underlying transport")); err != nil || n == 0 {
|
||||
t.Fatalf("underlying transport did not recover: %v", err)
|
||||
}
|
||||
t.Log("one recoverable write error permanently invalidates this crypto writer; recovered underlying transport is never retried")
|
||||
}
|
||||
|
||||
func TestDispatcherWriteErrorClosesControlAndFreshSessionWorks(t *testing.T) {
|
||||
c := newRegressionControl(t)
|
||||
regressionWarm(t, c)
|
||||
defer c.transport.close()
|
||||
d := NewDispatcher(c)
|
||||
d.Run()
|
||||
c.transport.arm()
|
||||
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regressionAwait(t, c.transport.closed, "failed control connection close")
|
||||
regressionAwait(t, d.Done(), "failed dispatcher completion")
|
||||
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("Send after completed shutdown: %v", err)
|
||||
}
|
||||
if c.closeCalls.Load() != 1 {
|
||||
t.Fatalf("connection closed %d times", c.closeCalls.Load())
|
||||
}
|
||||
|
||||
fresh := newRegressionControl(t)
|
||||
defer fresh.transport.close()
|
||||
d2 := NewDispatcher(fresh)
|
||||
d2.Run()
|
||||
for range 5 {
|
||||
if err := d2.Send(&ReqWorkConn{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := regressionReceiveWrite(t, fresh); err != nil {
|
||||
t.Fatalf("fresh session write failed: %v", err)
|
||||
}
|
||||
}
|
||||
fresh.transport.mu.Lock()
|
||||
raw := append([]byte(nil), fresh.transport.buffer.Bytes()...)
|
||||
fresh.transport.mu.Unlock()
|
||||
r := libcrypto.NewReader(bytes.NewReader(raw), regressionKey)
|
||||
for i := range 5 {
|
||||
m, err := ReadMsg(r)
|
||||
if err != nil {
|
||||
t.Fatalf("new stream decode %d: %v", i, err)
|
||||
}
|
||||
if _, ok := m.(*ReqWorkConn); !ok {
|
||||
t.Fatalf("decoded %T", m)
|
||||
}
|
||||
}
|
||||
fresh.transport.close()
|
||||
regressionAwait(t, d2.Done(), "new-session cleanup")
|
||||
t.Log("write error closes failed control; new session sends and decrypts five valid ReqWorkConn messages")
|
||||
}
|
||||
|
||||
func TestDispatcherWriteErrorWaitsForSynchronousHandler(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
c := newRegressionControl(t)
|
||||
regressionWarm(t, c)
|
||||
defer c.transport.close()
|
||||
d := NewDispatcher(c)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
d.RegisterHandler(&ReqWorkConn{}, func(Message) {
|
||||
close(entered)
|
||||
<-release
|
||||
})
|
||||
d.Run()
|
||||
c.incoming <- &ReqWorkConn{}
|
||||
regressionAwait(t, entered, "synchronous handler entry")
|
||||
c.transport.arm()
|
||||
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regressionAwait(t, c.transport.closed, "write failure closes control during handler")
|
||||
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("Send after stop while handler is active: %v", err)
|
||||
}
|
||||
// Let the writer finish stopping while the handler remains blocked.
|
||||
synctest.Wait()
|
||||
select {
|
||||
case <-d.Done():
|
||||
t.Fatal("Done closed before synchronous handler returned; owner cleanup could race proxy registration")
|
||||
default:
|
||||
}
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
regressionAwait(t, d.Done(), "completion after handler returned")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDispatcherWriteErrorUnblocksSenderWithFullQueue(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
c := newRegressionControl(t)
|
||||
regressionWarm(t, c)
|
||||
defer c.transport.close()
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
c.beforeWrite = func() { close(entered); <-release }
|
||||
c.transport.arm()
|
||||
d := NewDispatcher(c)
|
||||
if err := d.Send(&ReqWorkConn{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.Run()
|
||||
regressionAwait(t, entered, "blocked transport write")
|
||||
for i := 0; i < cap(d.sendCh); i++ {
|
||||
if err := d.Send(&ReqWorkConn{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sent := make(chan error, 1)
|
||||
go func() { sent <- d.Send(&ReqWorkConn{}) }()
|
||||
// Wait until the sender has reached its full-queue channel wait.
|
||||
synctest.Wait()
|
||||
select {
|
||||
case err := <-sent:
|
||||
t.Fatalf("Send did not block on full queue: %v", err)
|
||||
default:
|
||||
}
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
select {
|
||||
case err := <-sent:
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("blocked Send after write failure: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("write failure did not unblock full-queue sender")
|
||||
}
|
||||
regressionAwait(t, d.Done(), "full-queue dispatcher cleanup")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDispatcherConcurrentReadWriteFailureClosesOnce(t *testing.T) {
|
||||
c := newRegressionControl(t)
|
||||
regressionWarm(t, c)
|
||||
defer c.transport.close()
|
||||
readEntered, writeEntered := make(chan struct{}), make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
c.beforeRead = func() error {
|
||||
close(readEntered)
|
||||
<-release
|
||||
return io.EOF
|
||||
}
|
||||
c.beforeWrite = func() {
|
||||
close(writeEntered)
|
||||
<-release
|
||||
}
|
||||
d := NewDispatcher(c)
|
||||
c.transport.arm()
|
||||
if err := d.Send(&ReqWorkConn{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readExited, writeExited := make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(readExited)
|
||||
d.readLoop()
|
||||
}()
|
||||
go func() {
|
||||
defer close(writeExited)
|
||||
d.sendLoop()
|
||||
}()
|
||||
regressionAwait(t, readEntered, "read path before injected failure")
|
||||
regressionAwait(t, writeEntered, "write path before injected failure")
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
regressionAwait(t, readExited, "failed read loop exit")
|
||||
regressionAwait(t, writeExited, "failed write loop exit")
|
||||
regressionAwait(t, d.Done(), "concurrent read/write shutdown")
|
||||
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
|
||||
t.Fatalf("concurrent WriteMsg failure: %v", err)
|
||||
}
|
||||
if _, failures, _ := c.transport.snapshot(); failures != 1 {
|
||||
t.Fatalf("injected transport failed %d times", failures)
|
||||
}
|
||||
if c.closeCalls.Load() != 1 {
|
||||
t.Fatalf("connection closed %d times", c.closeCalls.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// 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 msg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fmux "github.com/fatedier/yamux"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
)
|
||||
|
||||
// Delay one real TCP write without injecting an error. The stream's write
|
||||
// failure must come from yamux's ConnectionWriteTimeout.
|
||||
type delayedTCPConn struct {
|
||||
net.Conn
|
||||
armed atomic.Bool
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (c *delayedTCPConn) Write(p []byte) (int, error) {
|
||||
if c.armed.Swap(false) {
|
||||
close(c.entered)
|
||||
<-c.release
|
||||
}
|
||||
return c.Conn.Write(p)
|
||||
}
|
||||
|
||||
type observedYamuxConn struct {
|
||||
net.Conn
|
||||
writes chan error
|
||||
readEntered chan struct{}
|
||||
readOnce sync.Once
|
||||
closed chan struct{}
|
||||
closeCalls atomic.Int32
|
||||
}
|
||||
|
||||
func (c *observedYamuxConn) Read(p []byte) (int, error) {
|
||||
c.readOnce.Do(func() { close(c.readEntered) })
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
|
||||
func (c *observedYamuxConn) Write(p []byte) (int, error) {
|
||||
n, err := c.Conn.Write(p)
|
||||
c.writes <- err
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *observedYamuxConn) Close() error {
|
||||
c.closeCalls.Add(1)
|
||||
err := c.Conn.Close()
|
||||
close(c.closed)
|
||||
return err
|
||||
}
|
||||
|
||||
func newLoopbackYamuxPair(t *testing.T) (*fmux.Session, *fmux.Session, *delayedTCPConn) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
clientTCP, err := net.Dial("tcp", listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { clientTCP.Close() })
|
||||
serverTCP, err := listener.Accept()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { serverTCP.Close() })
|
||||
gate := &delayedTCPConn{Conn: clientTCP, entered: make(chan struct{}), release: make(chan struct{})}
|
||||
config := fmux.DefaultConfig()
|
||||
config.ConnectionWriteTimeout = 80 * time.Millisecond
|
||||
config.LogOutput = io.Discard
|
||||
client, err := fmux.Client(gate, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { client.Close() })
|
||||
server, err := fmux.Server(serverTCP, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { server.Close() })
|
||||
return client, server, gate
|
||||
}
|
||||
|
||||
func loopbackCryptoConn(t *testing.T, conn net.Conn, protocol string, role netpkg.AEADCryptoRole) *Conn {
|
||||
t.Helper()
|
||||
var rw io.ReadWriter
|
||||
var err error
|
||||
if protocol == wire.ProtocolV2 {
|
||||
rw, err = netpkg.NewAEADCryptoReadWriter(
|
||||
conn, regressionKey, role, wire.AEADAlgorithmAES256GCM, []byte("isolated-test-transcript"))
|
||||
} else {
|
||||
rw, err = netpkg.NewCryptoReadWriter(conn, regressionKey)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewConn(conn, NewReadWriter(rw, protocol))
|
||||
}
|
||||
|
||||
func loopbackControlPair(t *testing.T, client, server *fmux.Session, protocol string) (*Conn, *Conn, *observedYamuxConn) {
|
||||
t.Helper()
|
||||
local, err := client.OpenStream()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remote, err := server.AcceptStream()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observed := &observedYamuxConn{
|
||||
Conn: local, writes: make(chan error, 16), readEntered: make(chan struct{}), closed: make(chan struct{}),
|
||||
}
|
||||
return loopbackCryptoConn(t, observed, protocol, netpkg.AEADCryptoRoleClient),
|
||||
loopbackCryptoConn(t, remote, protocol, netpkg.AEADCryptoRoleServer), observed
|
||||
}
|
||||
|
||||
func loopbackReadReq(t *testing.T, conn *Conn) {
|
||||
t.Helper()
|
||||
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.SetReadDeadline(time.Time{})
|
||||
m, err := conn.ReadMsg()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := m.(*ReqWorkConn); !ok {
|
||||
t.Fatalf("decoded %T, want ReqWorkConn", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherYamuxWriteTimeoutInterruptsRead(t *testing.T) {
|
||||
for _, protocol := range []string{wire.ProtocolV1, wire.ProtocolV2} {
|
||||
t.Run(protocol, func(t *testing.T) {
|
||||
client, server, gate := newLoopbackYamuxPair(t)
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(gate.release) })
|
||||
control, peer, observed := loopbackControlPair(t, client, server, protocol)
|
||||
if err := control.WriteMsg(&ReqWorkConn{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loopbackReadReq(t, peer)
|
||||
// Warm-up has completed, including all crypto header writes.
|
||||
for len(observed.writes) > 0 {
|
||||
<-observed.writes
|
||||
}
|
||||
d := NewDispatcher(control)
|
||||
readExited := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readExited)
|
||||
d.readLoop()
|
||||
}()
|
||||
regressionAwait(t, observed.readEntered, "control reader entry")
|
||||
gate.armed.Store(true)
|
||||
// Queue before starting the writer so this Send cannot race its failure.
|
||||
if err := d.Send(&ReqWorkConn{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go d.sendLoop()
|
||||
regressionAwait(t, gate.entered, "TCP write delay barrier")
|
||||
select {
|
||||
case err := <-observed.writes:
|
||||
if !errors.Is(err, fmux.ErrConnectionWriteTimeout) {
|
||||
t.Fatalf("stream write error: %v, want yamux connection write timeout", err)
|
||||
}
|
||||
t.Logf("production yamux write failed: %v", err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("yamux write did not time out")
|
||||
}
|
||||
regressionAwait(t, d.stopCh, "write failure stops sends")
|
||||
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("Send after stop: %v, want EOF", err)
|
||||
}
|
||||
releaseOnce.Do(func() { close(gate.release) })
|
||||
regressionAwait(t, observed.closed, "control Close returns")
|
||||
// peer never closes its write half: local yamux Close alone cannot
|
||||
// interrupt Read. Neither a peer FIN nor session cleanup may help.
|
||||
regressionAwait(t, d.Done(), "Done without peer FIN")
|
||||
regressionAwait(t, readExited, "control readLoop exit without peer FIN")
|
||||
if observed.closeCalls.Load() != 1 {
|
||||
t.Fatalf("control closed %d times, want once", observed.closeCalls.Load())
|
||||
}
|
||||
if client.IsClosed() || server.IsClosed() {
|
||||
t.Fatal("yamux session unexpectedly closed")
|
||||
}
|
||||
fresh, freshPeer, freshObserved := loopbackControlPair(t, client, server, protocol)
|
||||
for range 5 {
|
||||
if err := fresh.WriteMsg(&ReqWorkConn{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loopbackReadReq(t, freshPeer)
|
||||
for len(freshObserved.writes) > 0 {
|
||||
<-freshObserved.writes
|
||||
}
|
||||
}
|
||||
t.Log("Done/readLoop exited without peer FIN; Close once; same yamux session decrypted five fresh messages")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,9 @@ func rewriteHTTPPluginRequest(
|
||||
}
|
||||
|
||||
type httpBridgePlugin struct {
|
||||
l *Listener
|
||||
s *http.Server
|
||||
l *Listener
|
||||
s *http.Server
|
||||
httpsServer *httpsserver.Server
|
||||
|
||||
useSourceRemoteAddr bool
|
||||
}
|
||||
@@ -87,7 +88,8 @@ func newHTTPSBridgePluginServer(
|
||||
}
|
||||
p := &httpBridgePlugin{
|
||||
l: listener,
|
||||
s: server,
|
||||
s: server.Server,
|
||||
httpsServer: server,
|
||||
useSourceRemoteAddr: useSourceRemoteAddr,
|
||||
}
|
||||
go func() {
|
||||
@@ -124,3 +126,18 @@ func (p *httpBridgePlugin) Close() error {
|
||||
_ = p.l.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload refreshes only resources owned by this plugin; HTTP bridges are no-op.
|
||||
func (p *httpBridgePlugin) Reload() error {
|
||||
if p.httpsServer == nil {
|
||||
return nil
|
||||
}
|
||||
return p.httpsServer.Reload()
|
||||
}
|
||||
|
||||
func (p *httpBridgePlugin) ReloadError() error {
|
||||
if p.httpsServer == nil {
|
||||
return nil
|
||||
}
|
||||
return p.httpsServer.ReloadError()
|
||||
}
|
||||
|
||||
@@ -17,9 +17,13 @@
|
||||
package httpsserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
@@ -28,13 +32,38 @@ import (
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
)
|
||||
|
||||
func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*http.Server, error) {
|
||||
tlsConfig, err := transport.NewServerTLSConfig(crtPath, keyPath, "")
|
||||
// snapshot is immutable after publication. Errors retain the last usable pair.
|
||||
type snapshot struct {
|
||||
cert *tls.Certificate
|
||||
certPEM, keyPEM []byte
|
||||
err error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
*http.Server
|
||||
|
||||
crtPath, keyPath string
|
||||
reloadMu sync.Mutex
|
||||
state atomic.Pointer[snapshot]
|
||||
}
|
||||
|
||||
func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*Server, error) {
|
||||
server := &Server{crtPath: crtPath, keyPath: keyPath}
|
||||
var tlsConfig *tls.Config
|
||||
var err error
|
||||
if crtPath == "" || keyPath == "" {
|
||||
tlsConfig, err = transport.NewServerTLSConfig(crtPath, keyPath, "")
|
||||
} else {
|
||||
err = server.Reload()
|
||||
tlsConfig = &tls.Config{GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return server.state.Load().cert, nil
|
||||
}}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
server.Server = &http.Server{
|
||||
Handler: withMisdirectedRequestCheck(handler),
|
||||
ReadHeaderTimeout: 60 * time.Second,
|
||||
TLSConfig: tlsConfig,
|
||||
@@ -58,3 +87,51 @@ func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) Reload() (err error) {
|
||||
if s.crtPath == "" || s.keyPath == "" {
|
||||
return nil
|
||||
}
|
||||
s.reloadMu.Lock()
|
||||
defer s.reloadMu.Unlock()
|
||||
old := s.state.Load()
|
||||
// Publish the error before releasing reloadMu; status never waits for I/O.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
next := snapshot{err: err}
|
||||
if old != nil {
|
||||
next.cert, next.certPEM, next.keyPEM = old.cert, old.certPEM, old.keyPEM
|
||||
}
|
||||
s.state.Store(&next)
|
||||
}
|
||||
}()
|
||||
certPEM, err := os.ReadFile(s.crtPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read certificate: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(s.keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private key: %w", err)
|
||||
}
|
||||
if old != nil && old.cert != nil && bytes.Equal(certPEM, old.certPEM) && bytes.Equal(keyPEM, old.keyPEM) {
|
||||
if old.err != nil {
|
||||
next := *old
|
||||
next.err = nil
|
||||
s.state.Store(&next)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse certificate pair: %w", err)
|
||||
}
|
||||
s.state.Store(&snapshot{cert: &cert, certPEM: certPEM, keyPEM: keyPEM})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ReloadError() error {
|
||||
if v := s.state.Load(); v != nil {
|
||||
return v.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// 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 httpsserver
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func certificatePEM(t *testing.T, serial int64) ([]byte, []byte) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(serial),
|
||||
DNSNames: []string{"localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
||||
pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
}
|
||||
|
||||
func handshake(t *testing.T, cfg *tls.Config) (*tls.Conn, *tls.Conn) {
|
||||
t.Helper()
|
||||
clientRaw, serverRaw := net.Pipe()
|
||||
t.Cleanup(func() { _ = clientRaw.Close(); _ = serverRaw.Close() })
|
||||
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
require.NoError(t, serverRaw.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
client := tls.Client(clientRaw, &tls.Config{InsecureSkipVerify: true})
|
||||
server := tls.Server(serverRaw, cfg)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- server.Handshake() }()
|
||||
require.NoError(t, client.Handshake())
|
||||
require.NoError(t, <-done)
|
||||
return client, server
|
||||
}
|
||||
|
||||
func TestReloadCertificate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
|
||||
certPEM, keyPEM := certificatePEM(t, 1)
|
||||
require.NoError(t, os.WriteFile(crtPath, certPEM, 0o600))
|
||||
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
|
||||
s, err := New(nil, crtPath, keyPath, nil)
|
||||
require.NoError(t, err)
|
||||
originalState := s.state.Load()
|
||||
original := originalState.cert
|
||||
oldClient, oldServer := handshake(t, s.TLSConfig)
|
||||
require.Equal(t, int64(1), oldClient.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
|
||||
|
||||
require.NoError(t, s.Reload())
|
||||
require.Same(t, originalState, s.state.Load(), "unchanged bytes must not parse or publish a new pair")
|
||||
require.NoError(t, s.ReloadError())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, path string
|
||||
data []byte
|
||||
}{
|
||||
{"missing certificate", crtPath, nil},
|
||||
{"missing key", keyPath, nil},
|
||||
{"invalid certificate", crtPath, []byte("invalid")},
|
||||
{"invalid key", keyPath, []byte("invalid")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.data == nil {
|
||||
require.NoError(t, os.Remove(tc.path))
|
||||
} else {
|
||||
require.NoError(t, os.WriteFile(tc.path, tc.data, 0o600))
|
||||
}
|
||||
require.Error(t, s.Reload())
|
||||
require.Error(t, s.ReloadError())
|
||||
require.Same(t, original, s.state.Load().cert)
|
||||
client, _ := handshake(t, s.TLSConfig)
|
||||
require.Equal(t, int64(1), client.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
|
||||
require.NoError(t, os.WriteFile(crtPath, certPEM, 0o600))
|
||||
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
|
||||
require.NoError(t, s.Reload())
|
||||
require.NoError(t, s.ReloadError())
|
||||
require.Same(t, original, s.state.Load().cert)
|
||||
})
|
||||
}
|
||||
// Even a key-only byte change must be noticed, regardless of parsed identity.
|
||||
require.NoError(t, os.WriteFile(keyPath, append(keyPEM, '\n'), 0o600))
|
||||
require.NoError(t, s.Reload())
|
||||
require.NotSame(t, original, s.state.Load().cert)
|
||||
|
||||
nextCert, nextKey := certificatePEM(t, 2)
|
||||
require.NoError(t, os.WriteFile(crtPath, nextCert, 0o600))
|
||||
beforeMismatch := s.state.Load().cert
|
||||
require.Error(t, s.Reload(), "mismatched pair must retain the active certificate")
|
||||
require.Same(t, beforeMismatch, s.state.Load().cert)
|
||||
require.NoError(t, os.WriteFile(keyPath, nextKey, 0o600))
|
||||
beforeReload, _ := handshake(t, s.TLSConfig)
|
||||
require.Equal(t, int64(1), beforeReload.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
|
||||
require.NoError(t, s.Reload())
|
||||
newClient, _ := handshake(t, s.TLSConfig)
|
||||
require.Equal(t, int64(2), newClient.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
|
||||
|
||||
require.NoError(t, oldClient.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
require.NoError(t, oldServer.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := oldServer.Write([]byte("still connected")); done <- err }()
|
||||
data := make([]byte, len("still connected"))
|
||||
_, err = io.ReadFull(oldClient, data)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-done)
|
||||
require.Equal(t, "still connected", string(data))
|
||||
}
|
||||
|
||||
func TestReloadCertificateKeepsSelfSigned(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "missing")
|
||||
for _, paths := range [][2]string{{"", ""}, {missing, ""}, {"", missing}} {
|
||||
s, err := New(nil, paths[0], paths[1], nil)
|
||||
require.NoError(t, err)
|
||||
original := &s.TLSConfig.Certificates[0]
|
||||
require.Nil(t, s.TLSConfig.GetCertificate)
|
||||
require.NoError(t, s.Reload())
|
||||
require.Same(t, original, &s.TLSConfig.Certificates[0])
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -33,10 +34,16 @@ func init() {
|
||||
Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)
|
||||
}
|
||||
|
||||
type clientRouteController interface {
|
||||
RegisterClientRoute(context.Context, string, []net.IPNet, io.ReadWriteCloser)
|
||||
UnregisterClientRoute(string, io.Writer) bool
|
||||
}
|
||||
|
||||
type VirtualNetPlugin struct {
|
||||
pluginCtx PluginContext
|
||||
|
||||
routes []net.IPNet
|
||||
routeController clientRouteController
|
||||
routes []net.IPNet
|
||||
|
||||
mu sync.Mutex
|
||||
controllerConn net.Conn
|
||||
@@ -48,6 +55,11 @@ type VirtualNetPlugin struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
const (
|
||||
virtualNetReconnectBaseDelay = 60 * time.Second
|
||||
virtualNetReconnectMaxDelay = 300 * time.Second
|
||||
)
|
||||
|
||||
func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {
|
||||
opts := options.(*v1.VirtualNetVisitorPluginOptions)
|
||||
|
||||
@@ -55,6 +67,9 @@ func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOption
|
||||
pluginCtx: pluginCtx,
|
||||
routes: make([]net.IPNet, 0),
|
||||
}
|
||||
if pluginCtx.VnetController != nil {
|
||||
p.routeController = pluginCtx.VnetController
|
||||
}
|
||||
|
||||
p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)
|
||||
|
||||
@@ -85,7 +100,7 @@ func (p *VirtualNetPlugin) Name() string {
|
||||
|
||||
func (p *VirtualNetPlugin) Start() {
|
||||
xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
|
||||
if p.pluginCtx.VnetController == nil {
|
||||
if p.routeController == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,16 +126,17 @@ func (p *VirtualNetPlugin) run() {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name)
|
||||
p.cleanupControllerConn(xl)
|
||||
p.cleanupCurrentControllerConn(xl)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
controllerConn, pluginConn := net.Pipe()
|
||||
|
||||
p.mu.Lock()
|
||||
p.controllerConn = controllerConn
|
||||
p.mu.Unlock()
|
||||
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
|
||||
if !p.registerControllerConn(controllerConn, pluginConn) {
|
||||
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before route registration).", p.pluginCtx.Name)
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap with CloseNotifyConn which supports both close notification and error recording
|
||||
var closeErr error
|
||||
@@ -129,8 +145,6 @@ func (p *VirtualNetPlugin) run() {
|
||||
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)
|
||||
|
||||
// Pass the CloseNotifyConn to the visitor for handling.
|
||||
@@ -141,7 +155,7 @@ func (p *VirtualNetPlugin) run() {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name)
|
||||
p.cleanupControllerConn(xl)
|
||||
p.cleanupControllerConn(xl, controllerConn)
|
||||
return
|
||||
case <-currentCloseSignal:
|
||||
// Determine reconnect delay based on error with exponential backoff
|
||||
@@ -152,8 +166,7 @@ func (p *VirtualNetPlugin) run() {
|
||||
p.pluginCtx.Name, p.consecutiveErrors, closeErr)
|
||||
|
||||
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
|
||||
baseDelay := 60 * time.Second
|
||||
reconnectDelay = min(baseDelay*time.Duration(1<<uint(p.consecutiveErrors-1)), 300*time.Second)
|
||||
reconnectDelay = virtualNetReconnectDelay(p.consecutiveErrors)
|
||||
} else {
|
||||
// Reset consecutive errors on successful connection
|
||||
if p.consecutiveErrors > 0 {
|
||||
@@ -167,7 +180,7 @@ func (p *VirtualNetPlugin) run() {
|
||||
}
|
||||
|
||||
// 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)
|
||||
select {
|
||||
@@ -182,16 +195,66 @@ func (p *VirtualNetPlugin) run() {
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupControllerConn closes the current controllerConn (if it exists) under lock.
|
||||
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) {
|
||||
// registerControllerConn publishes and registers controllerConn atomically with
|
||||
// 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()
|
||||
defer p.mu.Unlock()
|
||||
if p.controllerConn != nil {
|
||||
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
|
||||
p.controllerConn.Close()
|
||||
p.controllerConn = nil
|
||||
p.cleanupControllerConnLocked(xl, controllerConn)
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -202,15 +265,9 @@ func (p *VirtualNetPlugin) Close() error {
|
||||
// Signal the run loop goroutine to stop.
|
||||
p.cancel()
|
||||
|
||||
// Unregister the route from the controller.
|
||||
if p.pluginCtx.VnetController != nil {
|
||||
p.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name)
|
||||
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)
|
||||
// Unregister and close the current connection while holding the same lock
|
||||
// used to check cancellation and register a route in run.
|
||||
p.cleanupCurrentControllerConn(xl)
|
||||
xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name)
|
||||
|
||||
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())
|
||||
}
|
||||
+49
-5
@@ -80,17 +80,23 @@ func newRandomTLSKeyPair() (*tls.Certificate, error) {
|
||||
// Only support one ca file to add
|
||||
func newCertPool(caPath string) (*x509.CertPool, error) {
|
||||
pool := x509.NewCertPool()
|
||||
if err := appendCertsFromFile(pool, caPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func appendCertsFromFile(pool *x509.CertPool, caPath string) error {
|
||||
caCrt, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
if !pool.AppendCertsFromPEM(caCrt) {
|
||||
return nil, fmt.Errorf("failed to parse CA certificate from file %q: no valid PEM certificates found", caPath)
|
||||
return fmt.Errorf("failed to parse CA certificate from file %q: no valid PEM certificates found", caPath)
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
|
||||
@@ -126,6 +132,21 @@ func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
|
||||
}
|
||||
|
||||
func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Config, error) {
|
||||
return NewClientTLSConfigWithSystemCA(certPath, keyPath, caPath, serverName, false)
|
||||
}
|
||||
|
||||
// NewClientTLSConfigWithSystemCA enables verification against system CAs when
|
||||
// useSystemCa is true, adding certificates from caPath if it is also set.
|
||||
// Without either trust source, server certificate verification remains disabled.
|
||||
func NewClientTLSConfigWithSystemCA(certPath, keyPath, caPath, serverName string, useSystemCa bool) (*tls.Config, error) {
|
||||
return newClientTLSConfig(certPath, keyPath, caPath, serverName, useSystemCa, x509.SystemCertPool)
|
||||
}
|
||||
|
||||
func newClientTLSConfig(
|
||||
certPath, keyPath, caPath, serverName string,
|
||||
useSystemCa bool,
|
||||
systemCertPool func() (*x509.CertPool, error),
|
||||
) (*tls.Config, error) {
|
||||
base := &tls.Config{}
|
||||
|
||||
if certPath != "" && keyPath != "" {
|
||||
@@ -139,7 +160,26 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf
|
||||
|
||||
base.ServerName = serverName
|
||||
|
||||
if caPath != "" {
|
||||
switch {
|
||||
case caPath != "" && useSystemCa:
|
||||
// SystemCertPool returns a copy, so appending does not change the system pool.
|
||||
// Design basis: Go 1.26.7 crypto/x509/verify.go:559-573 first uses the
|
||||
// Windows/macOS verifier, then falls back to Go verification using only the
|
||||
// added CAs if platform verification fails. This platform behavior has not been
|
||||
// tested here; preserve the system pool instead of copying its subjects.
|
||||
pool, err := systemCertPool()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load system CA pool: %w", err)
|
||||
}
|
||||
if pool == nil {
|
||||
return nil, fmt.Errorf("failed to load system CA pool: no pool returned")
|
||||
}
|
||||
if err := appendCertsFromFile(pool, caPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base.RootCAs = pool
|
||||
base.InsecureSkipVerify = false
|
||||
case caPath != "":
|
||||
pool, err := newCertPool(caPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -147,7 +187,11 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf
|
||||
|
||||
base.RootCAs = pool
|
||||
base.InsecureSkipVerify = false
|
||||
} else {
|
||||
case useSystemCa:
|
||||
// A nil RootCAs makes crypto/tls use the host root store, through the
|
||||
// platform verifier on macOS and Windows.
|
||||
base.InsecureSkipVerify = false
|
||||
default:
|
||||
base.InsecureSkipVerify = true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// 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 transport
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newSelfSignedCert(t *testing.T, name string) (tls.Certificate, []byte) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
Subject: pkix.Name{CommonName: name},
|
||||
DNSNames: []string{name},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
require.NoError(t, err)
|
||||
return cert, certPEM
|
||||
}
|
||||
|
||||
func TestNewClientTLSConfigVerification(t *testing.T) {
|
||||
_, systemPEM := newSelfSignedCert(t, "system.example.com")
|
||||
_, filePEM := newSelfSignedCert(t, "file.example.com")
|
||||
caFile := filepath.Join(t.TempDir(), "ca.crt")
|
||||
require.NoError(t, os.WriteFile(caFile, filePEM, 0o600))
|
||||
|
||||
systemPool := x509.NewCertPool()
|
||||
require.True(t, systemPool.AppendCertsFromPEM(systemPEM))
|
||||
filePool := x509.NewCertPool()
|
||||
require.True(t, filePool.AppendCertsFromPEM(filePEM))
|
||||
combinedPool := systemPool.Clone()
|
||||
require.True(t, combinedPool.AppendCertsFromPEM(filePEM))
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
caPath string
|
||||
useSystemCa bool
|
||||
insecureSkipVerify bool
|
||||
wantPool *x509.CertPool
|
||||
}{
|
||||
{name: "no trust source", insecureSkipVerify: true},
|
||||
{name: "file CA only", caPath: caFile, wantPool: filePool},
|
||||
{name: "system CA only", useSystemCa: true},
|
||||
{name: "system and file CAs", caPath: caFile, useSystemCa: true, wantPool: combinedPool},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var loadedPool *x509.CertPool
|
||||
loads := 0
|
||||
loadSystemPool := func() (*x509.CertPool, error) {
|
||||
loads++
|
||||
loadedPool = systemPool.Clone()
|
||||
return loadedPool, nil
|
||||
}
|
||||
cfg, err := newClientTLSConfig("", "", test.caPath, "example.com", test.useSystemCa, loadSystemPool)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "example.com", cfg.ServerName)
|
||||
require.Equal(t, test.insecureSkipVerify, cfg.InsecureSkipVerify)
|
||||
if test.wantPool == nil {
|
||||
require.Nil(t, cfg.RootCAs)
|
||||
} else {
|
||||
require.NotNil(t, cfg.RootCAs)
|
||||
require.True(t, cfg.RootCAs.Equal(test.wantPool))
|
||||
}
|
||||
if test.caPath != "" && test.useSystemCa {
|
||||
require.Equal(t, 1, loads)
|
||||
require.Same(t, loadedPool, cfg.RootCAs, "append to the returned system pool")
|
||||
} else {
|
||||
require.Zero(t, loads, "only combined trust needs an explicit system pool")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The four-argument API keeps the original skip/file-only semantics for
|
||||
// xtcp visitors and plugins, regardless of the new transport option.
|
||||
cfg, err := NewClientTLSConfig("", "", "", "example.com")
|
||||
require.NoError(t, err)
|
||||
require.True(t, cfg.InsecureSkipVerify)
|
||||
require.Nil(t, cfg.RootCAs)
|
||||
cfg, err = NewClientTLSConfig("", "", caFile, "example.com")
|
||||
require.NoError(t, err)
|
||||
require.False(t, cfg.InsecureSkipVerify)
|
||||
require.True(t, cfg.RootCAs.Equal(filePool))
|
||||
}
|
||||
|
||||
func TestNewClientTLSConfigCAErrors(t *testing.T) {
|
||||
_, caPEM := newSelfSignedCert(t, "localhost")
|
||||
dir := t.TempDir()
|
||||
caFile := filepath.Join(dir, "ca.crt")
|
||||
badFile := filepath.Join(dir, "bad.crt")
|
||||
require.NoError(t, os.WriteFile(caFile, caPEM, 0o600))
|
||||
require.NoError(t, os.WriteFile(badFile, []byte("not a PEM certificate"), 0o600))
|
||||
systemErr := errors.New("system trust store unavailable")
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
caPath string
|
||||
useSystemCa bool
|
||||
pool *x509.CertPool
|
||||
poolErr error
|
||||
wantErr string
|
||||
}{
|
||||
{name: "file missing", caPath: filepath.Join(dir, "missing.crt"), wantErr: "missing.crt"},
|
||||
{name: "file invalid", caPath: badFile, wantErr: "no valid PEM certificates found"},
|
||||
{
|
||||
name: "combined file missing", caPath: filepath.Join(dir, "missing.crt"), useSystemCa: true,
|
||||
pool: x509.NewCertPool(), wantErr: "missing.crt",
|
||||
},
|
||||
{
|
||||
name: "combined file invalid", caPath: badFile, useSystemCa: true,
|
||||
pool: x509.NewCertPool(), wantErr: "no valid PEM certificates found",
|
||||
},
|
||||
{
|
||||
name: "system pool error", caPath: caFile, useSystemCa: true,
|
||||
pool: x509.NewCertPool(), poolErr: systemErr, wantErr: "failed to load system CA pool",
|
||||
},
|
||||
{name: "nil system pool", caPath: caFile, useSystemCa: true, wantErr: "no pool returned"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg, err := newClientTLSConfig("", "", test.caPath, "localhost", test.useSystemCa,
|
||||
func() (*x509.CertPool, error) { return test.pool, test.poolErr })
|
||||
require.ErrorContains(t, err, test.wantErr)
|
||||
require.Nil(t, cfg, "do not return a partially configured TLS client or fall back")
|
||||
if test.poolErr != nil {
|
||||
require.ErrorIs(t, err, test.poolErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTLSConfigHandshake(t *testing.T) {
|
||||
systemCert, systemPEM := newSelfSignedCert(t, "localhost")
|
||||
fileCert, filePEM := newSelfSignedCert(t, "localhost")
|
||||
untrustedCert, _ := newSelfSignedCert(t, "localhost")
|
||||
caFile := filepath.Join(t.TempDir(), "ca.crt")
|
||||
require.NoError(t, os.WriteFile(caFile, filePEM, 0o600))
|
||||
systemPool := x509.NewCertPool()
|
||||
require.True(t, systemPool.AppendCertsFromPEM(systemPEM))
|
||||
|
||||
for _, server := range []struct {
|
||||
name string
|
||||
cert tls.Certificate
|
||||
}{
|
||||
{name: "system CA", cert: systemCert},
|
||||
{name: "file CA", cert: fileCert},
|
||||
{name: "untrusted CA", cert: untrustedCert},
|
||||
} {
|
||||
t.Run(server.name, func(t *testing.T) {
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{server.cert}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
_ = conn.(*tls.Conn).Handshake()
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
caPath string
|
||||
useSystemCa bool
|
||||
serverName string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "no trust source"},
|
||||
{name: "file CA only", caPath: caFile, wantErr: server.name != "file CA"},
|
||||
// All certificates were just generated and are absent from the real
|
||||
// host store, even on hosts without any installed system roots.
|
||||
{name: "system CA only rejects unknown certificate", useSystemCa: true, wantErr: true},
|
||||
{name: "combined CAs", caPath: caFile, useSystemCa: true, wantErr: server.name == "untrusted CA"},
|
||||
{
|
||||
name: "combined CAs reject wrong name", caPath: caFile, useSystemCa: true,
|
||||
serverName: "other.example.com", wantErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
serverName := test.serverName
|
||||
if serverName == "" {
|
||||
serverName = "localhost"
|
||||
}
|
||||
var cfg *tls.Config
|
||||
var err error
|
||||
if test.caPath != "" && test.useSystemCa {
|
||||
cfg, err = newClientTLSConfig("", "", test.caPath, serverName, true,
|
||||
func() (*x509.CertPool, error) { return systemPool.Clone(), nil })
|
||||
} else {
|
||||
cfg, err = NewClientTLSConfigWithSystemCA("", "", test.caPath, serverName, test.useSystemCa)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", ln.Addr().String(), cfg)
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
if test.wantErr {
|
||||
var verificationErr *tls.CertificateVerificationError
|
||||
require.ErrorAs(t, err, &verificationErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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 (
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
// NewMetricsServer creates a listener with only health, optional metrics, and
|
||||
// opt-in profiling endpoints. A zero port disables it and returns a nil server.
|
||||
func NewMetricsServer(cfg v1.MetricsServerConfig, metricsHandler http.Handler) (*Server, error) {
|
||||
if cfg.Port == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s, err := newServer(cfg.Addr, cfg.Port, cfg.User, cfg.Password, cfg.TLS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.router.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
authed := s.router.NewRoute().Subrouter()
|
||||
authed.Use(s.authMiddleware)
|
||||
if metricsHandler != nil {
|
||||
authed.Handle("/metrics", metricsHandler)
|
||||
}
|
||||
if cfg.PprofEnable {
|
||||
// Use exact routes rather than a prefix so cmdline and unknown endpoints
|
||||
// cannot reach pprof.Index's fallback dispatch.
|
||||
authed.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
authed.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
authed.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
authed.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
for _, name := range []string{"allocs", "block", "goroutine", "heap", "mutex", "threadcreate"} {
|
||||
authed.Handle("/debug/pprof/"+name, pprof.Handler(name))
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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 (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/assets"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func newTestMetricsServer(t *testing.T, pprofEnabled bool, handler http.Handler) *Server {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, ln.Close())
|
||||
s, err := NewMetricsServer(v1.MetricsServerConfig{
|
||||
Addr: "127.0.0.1", Port: port, User: "monitor", Password: "secret", PprofEnable: pprofEnabled,
|
||||
}, handler)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestMetricsServerRoutes(t *testing.T) {
|
||||
for _, enabled := range []bool{false, true} {
|
||||
name := "pprof disabled"
|
||||
if enabled {
|
||||
name = "pprof enabled"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
calls := 0
|
||||
s := newTestMetricsServer(t, enabled, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
_, _ = w.Write([]byte("business metrics"))
|
||||
}))
|
||||
for _, authed := range []bool{false, true} {
|
||||
for _, path := range []string{
|
||||
"/healthz", "/metrics", "/debug/pprof/", "/debug/pprof/goroutine",
|
||||
"/debug/pprof/cmdline", "/debug/pprof/unknown", "/", "/static/app.js", "/api/serverinfo", "/api/config",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if authed {
|
||||
req.SetBasicAuth("monitor", "secret")
|
||||
}
|
||||
resp := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(resp, req)
|
||||
expected := http.StatusNotFound
|
||||
switch path {
|
||||
case "/healthz":
|
||||
expected = http.StatusOK
|
||||
require.Empty(t, resp.Body.String())
|
||||
case "/metrics":
|
||||
expected = http.StatusUnauthorized
|
||||
if authed {
|
||||
expected = http.StatusOK
|
||||
require.Equal(t, "business metrics", resp.Body.String())
|
||||
}
|
||||
case "/debug/pprof/", "/debug/pprof/goroutine":
|
||||
if enabled {
|
||||
expected = http.StatusUnauthorized
|
||||
if authed {
|
||||
expected = http.StatusOK
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Equal(t, expected, resp.Code, "path=%s authenticated=%v", path, authed)
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, calls, "unauthenticated requests must not reach the metrics handler")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsServerWithoutMetricsPreservesAssets(t *testing.T) {
|
||||
assets.Load(t.TempDir())
|
||||
before := assets.FileSystem
|
||||
t.Cleanup(func() { assets.Load("") })
|
||||
s := newTestMetricsServer(t, false, nil)
|
||||
// assets.Load uses http.Dir for local assets, a value rather than a pointer.
|
||||
require.Equal(t, before, assets.FileSystem)
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req.SetBasicAuth("monitor", "secret")
|
||||
resp := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
}
|
||||
|
||||
func TestMetricsServerDisabled(t *testing.T) {
|
||||
s, err := NewMetricsServer(v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "nonexistent"}}, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, s)
|
||||
}
|
||||
+22
-13
@@ -48,15 +48,21 @@ type Server struct {
|
||||
func NewServer(cfg v1.WebServerConfig) (*Server, error) {
|
||||
assets.Load(cfg.AssetsDir)
|
||||
|
||||
addr := net.JoinHostPort(cfg.Addr, strconv.Itoa(cfg.Port))
|
||||
if addr == ":" {
|
||||
addr = ":http"
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
s, err := newServer(cfg.Addr, cfg.Port, cfg.User, cfg.Password, cfg.TLS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.PprofEnable {
|
||||
s.registerPprofHandlers()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func newServer(bindAddr string, port int, user, password string, tlsConfig *v1.TLSConfig) (*Server, error) {
|
||||
addr := net.JoinHostPort(bindAddr, strconv.Itoa(port))
|
||||
if addr == ":" {
|
||||
addr = ":http"
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
hs := &http.Server{
|
||||
@@ -67,15 +73,11 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) {
|
||||
}
|
||||
s := &Server{
|
||||
addr: addr,
|
||||
ln: ln,
|
||||
hs: hs,
|
||||
router: router,
|
||||
}
|
||||
if cfg.PprofEnable {
|
||||
s.registerPprofHandlers()
|
||||
}
|
||||
if cfg.TLS != nil {
|
||||
cert, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
|
||||
if tlsConfig != nil {
|
||||
cert, err := tls.LoadX509KeyPair(tlsConfig.CertFile, tlsConfig.KeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -83,7 +85,14 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) {
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
}
|
||||
s.authMiddleware = netpkg.NewHTTPAuthMiddleware(cfg.User, cfg.Password).SetAuthFailDelay(200 * time.Millisecond).Middleware
|
||||
s.authMiddleware = netpkg.NewHTTPAuthMiddleware(user, password).SetAuthFailDelay(200 * time.Millisecond).Middleware
|
||||
|
||||
// Bind only after all fallible preparation so an error cannot leak the listener.
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ln = ln
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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 (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestNewServerDoesNotLeakListenerOnTLSFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certFile := filepath.Join(dir, "cert.pem")
|
||||
keyFile := filepath.Join(dir, "key.pem")
|
||||
require.NoError(t, os.WriteFile(certFile, []byte("invalid certificate"), 0o600))
|
||||
require.NoError(t, os.WriteFile(keyFile, []byte("invalid key"), 0o600))
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
address := ln.Addr().String()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, ln.Close())
|
||||
|
||||
svr, err := NewServer(v1.WebServerConfig{
|
||||
Addr: "127.0.0.1",
|
||||
Port: port,
|
||||
TLS: &v1.TLSConfig{CertFile: certFile, KeyFile: keyFile},
|
||||
})
|
||||
if svr != nil {
|
||||
defer svr.Close()
|
||||
}
|
||||
require.ErrorContains(t, err, "failed to find any PEM data in certificate input")
|
||||
require.Nil(t, svr)
|
||||
|
||||
ln, err = net.Listen("tcp", address)
|
||||
require.NoError(t, err, "web server port was not released")
|
||||
require.NoError(t, ln.Close())
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func newStandardDateCounterWithClock(reserveDays int64, clk clock.PassiveClock)
|
||||
reserveDays: reserveDays,
|
||||
counts: make([]int64, reserveDays),
|
||||
clock: clk,
|
||||
lastUpdateDate: startOfDay(clk.Now()),
|
||||
lastUpdateDate: calendarDate(clk.Now()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func (c *StandardDateCounter) Clear() {
|
||||
// rotate
|
||||
// Must hold the lock before calling this function.
|
||||
func (c *StandardDateCounter) rotate(now time.Time) {
|
||||
now = startOfDay(now)
|
||||
now = calendarDate(now)
|
||||
days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
|
||||
reserveDays := int(c.reserveDays)
|
||||
|
||||
@@ -132,7 +132,8 @@ func (c *StandardDateCounter) rotate(now time.Time) {
|
||||
c.lastUpdateDate = now
|
||||
}
|
||||
|
||||
// startOfDay returns midnight in t's location.
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
// calendarDate represents t's local date as midnight UTC for day arithmetic.
|
||||
// This avoids variable day lengths and nonexistent local midnights during DST.
|
||||
func calendarDate(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestDateCounter(t *testing.T) {
|
||||
|
||||
func TestDateCounterRotate(t *testing.T) {
|
||||
loc := time.FixedZone("test", 8*60*60)
|
||||
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, loc)
|
||||
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -55,19 +55,19 @@ func TestDateCounterRotate(t *testing.T) {
|
||||
name: "one day",
|
||||
now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc),
|
||||
want: []int64{0, 10, 7},
|
||||
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, loc),
|
||||
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "two days",
|
||||
now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc),
|
||||
want: []int64{0, 0, 10},
|
||||
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, loc),
|
||||
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "all reserved days elapsed",
|
||||
now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc),
|
||||
want: []int64{0, 0, 0},
|
||||
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, loc),
|
||||
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -132,3 +132,63 @@ func TestDateCounterConcurrentAccess(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestDateCounterCalendarDaysAcrossDST(t *testing.T) {
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
require.NoError(t, err)
|
||||
midnightLoc, err := time.LoadLocation("America/Sao_Paulo")
|
||||
require.NoError(t, err)
|
||||
tests := []struct {
|
||||
name string
|
||||
start time.Time
|
||||
end time.Time
|
||||
want []int64
|
||||
}{
|
||||
{
|
||||
name: "spring next day",
|
||||
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
|
||||
end: time.Date(2026, time.March, 9, 12, 0, 0, 0, loc),
|
||||
want: []int64{0, 10, 0},
|
||||
},
|
||||
{
|
||||
name: "spring two days",
|
||||
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
|
||||
end: time.Date(2026, time.March, 10, 12, 0, 0, 0, loc),
|
||||
want: []int64{0, 0, 10},
|
||||
},
|
||||
{
|
||||
name: "spring retention expiry",
|
||||
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
|
||||
end: time.Date(2026, time.March, 11, 12, 0, 0, 0, loc),
|
||||
want: []int64{0, 0, 0},
|
||||
},
|
||||
{
|
||||
name: "fall next day",
|
||||
start: time.Date(2026, time.November, 1, 12, 0, 0, 0, loc),
|
||||
end: time.Date(2026, time.November, 2, 12, 0, 0, 0, loc),
|
||||
want: []int64{0, 10, 0},
|
||||
},
|
||||
{
|
||||
name: "skipped midnight",
|
||||
start: time.Date(2018, time.November, 3, 12, 0, 0, 0, midnightLoc),
|
||||
end: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
|
||||
want: []int64{0, 10, 0},
|
||||
},
|
||||
{
|
||||
name: "day after skipped midnight",
|
||||
start: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
|
||||
end: time.Date(2018, time.November, 5, 12, 0, 0, 0, midnightLoc),
|
||||
want: []int64{0, 10, 0},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clk := clocktesting.NewFakeClock(tt.start)
|
||||
dc := newStandardDateCounterWithClock(3, clk)
|
||||
dc.Inc(10)
|
||||
clk.SetTime(tt.end)
|
||||
require.Equal(t, tt.want[0], dc.TodayCount())
|
||||
require.Equal(t, tt.want, dc.GetLastDaysCount(3))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+37
-18
@@ -15,16 +15,21 @@
|
||||
package net
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
kcp "github.com/xtaci/kcp-go/v5"
|
||||
)
|
||||
|
||||
var ErrKCPListenerClosed = errors.New("kcp listener closed")
|
||||
|
||||
type KCPListener struct {
|
||||
listener net.Listener
|
||||
acceptCh chan net.Conn
|
||||
closeFlag bool
|
||||
listener net.Listener
|
||||
acceptCh chan net.Conn
|
||||
done chan struct{}
|
||||
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func ListenKcp(address string) (l *KCPListener, err error) {
|
||||
@@ -36,20 +41,21 @@ func ListenKcp(address string) (l *KCPListener, err error) {
|
||||
_ = listener.SetWriteBuffer(4194304)
|
||||
|
||||
l = &KCPListener{
|
||||
listener: listener,
|
||||
acceptCh: make(chan net.Conn),
|
||||
closeFlag: false,
|
||||
listener: listener,
|
||||
acceptCh: make(chan net.Conn),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.AcceptKCP()
|
||||
if err != nil {
|
||||
if l.closeFlag {
|
||||
close(l.acceptCh)
|
||||
select {
|
||||
case <-l.done:
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
conn.SetStreamMode(true)
|
||||
conn.SetWriteDelay(true)
|
||||
@@ -58,25 +64,38 @@ func ListenKcp(address string) (l *KCPListener, err error) {
|
||||
conn.SetWindowSize(1024, 1024)
|
||||
conn.SetACKNoDelay(false)
|
||||
|
||||
l.acceptCh <- conn
|
||||
select {
|
||||
case <-l.done:
|
||||
conn.Close()
|
||||
return
|
||||
case l.acceptCh <- conn:
|
||||
// A ready handoff may win when Close races with it. Once delivered,
|
||||
// the connection is owned by the caller of Accept.
|
||||
}
|
||||
}
|
||||
}()
|
||||
return l, err
|
||||
}
|
||||
|
||||
func (l *KCPListener) Accept() (net.Conn, error) {
|
||||
conn, ok := <-l.acceptCh
|
||||
if !ok {
|
||||
return conn, fmt.Errorf("channel for kcp listener closed")
|
||||
select {
|
||||
case <-l.done:
|
||||
return nil, ErrKCPListenerClosed
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-l.done:
|
||||
return nil, ErrKCPListenerClosed
|
||||
case conn := <-l.acceptCh:
|
||||
return conn, nil
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (l *KCPListener) Close() error {
|
||||
if !l.closeFlag {
|
||||
l.closeFlag = true
|
||||
l.closeOnce.Do(func() {
|
||||
close(l.done)
|
||||
l.listener.Close()
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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 net
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net"
|
||||
"runtime/pprof"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
kcp "github.com/xtaci/kcp-go/v5"
|
||||
)
|
||||
|
||||
func kcpGoroutineDump(t *testing.T) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, pprof.Lookup("goroutine").WriteTo(&buf, 2))
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestKCPListenerCloseCancelsPendingHandoff(t *testing.T) {
|
||||
l, err := ListenKcp("127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = l.Close()
|
||||
// Also release a stuck handoff when this test runs against the old code.
|
||||
select {
|
||||
case conn := <-l.acceptCh:
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
sessionsBefore := atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab)
|
||||
peer, err := kcp.DialWithOptions(l.Addr().String(), nil, 10, 3)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = peer.Close() })
|
||||
peer.SetNoDelay(1, 10, 2, 1)
|
||||
require.NoError(t, peer.SetWriteDeadline(time.Now().Add(5*time.Second)))
|
||||
_, err = peer.Write([]byte("pending handoff"))
|
||||
require.NoError(t, err)
|
||||
|
||||
var loopID string
|
||||
require.Eventually(t, func() bool {
|
||||
for stack := range strings.SplitSeq(kcpGoroutineDump(t), "\n\n") {
|
||||
if !strings.Contains(stack, "github.com/fatedier/frp/pkg/util/net.ListenKcp.func1()") ||
|
||||
strings.Contains(stack, "github.com/xtaci/kcp-go/v5.(*Listener).AcceptKCP(") {
|
||||
continue
|
||||
}
|
||||
header, _, _ := strings.Cut(stack, "\n")
|
||||
if strings.Contains(header, "[select]") || strings.Contains(header, "[chan send]") {
|
||||
loopID = strings.Fields(header)[1]
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, time.Millisecond, "accept loop did not reach the unconsumed handoff")
|
||||
require.Equal(t, sessionsBefore+2, atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab))
|
||||
|
||||
require.NoError(t, l.Close())
|
||||
require.Eventually(t, func() bool {
|
||||
return !strings.Contains(kcpGoroutineDump(t), "goroutine "+loopID+" [")
|
||||
}, 5*time.Second, time.Millisecond, "accept loop remained blocked after Close")
|
||||
|
||||
// kcp-go has no close frame. Its session counter decreases only when the
|
||||
// session itself is closed; closing the listener socket is not sufficient.
|
||||
// Keep the client open so this decrement must belong to the pending server session.
|
||||
require.Equal(t, sessionsBefore+1, atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab))
|
||||
require.NoError(t, peer.Close())
|
||||
require.Equal(t, sessionsBefore, atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab))
|
||||
}
|
||||
|
||||
func TestKCPListenerAcceptUnblocksOnClose(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
l := &KCPListener{
|
||||
listener: &closeStubListener{closed: make(chan struct{}), err: errors.New("listener close failed")},
|
||||
acceptCh: make(chan net.Conn),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
acceptErr := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if conn != nil {
|
||||
t.Error("Accept returned a connection without a sender")
|
||||
}
|
||||
acceptErr <- err
|
||||
}()
|
||||
synctest.Wait() // Accept is blocked before Close.
|
||||
require.Empty(t, acceptErr)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 2 {
|
||||
wg.Go(func() {
|
||||
if err := l.Close(); err != nil {
|
||||
t.Errorf("Close: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
synctest.Wait()
|
||||
require.Len(t, acceptErr, 1)
|
||||
require.ErrorIs(t, <-acceptErr, ErrKCPListenerClosed)
|
||||
|
||||
require.NoError(t, l.Close())
|
||||
conn, err := l.Accept()
|
||||
require.Nil(t, conn)
|
||||
require.ErrorIs(t, err, ErrKCPListenerClosed)
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
@@ -18,8 +19,11 @@ const (
|
||||
type WebsocketListener struct {
|
||||
ln net.Listener
|
||||
acceptCh chan net.Conn
|
||||
done chan struct{}
|
||||
|
||||
server *http.Server
|
||||
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewWebsocketListener to handle websocket connections
|
||||
@@ -28,6 +32,7 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
||||
wl = &WebsocketListener{
|
||||
ln: ln,
|
||||
acceptCh: make(chan net.Conn),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
muxer := http.NewServeMux()
|
||||
@@ -41,7 +46,14 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||
close(notifyCh)
|
||||
})
|
||||
wl.acceptCh <- conn
|
||||
select {
|
||||
case <-wl.done:
|
||||
conn.Close()
|
||||
return
|
||||
case wl.acceptCh <- conn:
|
||||
// A ready handoff may win when Close races with it. Once delivered,
|
||||
// the connection is owned by the caller of Accept.
|
||||
}
|
||||
<-notifyCh
|
||||
}))
|
||||
|
||||
@@ -58,14 +70,21 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
||||
}
|
||||
|
||||
func (p *WebsocketListener) Accept() (net.Conn, error) {
|
||||
c, ok := <-p.acceptCh
|
||||
if !ok {
|
||||
select {
|
||||
case <-p.done:
|
||||
return nil, ErrWebsocketListenerClosed
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-p.done:
|
||||
return nil, ErrWebsocketListenerClosed
|
||||
case c := <-p.acceptCh:
|
||||
return c, nil
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (p *WebsocketListener) Close() error {
|
||||
p.closeOnce.Do(func() { close(p.done) })
|
||||
return p.server.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// 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 net
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
type closeStubListener struct {
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *closeStubListener) Accept() (net.Conn, error) {
|
||||
<-l.closed
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
|
||||
func (l *closeStubListener) Close() error {
|
||||
l.once.Do(func() { close(l.closed) })
|
||||
return l.err
|
||||
}
|
||||
|
||||
func (l *closeStubListener) Addr() net.Addr {
|
||||
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}
|
||||
}
|
||||
|
||||
func TestWebsocketListenerAcceptUnblocksOnClose(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
|
||||
t.Cleanup(func() { wl.Close() })
|
||||
errCh := make(chan error, 3)
|
||||
for range 3 {
|
||||
go func() {
|
||||
c, err := wl.Accept()
|
||||
if c != nil {
|
||||
t.Error("Accept returned a connection without a sender")
|
||||
}
|
||||
errCh <- err
|
||||
}()
|
||||
}
|
||||
synctest.Wait() // All Accept calls are blocked before Close.
|
||||
require.Empty(t, errCh)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 3 {
|
||||
wg.Go(func() {
|
||||
if err := wl.Close(); err != nil {
|
||||
t.Errorf("Close: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
synctest.Wait()
|
||||
require.Len(t, errCh, 3)
|
||||
for range 3 {
|
||||
require.ErrorIs(t, <-errCh, ErrWebsocketListenerClosed)
|
||||
}
|
||||
c, err := wl.Accept()
|
||||
require.Nil(t, c)
|
||||
require.ErrorIs(t, err, ErrWebsocketListenerClosed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebsocketListenerCloseError(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
closeErr := errors.New("listener close failed")
|
||||
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{}), err: closeErr})
|
||||
synctest.Wait() // Let Serve register its listener before Close.
|
||||
require.ErrorIs(t, wl.Close(), closeErr)
|
||||
require.NoError(t, wl.Close())
|
||||
})
|
||||
}
|
||||
|
||||
type websocketPipeHijacker struct {
|
||||
*httptest.ResponseRecorder
|
||||
conn net.Conn
|
||||
rw *bufio.ReadWriter
|
||||
}
|
||||
|
||||
func (h *websocketPipeHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return h.conn, h.rw, nil
|
||||
}
|
||||
|
||||
func newWebsocketPipe(t *testing.T) (*WebsocketListener, *websocket.Conn, net.Conn, <-chan struct{}) {
|
||||
t.Helper()
|
||||
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
|
||||
serverConn, clientConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
wl.Close()
|
||||
serverConn.Close()
|
||||
clientConn.Close()
|
||||
})
|
||||
require.NoError(t, serverConn.SetDeadline(time.Now().Add(time.Second)))
|
||||
require.NoError(t, clientConn.SetDeadline(time.Now().Add(time.Second)))
|
||||
handlerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(handlerDone)
|
||||
rw := bufio.NewReadWriter(bufio.NewReader(serverConn), bufio.NewWriter(serverConn))
|
||||
req, err := http.ReadRequest(rw.Reader)
|
||||
if err != nil {
|
||||
t.Errorf("ReadRequest: %v", err)
|
||||
return
|
||||
}
|
||||
wl.server.Handler.ServeHTTP(&websocketPipeHijacker{httptest.NewRecorder(), serverConn, rw}, req)
|
||||
}()
|
||||
cfg, err := websocket.NewConfig("ws://localhost"+FrpWebsocketPath, "http://localhost")
|
||||
require.NoError(t, err)
|
||||
peer, err := websocket.NewClient(cfg, clientConn)
|
||||
require.NoError(t, err)
|
||||
return wl, peer, serverConn, handlerDone
|
||||
}
|
||||
|
||||
func TestWebsocketListenerUnacceptedConnectionClose(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
wl, peer, raw, handlerDone := newWebsocketPipe(t)
|
||||
peerErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := peer.Read(make([]byte, 1))
|
||||
peerErr <- err
|
||||
}()
|
||||
// No Accept consumer exists. Do not Wait here: it would synchronize
|
||||
// the handler's send with Close and hide the baseline send/close race.
|
||||
require.NoError(t, wl.Close())
|
||||
require.ErrorIs(t, <-peerErr, io.EOF)
|
||||
<-handlerDone
|
||||
_, err := raw.Read(make([]byte, 1))
|
||||
require.ErrorIs(t, err, io.ErrClosedPipe)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebsocketListenerAcceptedConnectionOwnership(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
wl, peer, raw, handlerDone := newWebsocketPipe(t)
|
||||
owner, err := wl.Accept()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
raw.Close()
|
||||
owner.Close()
|
||||
})
|
||||
require.NoError(t, wl.Close())
|
||||
|
||||
payload := []byte{0, 255, 128, 42}
|
||||
reply := []byte("reply")
|
||||
gotReply := make([]byte, len(reply))
|
||||
peerErr := make(chan error, 2)
|
||||
go func() {
|
||||
if _, err := peer.Write(payload); err != nil {
|
||||
peerErr <- err
|
||||
return
|
||||
}
|
||||
_, err := io.ReadFull(peer, gotReply)
|
||||
peerErr <- err
|
||||
_, err = peer.Read(make([]byte, 1))
|
||||
peerErr <- err
|
||||
}()
|
||||
got := make([]byte, len(payload))
|
||||
_, err = io.ReadFull(owner, got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, payload, got)
|
||||
_, err = owner.Write(reply)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-peerErr)
|
||||
require.Equal(t, reply, gotReply)
|
||||
require.NoError(t, owner.Close())
|
||||
require.NoError(t, owner.Close())
|
||||
require.ErrorIs(t, <-peerErr, io.EOF)
|
||||
<-handlerDone
|
||||
_, err = raw.Read(make([]byte, 1))
|
||||
require.ErrorIs(t, err, io.ErrClosedPipe)
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
package version
|
||||
|
||||
var version = "0.71.0"
|
||||
var version = "0.71.1"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
+23
-3
@@ -206,13 +206,29 @@ func checkRouteAuthByRequest(req *http.Request, rc *RouteConfig) bool {
|
||||
}
|
||||
|
||||
func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.ContentLength > 0 || len(req.TransferEncoding) > 0 {
|
||||
http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hj, ok := rw.(http.Hijacker)
|
||||
if !ok {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
client, _, err := hj.Hijack()
|
||||
// Forward only CONNECT headers; the original Body must not be used after Hijack.
|
||||
outreq := req.Clone(req.Context())
|
||||
outreq.Body = nil
|
||||
outreq.GetBody = nil
|
||||
outreq.ContentLength = 0
|
||||
outreq.TransferEncoding = nil
|
||||
outreq.Trailer = nil
|
||||
outreq.Header.Del("Content-Length")
|
||||
outreq.Header.Del("Transfer-Encoding")
|
||||
outreq.Header.Del("Trailer")
|
||||
|
||||
client, buffered, err := hj.Hijack()
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
@@ -224,8 +240,12 @@ func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Req
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
_ = req.Write(remote)
|
||||
go libio.Join(remote, client)
|
||||
if err := outreq.Write(remote); err != nil {
|
||||
_ = remote.Close()
|
||||
_ = client.Close()
|
||||
return
|
||||
}
|
||||
go libio.Join(remote, libio.WrapReadWriteCloser(buffered.Reader, client, client.Close))
|
||||
}
|
||||
|
||||
func getRequestRouteUser(req *http.Request) string {
|
||||
|
||||
@@ -3,17 +3,266 @@ package vhost
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
)
|
||||
|
||||
func TestHTTPConnectTunnel(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
framing string
|
||||
pipelined bool
|
||||
}{
|
||||
{name: "plain"},
|
||||
{name: "plain-early", pipelined: true},
|
||||
{name: "cl0", framing: "Content-Length: 0\r\n"},
|
||||
{name: "cl0-early", framing: "Content-Length: 0\r\n", pipelined: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
remote, backend := net.Pipe()
|
||||
defer remote.Close()
|
||||
defer backend.Close()
|
||||
require.NoError(t, backend.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
|
||||
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
||||
require.NoError(t, rp.Register(RouteConfig{
|
||||
Domain: "target.example.com",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
CreateConnFn: func(string) (net.Conn, error) {
|
||||
return remote, nil
|
||||
},
|
||||
}))
|
||||
listener := netpkg.NewInternalListener()
|
||||
server := &http.Server{Handler: rp, ReadHeaderTimeout: time.Second}
|
||||
serveErr := make(chan error, 1)
|
||||
go func() {
|
||||
serveErr <- server.Serve(listener)
|
||||
}()
|
||||
defer func() {
|
||||
require.NoError(t, server.Close())
|
||||
require.ErrorIs(t, <-serveErr, http.ErrServerClosed)
|
||||
}()
|
||||
|
||||
client, incoming := net.Pipe()
|
||||
defer client.Close()
|
||||
defer incoming.Close()
|
||||
require.NoError(t, client.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
require.NoError(t, listener.PutConn(incoming))
|
||||
|
||||
proxyAuth := httppkg.BasicAuth("alice", "secret")
|
||||
request := "CONNECT target.example.com:443 HTTP/1.1\r\nHost: target.example.com:443\r\n" +
|
||||
"X-Connect-Test: tunnel\r\nProxy-Authorization: " + proxyAuth + "\r\n" + tc.framing + "\r\n"
|
||||
if tc.pipelined {
|
||||
// One pipe write puts the tunnel data in net/http's request buffer.
|
||||
request += "early data"
|
||||
}
|
||||
_, err := io.WriteString(client, request)
|
||||
require.NoError(t, err)
|
||||
backendReader := bufio.NewReader(backend)
|
||||
req, err := http.ReadRequest(backendReader)
|
||||
require.NoError(t, err)
|
||||
defer req.Body.Close()
|
||||
require.Equal(t, http.MethodConnect, req.Method)
|
||||
require.Equal(t, "target.example.com:443", req.Host)
|
||||
require.Equal(t, "target.example.com:443", req.URL.Host)
|
||||
require.Equal(t, "tunnel", req.Header.Get("X-Connect-Test"))
|
||||
require.Equal(t, proxyAuth, req.Header.Get("Proxy-Authorization"))
|
||||
require.Zero(t, req.ContentLength)
|
||||
require.Empty(t, req.TransferEncoding)
|
||||
if tc.pipelined {
|
||||
data := make([]byte, len("early data"))
|
||||
_, err = io.ReadFull(backendReader, data)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "early data", string(data))
|
||||
}
|
||||
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := io.WriteString(backend, "HTTP/1.1 200 Connection Established\r\n\r\nreply")
|
||||
writeErr <- err
|
||||
}()
|
||||
clientReader := bufio.NewReader(client)
|
||||
response, err := http.ReadResponse(clientReader, req)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
reply := make([]byte, len("reply"))
|
||||
_, err = io.ReadFull(clientReader, reply)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "reply", string(reply))
|
||||
require.NoError(t, <-writeErr)
|
||||
|
||||
_, err = io.WriteString(client, "later data")
|
||||
require.NoError(t, err)
|
||||
data := make([]byte, len("later data"))
|
||||
_, err = io.ReadFull(backendReader, data)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "later data", string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPConnectRejectsContent(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
framing string
|
||||
body string
|
||||
}{
|
||||
{name: "content-length", framing: "Content-Length: 1\r\n", body: "X"},
|
||||
{name: "chunked", framing: "Transfer-Encoding: chunked\r\n", body: "1\r\nX\r\n0\r\n\r\n"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
backendCalls := 0
|
||||
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
||||
require.NoError(t, rp.Register(RouteConfig{
|
||||
Domain: "target.example.com",
|
||||
CreateConnFn: func(string) (net.Conn, error) {
|
||||
backendCalls++
|
||||
return nil, io.ErrClosedPipe
|
||||
},
|
||||
}))
|
||||
req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(
|
||||
"CONNECT target.example.com:443 HTTP/1.1\r\nHost: target.example.com:443\r\n" + tc.framing + "\r\n" + tc.body)))
|
||||
require.NoError(t, err)
|
||||
defer req.Body.Close()
|
||||
rw := &connectTestResponseWriter{ResponseRecorder: httptest.NewRecorder()}
|
||||
|
||||
rp.ServeHTTP(rw, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rw.Code)
|
||||
require.Zero(t, rw.hijackCalls, "reject content before Hijack")
|
||||
require.Zero(t, backendCalls, "reject content before creating a backend")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPConnectHeaderWriteFailure(t *testing.T) {
|
||||
for _, originalBody := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("originalBody=%v", originalBody), func(t *testing.T) {
|
||||
client, incoming := net.Pipe()
|
||||
defer client.Close()
|
||||
defer incoming.Close()
|
||||
remote, backend := net.Pipe()
|
||||
defer remote.Close()
|
||||
defer backend.Close()
|
||||
clientConn := &connectTestConn{Conn: incoming}
|
||||
remoteConn := &connectTestConn{Conn: remote, failWrites: true}
|
||||
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
||||
require.NoError(t, rp.Register(RouteConfig{
|
||||
Domain: "target.example.com",
|
||||
CreateConnFn: func(string) (net.Conn, error) {
|
||||
return remoteConn, nil
|
||||
},
|
||||
}))
|
||||
req := rp.injectRequestInfoToCtx(httptest.NewRequest(http.MethodConnect, "target.example.com:443", nil))
|
||||
require.Equal(t, "target.example.com:443", req.Host)
|
||||
body := &connectTestBody{}
|
||||
if originalBody {
|
||||
req.Body = body
|
||||
}
|
||||
requestBody := req.Body
|
||||
getBodyCalls := 0
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
getBodyCalls++
|
||||
return body, nil
|
||||
}
|
||||
req.Header.Set("Content-Length", "0")
|
||||
req.Header.Set("Trailer", "X-Connect-Trailer")
|
||||
req.Trailer = http.Header{"X-Connect-Trailer": {"value"}}
|
||||
originalHeader, originalTrailer := req.Header.Clone(), req.Trailer.Clone()
|
||||
rw := &connectTestResponseWriter{
|
||||
ResponseRecorder: httptest.NewRecorder(),
|
||||
conn: clientConn,
|
||||
buffered: bufio.NewReadWriter(bufio.NewReader(clientConn), bufio.NewWriter(clientConn)),
|
||||
}
|
||||
|
||||
rp.connectHandler(rw, req)
|
||||
|
||||
require.Equal(t, 1, rw.hijackCalls)
|
||||
require.Zero(t, body.reads, "do not read the original Body after Hijack")
|
||||
require.Zero(t, body.closes, "do not close the original Body after Hijack")
|
||||
require.Positive(t, remoteConn.writes.Load(), "exercise CONNECT header write failure")
|
||||
require.True(t, remoteConn.closed.Load(), "close backend on CONNECT header write failure")
|
||||
require.True(t, clientConn.closed.Load(), "close client on CONNECT header write failure")
|
||||
require.Zero(t, remoteConn.reads.Load(), "do not start Join after header write failure")
|
||||
require.Zero(t, clientConn.reads.Load(), "do not start Join after header write failure")
|
||||
require.Zero(t, getBodyCalls)
|
||||
require.Equal(t, requestBody, req.Body)
|
||||
require.NotNil(t, req.GetBody)
|
||||
require.Equal(t, originalHeader, req.Header)
|
||||
require.Equal(t, originalTrailer, req.Trailer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type connectTestResponseWriter struct {
|
||||
*httptest.ResponseRecorder
|
||||
conn net.Conn
|
||||
buffered *bufio.ReadWriter
|
||||
hijackCalls int
|
||||
}
|
||||
|
||||
func (rw *connectTestResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
rw.hijackCalls++
|
||||
if rw.conn == nil {
|
||||
return nil, nil, io.ErrClosedPipe
|
||||
}
|
||||
return rw.conn, rw.buffered, nil
|
||||
}
|
||||
|
||||
type connectTestConn struct {
|
||||
net.Conn
|
||||
failWrites bool
|
||||
closed atomic.Bool
|
||||
reads atomic.Int32
|
||||
writes atomic.Int32
|
||||
}
|
||||
|
||||
func (c *connectTestConn) Read(p []byte) (int, error) {
|
||||
c.reads.Add(1)
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
|
||||
func (c *connectTestConn) Write(p []byte) (int, error) {
|
||||
c.writes.Add(1)
|
||||
if c.failWrites {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
return c.Conn.Write(p)
|
||||
}
|
||||
|
||||
func (c *connectTestConn) Close() error {
|
||||
c.closed.Store(true)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
type connectTestBody struct {
|
||||
reads int
|
||||
closes int
|
||||
}
|
||||
|
||||
func (b *connectTestBody) Read([]byte) (int, error) {
|
||||
b.reads++
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
func (b *connectTestBody) Close() error {
|
||||
b.closes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHTTPServerProtocols(t *testing.T) {
|
||||
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
||||
protocols := new(http.Protocols)
|
||||
|
||||
+29
-10
@@ -17,6 +17,8 @@ package xlog
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
)
|
||||
@@ -30,11 +32,13 @@ type LogPrefix struct {
|
||||
Priority int
|
||||
}
|
||||
|
||||
// Logger is not thread safety for operations on prefix
|
||||
// Logger supports concurrent logging and prefix updates.
|
||||
// A Logger must not be copied after first use.
|
||||
type Logger struct {
|
||||
mu sync.Mutex
|
||||
prefixes []LogPrefix
|
||||
|
||||
prefixString string
|
||||
prefixString atomic.Pointer[string]
|
||||
}
|
||||
|
||||
func New() *Logger {
|
||||
@@ -44,9 +48,11 @@ func New() *Logger {
|
||||
}
|
||||
|
||||
func (l *Logger) ResetPrefixes() (old []LogPrefix) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
old = l.prefixes
|
||||
l.prefixes = make([]LogPrefix, 0)
|
||||
l.prefixString = ""
|
||||
l.prefixString.Store(nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,6 +65,8 @@ func (l *Logger) AppendPrefix(prefix string) *Logger {
|
||||
}
|
||||
|
||||
func (l *Logger) AddPrefix(prefix LogPrefix) *Logger {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
found := false
|
||||
if prefix.Priority <= 0 {
|
||||
prefix.Priority = 10
|
||||
@@ -78,39 +86,50 @@ func (l *Logger) AddPrefix(prefix LogPrefix) *Logger {
|
||||
return l
|
||||
}
|
||||
|
||||
// The caller must hold l.mu or exclusively own an unpublished logger.
|
||||
func (l *Logger) renderPrefixString() {
|
||||
slices.SortStableFunc(l.prefixes, func(a, b LogPrefix) int {
|
||||
return cmp.Compare(a.Priority, b.Priority)
|
||||
})
|
||||
l.prefixString = ""
|
||||
var prefixString string
|
||||
for _, v := range l.prefixes {
|
||||
l.prefixString += "[" + v.Value + "] "
|
||||
prefixString += "[" + v.Value + "] "
|
||||
}
|
||||
l.prefixString.Store(&prefixString)
|
||||
}
|
||||
|
||||
func (l *Logger) Spawn() *Logger {
|
||||
nl := New()
|
||||
l.mu.Lock()
|
||||
nl.prefixes = append(nl.prefixes, l.prefixes...)
|
||||
l.mu.Unlock()
|
||||
nl.renderPrefixString()
|
||||
return nl
|
||||
}
|
||||
|
||||
func (l *Logger) getPrefixString() string {
|
||||
if prefix := l.prefixString.Load(); prefix != nil {
|
||||
return *prefix
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (l *Logger) Errorf(format string, v ...any) {
|
||||
log.Logger.WithPrefix(l.prefixString).Errorf(format, v...)
|
||||
log.Logger.WithPrefix(l.getPrefixString()).Errorf(format, v...)
|
||||
}
|
||||
|
||||
func (l *Logger) Warnf(format string, v ...any) {
|
||||
log.Logger.WithPrefix(l.prefixString).Warnf(format, v...)
|
||||
log.Logger.WithPrefix(l.getPrefixString()).Warnf(format, v...)
|
||||
}
|
||||
|
||||
func (l *Logger) Infof(format string, v ...any) {
|
||||
log.Logger.WithPrefix(l.prefixString).Infof(format, v...)
|
||||
log.Logger.WithPrefix(l.getPrefixString()).Infof(format, v...)
|
||||
}
|
||||
|
||||
func (l *Logger) Debugf(format string, v ...any) {
|
||||
log.Logger.WithPrefix(l.prefixString).Debugf(format, v...)
|
||||
log.Logger.WithPrefix(l.getPrefixString()).Debugf(format, v...)
|
||||
}
|
||||
|
||||
func (l *Logger) Tracef(format string, v ...any) {
|
||||
log.Logger.WithPrefix(l.prefixString).Tracef(format, v...)
|
||||
log.Logger.WithPrefix(l.getPrefixString()).Tracef(format, v...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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 (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoggerConcurrentPrefixAccess(t *testing.T) {
|
||||
captureLogs(t)
|
||||
xl := New().AddPrefix(LogPrefix{Name: "runID", Value: "old-run"})
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
xl.AddPrefix(LogPrefix{Name: "runID", Value: "new-run"})
|
||||
xl.AppendPrefix("proxy")
|
||||
xl.ResetPrefixes()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
xl.Spawn().Tracef("spawn")
|
||||
xl.Errorf("error")
|
||||
xl.Warnf("warn")
|
||||
xl.Infof("info")
|
||||
xl.Debugf("debug")
|
||||
xl.Tracef("trace")
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -246,9 +246,9 @@ func (c *Controller) RegisterClientRoute(ctx context.Context, name string, route
|
||||
go c.readLoopClient(ctx, conn)
|
||||
}
|
||||
|
||||
// UnregisterClientRoute Remove client route from routing table
|
||||
func (c *Controller) UnregisterClientRoute(name string) {
|
||||
c.clientRouter.delRoute(name)
|
||||
// UnregisterClientRoute removes a client route only when it is still owned by conn.
|
||||
func (c *Controller) UnregisterClientRoute(name string, conn io.Writer) bool {
|
||||
return c.clientRouter.delRoute(name, conn)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func (r *clientRouter) delRoute(name string) {
|
||||
func (r *clientRouter) delRoute(name string, conn io.Writer) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
re, ok := r.routes[name]
|
||||
if !ok || re.conn != conn {
|
||||
return false
|
||||
}
|
||||
delete(r.routes, name)
|
||||
return true
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -615,7 +615,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
name: "tcp remote port",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: new(6000)},
|
||||
}},
|
||||
q: "6000",
|
||||
want: true,
|
||||
@@ -624,7 +624,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
name: "udp remote port",
|
||||
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "udp",
|
||||
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
|
||||
UDP: &model.V2UDPProxySpec{RemotePort: new(7000)},
|
||||
}},
|
||||
q: "7000",
|
||||
want: true,
|
||||
@@ -633,7 +633,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
name: "remote port does not match colon form",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||
Type: "tcp",
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
||||
TCP: &model.V2TCPProxySpec{RemotePort: new(6000)},
|
||||
}},
|
||||
q: ":6000",
|
||||
want: false,
|
||||
@@ -767,10 +767,6 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func v2TestIntPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newV2TestController(t *testing.T) *Controller {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestServiceMetricsServerWithoutWebServer(t *testing.T) {
|
||||
cfg := &v1.ServerConfig{
|
||||
BindAddr: "127.0.0.1", ProxyBindAddr: "127.0.0.1",
|
||||
MetricsServer: v1.MetricsServerConfig{Port: freeLifecycleTCPPort(t)},
|
||||
}
|
||||
require.NoError(t, cfg.Complete())
|
||||
cfg.BindPort = 0
|
||||
svr, err := NewService(cfg)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, svr.webServer)
|
||||
address := svr.metricsServer.Address()
|
||||
done := make(chan struct{})
|
||||
go func() { svr.Run(context.Background()); close(done) }()
|
||||
defer func() {
|
||||
require.NoError(t, svr.Close())
|
||||
waitForSignal(t, done, "metrics service shutdown")
|
||||
assertLifecyclePorts(t, []string{address}, true)
|
||||
}()
|
||||
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get("http://" + address + "/healthz")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, body)
|
||||
}
|
||||
|
||||
func TestNewServiceReleasesResourcesOnMetricsServerFailure(t *testing.T) {
|
||||
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
cfg := &v1.ServerConfig{BindAddr: "127.0.0.1", ProxyBindAddr: "127.0.0.1"}
|
||||
require.NoError(t, cfg.Complete())
|
||||
// Reserve distinct ports together, including the second HTTP listener.
|
||||
ports := []*int{&cfg.BindPort, &cfg.VhostHTTPPort, &cfg.WebServer.Port, &cfg.MetricsServer.Port}
|
||||
reservations := make([]net.Listener, 0, len(ports))
|
||||
addresses := make([]string, 0, len(ports))
|
||||
for _, port := range ports {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
*port = ln.Addr().(*net.TCPAddr).Port
|
||||
reservations = append(reservations, ln)
|
||||
addresses = append(addresses, ln.Addr().String())
|
||||
}
|
||||
for _, ln := range reservations[:len(reservations)-1] {
|
||||
require.NoError(t, ln.Close())
|
||||
}
|
||||
if mode == "invalid TLS certificate" {
|
||||
require.NoError(t, reservations[len(reservations)-1].Close())
|
||||
cfg.MetricsServer.TLS = &v1.TLSConfig{
|
||||
CertFile: filepath.Join(t.TempDir(), "missing.crt"), KeyFile: "missing.key",
|
||||
}
|
||||
} else {
|
||||
// This port belongs to the test, not to the failed service.
|
||||
addresses = addresses[:len(addresses)-1]
|
||||
}
|
||||
workersBefore := countPortManagerWorkers(t)
|
||||
for range 5 {
|
||||
svr, err := NewService(cfg)
|
||||
if svr != nil {
|
||||
_ = svr.Close()
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Nil(t, svr)
|
||||
if mode == "port conflict" {
|
||||
require.ErrorContains(t, err, fmt.Sprint(cfg.MetricsServer.Port))
|
||||
} else {
|
||||
require.ErrorContains(t, err, "missing.crt")
|
||||
}
|
||||
assertLifecyclePorts(t, addresses, true)
|
||||
}
|
||||
require.Equal(t, workersBefore, countPortManagerWorkers(t), "failed construction leaked port-manager workers")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -139,9 +139,7 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
|
||||
}
|
||||
|
||||
if pxy.GetLimiter() != nil {
|
||||
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {
|
||||
return rwc.Close()
|
||||
})
|
||||
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), rwc.Close)
|
||||
}
|
||||
|
||||
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
|
||||
|
||||
@@ -308,9 +308,7 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
|
||||
}
|
||||
|
||||
if pxy.GetLimiter() != nil {
|
||||
local = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), func() error {
|
||||
return local.Close()
|
||||
})
|
||||
local = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), local.Close)
|
||||
}
|
||||
|
||||
xl.Debugf("join connections, workConn(l[%s] r[%s]) userConn(l[%s] r[%s])", workConn.LocalAddr().String(),
|
||||
|
||||
+1
-3
@@ -217,9 +217,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
|
||||
}
|
||||
|
||||
if pxy.GetLimiter() != nil {
|
||||
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {
|
||||
return rwc.Close()
|
||||
})
|
||||
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), rwc.Close)
|
||||
}
|
||||
|
||||
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
|
||||
|
||||
+115
-34
@@ -25,11 +25,13 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/crypto"
|
||||
"github.com/fatedier/golib/net/mux"
|
||||
fmux "github.com/hashicorp/yamux"
|
||||
fmux "github.com/fatedier/yamux"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
quic "github.com/quic-go/quic-go"
|
||||
"github.com/samber/lo"
|
||||
|
||||
@@ -119,7 +121,11 @@ type Service struct {
|
||||
rc *controller.ResourceController
|
||||
|
||||
// web server for dashboard UI and apis
|
||||
webServer *httppkg.Server
|
||||
webServer *httppkg.Server
|
||||
metricsServer *httppkg.Server
|
||||
// HTTP vhost resources are started and owned by NewService.
|
||||
httpVhostServer *http.Server
|
||||
httpVhostListener net.Listener
|
||||
|
||||
sshTunnelGateway *ssh.Gateway
|
||||
|
||||
@@ -133,7 +139,10 @@ type Service struct {
|
||||
// service context
|
||||
ctx context.Context
|
||||
// call cancel to stop service
|
||||
cancel context.CancelFunc
|
||||
cancel context.CancelFunc
|
||||
lifecycleMu sync.Mutex
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
||||
@@ -145,20 +154,6 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var webServer *httppkg.Server
|
||||
if cfg.WebServer.Port > 0 {
|
||||
ws, err := httppkg.NewServer(cfg.WebServer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webServer = ws
|
||||
|
||||
modelmetrics.EnableMem()
|
||||
if cfg.EnablePrometheus {
|
||||
modelmetrics.EnablePrometheus()
|
||||
}
|
||||
}
|
||||
|
||||
authRuntime, err := auth.BuildServerAuth(&cfg.Auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -172,20 +167,14 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
||||
pluginManager: plugin.NewManager(),
|
||||
rc: &controller.ResourceController{
|
||||
VisitorManager: visitor.NewManager(),
|
||||
TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts),
|
||||
UDPPortManager: ports.NewManager("udp", cfg.ProxyBindAddr, cfg.AllowPorts),
|
||||
},
|
||||
sshTunnelListener: netpkg.NewInternalListener(),
|
||||
httpVhostRouter: vhost.NewRouters(),
|
||||
auth: authRuntime,
|
||||
webServer: webServer,
|
||||
tlsConfig: tlsConfig,
|
||||
cfg: cfg,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
if webServer != nil {
|
||||
webServer.RouteRegister(svr.registerRouteHandlers)
|
||||
}
|
||||
|
||||
// Create tcpmux httpconnect multiplexer.
|
||||
if cfg.TCPMuxHTTPConnectPort > 0 {
|
||||
@@ -210,9 +199,6 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
||||
}
|
||||
svr.rc.PluginManager = svr.pluginManager
|
||||
|
||||
// Init group controller
|
||||
svr.rc.TCPGroupCtl = group.NewTCPGroupCtl(svr.rc.TCPPortManager)
|
||||
|
||||
// Init HTTP group controller
|
||||
svr.rc.HTTPGroupCtl = group.NewHTTPGroupController(svr.httpVhostRouter)
|
||||
|
||||
@@ -319,6 +305,8 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
||||
return nil, fmt.Errorf("create vhost http listener error, %v", err)
|
||||
}
|
||||
}
|
||||
svr.httpVhostServer = server
|
||||
svr.httpVhostListener = l
|
||||
go func() {
|
||||
_ = server.Serve(l)
|
||||
}()
|
||||
@@ -360,16 +348,88 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
||||
return nil, fmt.Errorf("create nat hole controller error, %v", err)
|
||||
}
|
||||
svr.rc.NatHoleController = nc
|
||||
|
||||
// Create HTTP servers after all other fallible steps, rolling back listeners
|
||||
// if either server cannot be created.
|
||||
if cfg.WebServer.Port > 0 {
|
||||
ws, err := httppkg.NewServer(cfg.WebServer)
|
||||
if err != nil {
|
||||
// All other listeners are already created and must be released on failure.
|
||||
_ = svr.Close()
|
||||
return nil, err
|
||||
}
|
||||
svr.webServer = ws
|
||||
}
|
||||
if cfg.MetricsServer.Port > 0 {
|
||||
ms, err := httppkg.NewMetricsServer(cfg.MetricsServer, promhttp.Handler())
|
||||
if err != nil {
|
||||
_ = svr.Close()
|
||||
return nil, err
|
||||
}
|
||||
svr.metricsServer = ms
|
||||
}
|
||||
|
||||
// Collectors are enabled at most once. The in-memory collector is only read
|
||||
// by the dashboard API, and Prometheus metrics need an endpoint to serve them.
|
||||
enableMem, enablePrometheus := metricsCollectors(cfg)
|
||||
if enableMem {
|
||||
modelmetrics.EnableMem()
|
||||
}
|
||||
if enablePrometheus {
|
||||
modelmetrics.EnablePrometheus()
|
||||
}
|
||||
|
||||
// Start port-manager cleanup workers only after all fallible steps have succeeded.
|
||||
svr.rc.TCPPortManager = ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts)
|
||||
svr.rc.UDPPortManager = ports.NewManager("udp", cfg.ProxyBindAddr, cfg.AllowPorts)
|
||||
svr.rc.TCPGroupCtl = group.NewTCPGroupCtl(svr.rc.TCPPortManager)
|
||||
|
||||
if svr.webServer != nil {
|
||||
svr.webServer.RouteRegister(svr.registerRouteHandlers)
|
||||
}
|
||||
return svr, nil
|
||||
}
|
||||
|
||||
// metricsCollectors reports which collectors should be started for cfg. The
|
||||
// in-memory collector only backs the dashboard API (webServer), while the
|
||||
// Prometheus collector needs a listener that serves /metrics: either the new
|
||||
// metricsServer, or the legacy webServer endpoint gated by enablePrometheus.
|
||||
func metricsCollectors(cfg *v1.ServerConfig) (mem bool, prometheus bool) {
|
||||
mem = cfg.WebServer.Port > 0
|
||||
prometheus = cfg.MetricsServer.Port > 0 || (cfg.EnablePrometheus && mem)
|
||||
return mem, prometheus
|
||||
}
|
||||
|
||||
func (svr *Service) Run(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
svr.lifecycleMu.Lock()
|
||||
if svr.closed {
|
||||
svr.lifecycleMu.Unlock()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
svr.ctx = ctx
|
||||
svr.cancel = cancel
|
||||
svr.lifecycleMu.Unlock()
|
||||
defer svr.Close()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// Cancellation must unblock the synchronous main listener's Accept.
|
||||
stopClose := context.AfterFunc(ctx, func() { _ = svr.Close() })
|
||||
defer stopClose()
|
||||
|
||||
// run dashboard web server.
|
||||
if svr.webServer != nil {
|
||||
if svr.cfg.WebServer.PprofEnable {
|
||||
log.Warnf("webServer pprof endpoints are deprecated; use metricsServer.pprofEnable instead")
|
||||
}
|
||||
if svr.cfg.EnablePrometheus {
|
||||
log.Warnf("webServer /metrics is deprecated; configure metricsServer.port instead")
|
||||
}
|
||||
if svr.cfg.MetricsServer.Port == 0 {
|
||||
log.Infof("webServer /healthz is deprecated; health probes can migrate to metricsServer.port")
|
||||
}
|
||||
go func() {
|
||||
log.Infof("dashboard listen on %s", svr.webServer.Address())
|
||||
if err := svr.webServer.Run(); err != nil {
|
||||
@@ -378,6 +438,15 @@ func (svr *Service) Run(ctx context.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
if svr.metricsServer != nil {
|
||||
go func() {
|
||||
log.Infof("metrics server listen on %s", svr.metricsServer.Address())
|
||||
if err := svr.metricsServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Warnf("metrics server exit with error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go svr.HandleListener(svr.sshTunnelListener, true)
|
||||
|
||||
if svr.kcpListener != nil {
|
||||
@@ -400,13 +469,21 @@ func (svr *Service) Run(ctx context.Context) {
|
||||
svr.HandleListener(svr.listener, false)
|
||||
|
||||
<-svr.ctx.Done()
|
||||
// service context may not be canceled by svr.Close(), we should call it here to release resources
|
||||
if svr.listener != nil {
|
||||
svr.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (svr *Service) Close() error {
|
||||
svr.lifecycleMu.Lock()
|
||||
svr.closed = true
|
||||
cancel := svr.cancel
|
||||
svr.lifecycleMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
svr.closeOnce.Do(svr.closeResources)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svr *Service) closeResources() {
|
||||
if svr.kcpListener != nil {
|
||||
svr.kcpListener.Close()
|
||||
}
|
||||
@@ -428,16 +505,20 @@ func (svr *Service) Close() error {
|
||||
if svr.webServer != nil {
|
||||
svr.webServer.Close()
|
||||
}
|
||||
if svr.metricsServer != nil {
|
||||
svr.metricsServer.Close()
|
||||
}
|
||||
if svr.httpVhostServer != nil {
|
||||
svr.httpVhostServer.Close()
|
||||
// Close even if Serve has not registered its listener yet.
|
||||
svr.httpVhostListener.Close()
|
||||
}
|
||||
if svr.sshTunnelGateway != nil {
|
||||
svr.sshTunnelGateway.Close()
|
||||
}
|
||||
svr.rc.Close()
|
||||
svr.muxer.Close()
|
||||
svr.ctlManager.Close()
|
||||
if svr.cancel != nil {
|
||||
svr.cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/pprof"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||
)
|
||||
|
||||
type lifecycleListener struct {
|
||||
net.Listener
|
||||
accepted, closeEntered, closeResume chan struct{}
|
||||
acceptOnce, closeOnce sync.Once
|
||||
accepts, closes atomic.Int64
|
||||
}
|
||||
|
||||
func (l *lifecycleListener) Accept() (net.Conn, error) {
|
||||
l.accepts.Add(1)
|
||||
l.acceptOnce.Do(func() { close(l.accepted) })
|
||||
return l.Listener.Accept()
|
||||
}
|
||||
|
||||
func (l *lifecycleListener) Close() error {
|
||||
l.closes.Add(1)
|
||||
err := l.Listener.Close()
|
||||
if l.closeEntered != nil {
|
||||
l.closeOnce.Do(func() { close(l.closeEntered) })
|
||||
<-l.closeResume
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func freeLifecycleTCPPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func newLifecycleTestService(t *testing.T, web bool) (*Service, *lifecycleListener, []string) {
|
||||
t.Helper()
|
||||
cfg := &v1.ServerConfig{
|
||||
BindAddr: "127.0.0.1", ProxyBindAddr: "127.0.0.1",
|
||||
}
|
||||
if web {
|
||||
cfg.WebServer = v1.WebServerConfig{Addr: "127.0.0.1", Port: freeLifecycleTCPPort(t)}
|
||||
cfg.VhostHTTPPort = freeLifecycleTCPPort(t)
|
||||
}
|
||||
require.NoError(t, cfg.Complete())
|
||||
cfg.BindPort = 0
|
||||
_, err := validation.NewConfigValidator(nil).ValidateServerConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
svr, err := NewService(cfg)
|
||||
require.NoError(t, err)
|
||||
l := &lifecycleListener{Listener: svr.listener, accepted: make(chan struct{})}
|
||||
svr.listener = l
|
||||
addresses := []string{l.Addr().String()}
|
||||
if web {
|
||||
addresses = append(addresses, svr.webServer.Address())
|
||||
addresses = append(addresses, net.JoinHostPort(cfg.ProxyBindAddr, fmt.Sprint(cfg.VhostHTTPPort)))
|
||||
}
|
||||
assertLifecyclePorts(t, addresses, false)
|
||||
return svr, l, addresses
|
||||
}
|
||||
|
||||
func assertLifecyclePorts(t *testing.T, addresses []string, released bool) {
|
||||
t.Helper()
|
||||
for _, address := range addresses {
|
||||
ln, err := net.Listen("tcp", address)
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
if released {
|
||||
require.NoError(t, err, "listener %s was not released", address)
|
||||
} else {
|
||||
require.Error(t, err, "listener %s was not bound", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceDoesNotLeakWebServerOnBindFailure(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
|
||||
cfg := &v1.ServerConfig{
|
||||
BindAddr: "127.0.0.1",
|
||||
BindPort: ln.Addr().(*net.TCPAddr).Port,
|
||||
ProxyBindAddr: "127.0.0.1",
|
||||
WebServer: v1.WebServerConfig{Addr: "127.0.0.1", Port: freeLifecycleTCPPort(t)},
|
||||
}
|
||||
require.NoError(t, cfg.Complete())
|
||||
|
||||
svr, err := NewService(cfg)
|
||||
if svr != nil {
|
||||
defer svr.Close()
|
||||
}
|
||||
require.ErrorContains(t, err, "create server listener error")
|
||||
require.ErrorContains(t, err, ln.Addr().String())
|
||||
require.Nil(t, svr)
|
||||
assertLifecyclePorts(t, []string{net.JoinHostPort(cfg.WebServer.Addr, fmt.Sprint(cfg.WebServer.Port))}, true)
|
||||
}
|
||||
|
||||
func countPortManagerWorkers(t *testing.T) int {
|
||||
t.Helper()
|
||||
var profile bytes.Buffer
|
||||
require.NoError(t, pprof.Lookup("goroutine").WriteTo(&profile, 2))
|
||||
count := 0
|
||||
for stack := range strings.SplitSeq(profile.String(), "\n\n") {
|
||||
if strings.Contains(stack, "github.com/fatedier/frp/server/ports.(*Manager).cleanReservedPortsWorker") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestNewServiceDoesNotLeakPortManagerWorkersOnWebServerFailure(t *testing.T) {
|
||||
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
cfg := &v1.ServerConfig{
|
||||
BindAddr: "127.0.0.1",
|
||||
ProxyBindAddr: "127.0.0.1",
|
||||
WebServer: v1.WebServerConfig{Addr: "127.0.0.1", Port: ln.Addr().(*net.TCPAddr).Port},
|
||||
}
|
||||
require.NoError(t, cfg.Complete())
|
||||
cfg.BindPort = 0
|
||||
if mode == "invalid TLS certificate" {
|
||||
dir := t.TempDir()
|
||||
certFile := filepath.Join(dir, "cert.pem")
|
||||
keyFile := filepath.Join(dir, "key.pem")
|
||||
require.NoError(t, os.WriteFile(certFile, []byte("invalid certificate"), 0o600))
|
||||
require.NoError(t, os.WriteFile(keyFile, []byte("invalid key"), 0o600))
|
||||
cfg.WebServer.TLS = &v1.TLSConfig{CertFile: certFile, KeyFile: keyFile}
|
||||
require.NoError(t, ln.Close())
|
||||
}
|
||||
|
||||
workersBefore := countPortManagerWorkers(t)
|
||||
for range 5 {
|
||||
svr, err := NewService(cfg)
|
||||
if svr != nil {
|
||||
defer svr.Close()
|
||||
}
|
||||
require.Nil(t, svr)
|
||||
if mode == "port conflict" {
|
||||
var listenErr *net.OpError
|
||||
require.ErrorAs(t, err, &listenErr)
|
||||
require.Equal(t, "listen", listenErr.Op)
|
||||
require.Equal(t, ln.Addr().String(), listenErr.Addr.String())
|
||||
} else {
|
||||
require.ErrorContains(t, err, "failed to find any PEM data in certificate input")
|
||||
}
|
||||
}
|
||||
workersAfter := countPortManagerWorkers(t)
|
||||
t.Logf("port-manager workers after 5 failed constructions: before=%d after=%d", workersBefore, workersAfter)
|
||||
require.Equal(t, workersBefore, workersAfter, "failed construction leaked port-manager workers")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceReleasesListenersOnWebServerFailure(t *testing.T) {
|
||||
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
cfg := &v1.ServerConfig{
|
||||
BindAddr: "127.0.0.1",
|
||||
ProxyBindAddr: "127.0.0.1",
|
||||
WebServer: v1.WebServerConfig{Addr: "127.0.0.1"},
|
||||
}
|
||||
require.NoError(t, cfg.Complete())
|
||||
dir := t.TempDir()
|
||||
cfg.SSHTunnelGateway.AutoGenPrivateKeyPath = filepath.Join(dir, "ssh_key")
|
||||
|
||||
// Hold reservations together so each listener gets a distinct port.
|
||||
tcpPorts := []*int{
|
||||
&cfg.BindPort, &cfg.TCPMuxHTTPConnectPort, &cfg.SSHTunnelGateway.BindPort,
|
||||
&cfg.VhostHTTPPort, &cfg.VhostHTTPSPort, &cfg.WebServer.Port,
|
||||
}
|
||||
tcpReservations := make([]net.Listener, 0, len(tcpPorts))
|
||||
tcpAddresses := make([]string, 0, len(tcpPorts))
|
||||
for _, port := range tcpPorts {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
*port = ln.Addr().(*net.TCPAddr).Port
|
||||
tcpReservations = append(tcpReservations, ln)
|
||||
tcpAddresses = append(tcpAddresses, ln.Addr().String())
|
||||
}
|
||||
udpPorts := []*int{&cfg.KCPBindPort, &cfg.QUICBindPort}
|
||||
udpReservations := make([]net.PacketConn, 0, len(udpPorts))
|
||||
udpAddresses := make([]string, 0, len(udpPorts))
|
||||
for _, port := range udpPorts {
|
||||
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
*port = conn.LocalAddr().(*net.UDPAddr).Port
|
||||
udpReservations = append(udpReservations, conn)
|
||||
udpAddresses = append(udpAddresses, conn.LocalAddr().String())
|
||||
}
|
||||
|
||||
webReservation := tcpReservations[len(tcpReservations)-1]
|
||||
if mode == "invalid TLS certificate" {
|
||||
certFile := filepath.Join(dir, "cert.pem")
|
||||
keyFile := filepath.Join(dir, "key.pem")
|
||||
require.NoError(t, os.WriteFile(certFile, []byte("invalid certificate"), 0o600))
|
||||
require.NoError(t, os.WriteFile(keyFile, []byte("invalid key"), 0o600))
|
||||
cfg.WebServer.TLS = &v1.TLSConfig{CertFile: certFile, KeyFile: keyFile}
|
||||
require.NoError(t, webReservation.Close())
|
||||
}
|
||||
for _, ln := range tcpReservations[:len(tcpReservations)-1] {
|
||||
require.NoError(t, ln.Close())
|
||||
}
|
||||
for _, conn := range udpReservations {
|
||||
require.NoError(t, conn.Close())
|
||||
}
|
||||
|
||||
svr, err := NewService(cfg)
|
||||
if svr != nil {
|
||||
defer svr.Close()
|
||||
}
|
||||
require.Nil(t, svr)
|
||||
if mode == "port conflict" {
|
||||
var listenErr *net.OpError
|
||||
require.ErrorAs(t, err, &listenErr)
|
||||
require.Equal(t, "listen", listenErr.Op)
|
||||
require.Equal(t, webReservation.Addr().String(), listenErr.Addr.String())
|
||||
assertLifecyclePorts(t, []string{webReservation.Addr().String()}, false)
|
||||
require.NoError(t, webReservation.Close())
|
||||
} else {
|
||||
require.ErrorContains(t, err, "failed to find any PEM data in certificate input")
|
||||
}
|
||||
assertLifecyclePorts(t, tcpAddresses, true)
|
||||
for _, address := range udpAddresses {
|
||||
conn, err := net.ListenPacket("udp", address)
|
||||
require.NoError(t, err, "UDP listener %s was not released", address)
|
||||
require.NoError(t, conn.Close())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRunAfterEarlyShutdown(t *testing.T) {
|
||||
for _, mode := range []string{"close before Run", "close before Run with web", "parent already cancelled"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
svr, listener, addresses := newLifecycleTestService(t, mode == "close before Run with web")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
defer svr.Close()
|
||||
if mode != "parent already cancelled" {
|
||||
require.NoError(t, svr.Close())
|
||||
assertLifecyclePorts(t, addresses, true)
|
||||
} else {
|
||||
cancel()
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { svr.Run(ctx); close(done) }()
|
||||
waitForSignal(t, done, "Run after early shutdown")
|
||||
require.Zero(t, listener.accepts.Load(), "closed service started accepting")
|
||||
require.Equal(t, int64(1), listener.closes.Load())
|
||||
assertLifecyclePorts(t, addresses, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsCollectors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
webPort, metricsPort int
|
||||
enablePrometheus bool
|
||||
wantMem, wantPrometheus bool
|
||||
}{
|
||||
{name: "nothing enabled"},
|
||||
{name: "web server only", webPort: 7500, wantMem: true},
|
||||
{
|
||||
name: "web server with enablePrometheus", webPort: 7500, enablePrometheus: true,
|
||||
wantMem: true, wantPrometheus: true,
|
||||
},
|
||||
{
|
||||
// enablePrometheus alone has no endpoint to serve metrics from, so the
|
||||
// Prometheus collector must stay off, as it did before metricsServer.
|
||||
name: "enablePrometheus without listeners", enablePrometheus: true,
|
||||
},
|
||||
{
|
||||
// The in-memory collector only backs the dashboard API.
|
||||
name: "metrics server only", metricsPort: 7501, wantPrometheus: true,
|
||||
},
|
||||
{
|
||||
name: "both servers", webPort: 7500, metricsPort: 7501,
|
||||
wantMem: true, wantPrometheus: true,
|
||||
},
|
||||
{
|
||||
name: "both servers with enablePrometheus", webPort: 7500, metricsPort: 7501, enablePrometheus: true,
|
||||
wantMem: true, wantPrometheus: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &v1.ServerConfig{
|
||||
WebServer: v1.WebServerConfig{Port: tc.webPort},
|
||||
MetricsServer: v1.MetricsServerConfig{Port: tc.metricsPort},
|
||||
EnablePrometheus: tc.enablePrometheus,
|
||||
}
|
||||
mem, prometheus := metricsCollectors(cfg)
|
||||
require.Equal(t, tc.wantMem, mem, "EnableMem")
|
||||
require.Equal(t, tc.wantPrometheus, prometheus, "EnablePrometheus")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCloseMarksClosedBeforeCleanup(t *testing.T) {
|
||||
svr, listener, addresses := newLifecycleTestService(t, false)
|
||||
listener.closeEntered, listener.closeResume = make(chan struct{}), make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(listener.closeResume) }) }
|
||||
defer release()
|
||||
closeDone := make(chan struct{})
|
||||
go func() { _ = svr.Close(); close(closeDone) }()
|
||||
waitForSignal(t, listener.closeEntered, "Close cleanup barrier")
|
||||
runDone := make(chan struct{})
|
||||
go func() { svr.Run(context.Background()); close(runDone) }()
|
||||
waitForSignal(t, runDone, "Run to observe closed before cleanup finishes")
|
||||
require.Zero(t, listener.accepts.Load(), "Run started during cleanup")
|
||||
assertLifecyclePorts(t, addresses, false)
|
||||
release()
|
||||
waitForSignal(t, closeDone, "Close cleanup to finish")
|
||||
assertLifecyclePorts(t, addresses, true)
|
||||
}
|
||||
|
||||
func TestServiceParentCancellationAndCloseShareCleanup(t *testing.T) {
|
||||
svr, listener, addresses := newLifecycleTestService(t, false)
|
||||
listener.closeEntered, listener.closeResume = make(chan struct{}), make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(listener.closeResume) }) }
|
||||
defer release()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
runDone := make(chan struct{})
|
||||
go func() { svr.Run(ctx); close(runDone) }()
|
||||
waitForSignal(t, listener.accepted, "main listener Accept")
|
||||
cancel()
|
||||
waitForSignal(t, listener.closeEntered, "parent cancellation to close main listener")
|
||||
closeDone := make(chan struct{})
|
||||
go func() { _ = svr.Close(); close(closeDone) }()
|
||||
release()
|
||||
waitForSignal(t, closeDone, "external Close to finish")
|
||||
waitForSignal(t, runDone, "Run cleanup to finish")
|
||||
require.Equal(t, int64(1), listener.closes.Load(), "cleanup ran more than once")
|
||||
assertLifecyclePorts(t, addresses, true)
|
||||
}
|
||||
@@ -192,19 +192,52 @@ transport.wireProtocol = "v2"
|
||||
})
|
||||
})
|
||||
|
||||
var _ = ginkgo.Describe("[Compatibility: BinaryUDPPacket]", func() {
|
||||
var _ = ginkgo.Describe("[Compatibility: UDPDefault]", func() {
|
||||
f := framework.NewDefaultFramework()
|
||||
|
||||
ginkgo.It("current frps exchanges UDP with baseline frpc using the default wire protocol", func() {
|
||||
portName := port.GenName("CompatDefaultUDPBaselineFRPC")
|
||||
f.RunProcessesWithBinaries(
|
||||
compatCtx.CurrentFRPSPath,
|
||||
compatCtx.BaselineFRPCPath,
|
||||
consts.DefaultServerConfig,
|
||||
[]string{udpClientConfig(portName, "")},
|
||||
)
|
||||
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
|
||||
})
|
||||
|
||||
ginkgo.It("baseline frps exchanges UDP with current frpc defaulting to v1", func() {
|
||||
portName := port.GenName("CompatDefaultUDPBaselineFRPS")
|
||||
f.RunProcessesWithBinaries(
|
||||
compatCtx.BaselineFRPSPath,
|
||||
compatCtx.CurrentFRPCPath,
|
||||
consts.DefaultServerConfig,
|
||||
[]string{udpClientConfig(portName, "")},
|
||||
)
|
||||
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
|
||||
})
|
||||
})
|
||||
|
||||
var _ = ginkgo.Describe("[Compatibility: UDPWireV2]", func() {
|
||||
f := framework.NewDefaultFramework()
|
||||
|
||||
ginkgo.BeforeEach(func() {
|
||||
supportsV2, knownVersion := baselineSupportsControlWireProtocolV2(compatCtx.BaselineVersion)
|
||||
if !knownVersion || !supportsV2 {
|
||||
ginkgo.Skip(fmt.Sprintf("baseline version %q does not have known wire protocol v2 support", compatCtx.BaselineVersion))
|
||||
supportsV2, knownVersion := baselineSupportsUDPWireProtocolV2(compatCtx.BaselineVersion)
|
||||
if !knownVersion {
|
||||
ginkgo.Skip(fmt.Sprintf("baseline version %q has unknown ordinary UDP wire v2 framing support", compatCtx.BaselineVersion))
|
||||
}
|
||||
if !supportsV2 {
|
||||
// v0.69.0 supports control v2 but still uses legacy ordinary UDP framing.
|
||||
// Mixing that early v2 implementation with the v0.69.1 framing was not supported.
|
||||
ginkgo.Skip(fmt.Sprintf("baseline %q predates ordinary UDP wire v2 framing (v0.69.1); default UDP is tested separately",
|
||||
compatCtx.BaselineVersion))
|
||||
}
|
||||
})
|
||||
|
||||
ginkgo.It("current frps falls back to JSON for baseline frpc", func() {
|
||||
portName := port.GenName("CompatBinaryUDPBaselineFRPC")
|
||||
clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`)
|
||||
// The negotiated payload codec may be JSON or binary-v1; both use v2 framing.
|
||||
ginkgo.It("current frps exchanges v2-framed UDP with baseline frpc", func() {
|
||||
portName := port.GenName("CompatV2UDPBaselineFRPC")
|
||||
clientConf := udpClientConfig(portName, `transport.wireProtocol = "v2"`)
|
||||
f.RunProcessesWithBinaries(
|
||||
compatCtx.CurrentFRPSPath,
|
||||
compatCtx.BaselineFRPCPath,
|
||||
@@ -214,9 +247,9 @@ var _ = ginkgo.Describe("[Compatibility: BinaryUDPPacket]", func() {
|
||||
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
|
||||
})
|
||||
|
||||
ginkgo.It("current frpc falls back to JSON for baseline frps", func() {
|
||||
portName := port.GenName("CompatBinaryUDPBaselineFRPS")
|
||||
clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`)
|
||||
ginkgo.It("current frpc exchanges v2-framed UDP with baseline frps", func() {
|
||||
portName := port.GenName("CompatV2UDPBaselineFRPS")
|
||||
clientConf := udpClientConfig(portName, `transport.wireProtocol = "v2"`)
|
||||
f.RunProcessesWithBinaries(
|
||||
compatCtx.BaselineFRPSPath,
|
||||
compatCtx.CurrentFRPCPath,
|
||||
@@ -243,7 +276,7 @@ remotePort = {{ .%s }}
|
||||
`, consts.PortServerName, extra, proxyName, framework.TCPEchoServerPort, remotePortName)
|
||||
}
|
||||
|
||||
func udpClientConfig(proxyName string, remotePortName string, extra string) string {
|
||||
func udpClientConfig(remotePortName string, extra string) string {
|
||||
return fmt.Sprintf(`
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = {{ .%s }}
|
||||
@@ -252,11 +285,11 @@ log.level = "trace"
|
||||
%s
|
||||
|
||||
[[proxies]]
|
||||
name = "%s"
|
||||
name = "udp"
|
||||
type = "udp"
|
||||
localPort = {{ .%s }}
|
||||
remotePort = {{ .%s }}
|
||||
`, consts.PortServerName, extra, proxyName, framework.UDPEchoServerPort, remotePortName)
|
||||
`, consts.PortServerName, extra, framework.UDPEchoServerPort, remotePortName)
|
||||
}
|
||||
|
||||
func expectProcessExit(p *process.Process, timeout time.Duration) {
|
||||
@@ -268,6 +301,14 @@ func expectProcessExit(p *process.Process, timeout time.Duration) {
|
||||
}
|
||||
|
||||
func baselineSupportsControlWireProtocolV2(version string) (supports bool, known bool) {
|
||||
return baselineVersionAtLeast(version, [3]int{0, 69, 0})
|
||||
}
|
||||
|
||||
func baselineSupportsUDPWireProtocolV2(version string) (supports bool, known bool) {
|
||||
return baselineVersionAtLeast(version, [3]int{0, 69, 1})
|
||||
}
|
||||
|
||||
func baselineVersionAtLeast(version string, minimum [3]int) (supports bool, known bool) {
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) != 3 {
|
||||
@@ -287,7 +328,39 @@ func baselineSupportsControlWireProtocolV2(version string) (supports bool, known
|
||||
return false, false
|
||||
}
|
||||
|
||||
return compareSemanticVersion(major, minor, patch, 0, 69, 0) >= 0, true
|
||||
return compareSemanticVersion(major, minor, patch, minimum[0], minimum[1], minimum[2]) >= 0, true
|
||||
}
|
||||
|
||||
func TestBaselineWireProtocolSupport(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
version string
|
||||
known bool
|
||||
controlV2 bool
|
||||
udpV2 bool
|
||||
}{
|
||||
{version: "0.61.0", known: true},
|
||||
{version: "0.68.1", known: true},
|
||||
{version: "0.69.0", known: true, controlV2: true},
|
||||
{version: "v0.69.0", known: true, controlV2: true},
|
||||
{version: "0.69.1", known: true, controlV2: true, udpV2: true},
|
||||
{version: "v0.69.1", known: true, controlV2: true, udpV2: true},
|
||||
{version: "0.70.0", known: true, controlV2: true, udpV2: true},
|
||||
{version: "0.71.0", known: true, controlV2: true, udpV2: true},
|
||||
{version: "1.0.0", known: true, controlV2: true, udpV2: true},
|
||||
{version: "custom"},
|
||||
{version: "0.69"},
|
||||
{version: "0.69.x"},
|
||||
{version: "0.69.1-rc.1"},
|
||||
} {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
if supports, known := baselineSupportsControlWireProtocolV2(tc.version); supports != tc.controlV2 || known != tc.known {
|
||||
t.Errorf("control v2 support = (%v, %v), want (%v, %v)", supports, known, tc.controlV2, tc.known)
|
||||
}
|
||||
if supports, known := baselineSupportsUDPWireProtocolV2(tc.version); supports != tc.udpV2 || known != tc.known {
|
||||
t.Errorf("UDP v2 framing support = (%v, %v), want (%v, %v)", supports, known, tc.udpV2, tc.known)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compareSemanticVersion(major, minor, patch int, baseMajor, baseMinor, basePatch int) int {
|
||||
|
||||
@@ -177,6 +177,10 @@ var _ = ginkgo.Describe("[Feature: Client-Server]", func() {
|
||||
client: "transport.tls.enable = false",
|
||||
expectError: true,
|
||||
})
|
||||
defineClientServerTest("useSystemCa rejects the default self-signed server certificate", f, &generalTestConfigures{
|
||||
client: "transport.tls.useSystemCa = true",
|
||||
expectError: true,
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("TLS with custom certificate", func() {
|
||||
|
||||
@@ -2,6 +2,9 @@ package features
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -105,4 +108,82 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
|
||||
|
||||
framework.ExpectTrue(duration.Seconds() > 8, "100Kb with 10KB limit, want > 8 seconds, but got %s", duration.String())
|
||||
})
|
||||
|
||||
ginkgo.It("Server Bandwidth Limit closes work connection when user disconnects", func() {
|
||||
newFunc := func() *plugin.Request {
|
||||
var r plugin.Request
|
||||
r.Content = &plugin.NewProxyContent{}
|
||||
return &r
|
||||
}
|
||||
pluginPort := f.AllocPort()
|
||||
handler := func(req *plugin.Request) *plugin.Response {
|
||||
var ret plugin.Response
|
||||
content := req.Content.(*plugin.NewProxyContent)
|
||||
content.BandwidthLimit = "10MB"
|
||||
content.BandwidthLimitMode = "server"
|
||||
ret.Content = content
|
||||
return &ret
|
||||
}
|
||||
pluginServer := pluginpkg.NewHTTPPluginServer(pluginPort, newFunc, handler, nil)
|
||||
|
||||
f.RunServer("", pluginServer)
|
||||
|
||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||
[[httpPlugins]]
|
||||
name = "test"
|
||||
addr = "127.0.0.1:%d"
|
||||
path = "/handler"
|
||||
ops = ["NewProxy"]
|
||||
`, pluginPort)
|
||||
clientConf := consts.DefaultClientConfig
|
||||
|
||||
// The local server echoes the first message, then stays idle until the
|
||||
// connection from frpc is closed.
|
||||
localClosed := make(chan struct{})
|
||||
localPort := f.AllocPort()
|
||||
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
|
||||
streamserver.WithCustomHandler(func(c net.Conn) {
|
||||
defer c.Close()
|
||||
buf := make([]byte, 1024)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := c.Write(buf[:n]); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, c)
|
||||
close(localClosed)
|
||||
}))
|
||||
f.RunServer("", localServer)
|
||||
|
||||
remotePort := f.AllocPort()
|
||||
clientConf += fmt.Sprintf(`
|
||||
[[proxies]]
|
||||
name = "tcp"
|
||||
type = "tcp"
|
||||
localPort = %d
|
||||
remotePort = %d
|
||||
`, localPort, remotePort)
|
||||
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(remotePort)))
|
||||
framework.ExpectNoError(err)
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
_, err = conn.Write([]byte("ping"))
|
||||
framework.ExpectNoError(err)
|
||||
buf := make([]byte, 4)
|
||||
_, err = io.ReadFull(conn, buf)
|
||||
framework.ExpectNoError(err)
|
||||
framework.ExpectEqual(string(buf), "ping")
|
||||
|
||||
conn.Close()
|
||||
|
||||
select {
|
||||
case <-localClosed:
|
||||
case <-time.After(5 * time.Second):
|
||||
framework.Failf("local connection is not closed after user connection is closed")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+231
-40
@@ -1,14 +1,20 @@
|
||||
package features
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/fatedier/frp/pkg/sdk/client"
|
||||
"github.com/fatedier/frp/pkg/transport"
|
||||
"github.com/fatedier/frp/test/e2e/framework"
|
||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||
@@ -17,6 +23,80 @@ import (
|
||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||
)
|
||||
|
||||
func waitForProxyStatus(proxyClient *client.Client, proxyName, want string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(25 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastStatus string
|
||||
var lastErr error
|
||||
for {
|
||||
status, err := proxyClient.GetProxyStatus(ctx, proxyName)
|
||||
if err == nil {
|
||||
lastErr = nil
|
||||
lastStatus = status.Status
|
||||
if status.Status == want {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("timeout waiting for proxy %q status %q: last error: %w", proxyName, want, lastErr)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for proxy %q status %q: last status %q", proxyName, want, lastStatus)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForSignal(signal <-chan struct{}) error {
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-signal:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("timeout waiting for backend health-check signal")
|
||||
}
|
||||
}
|
||||
|
||||
// Keep this longer than client/proxy.statusCheckInterval (3s). The wrapper's
|
||||
// health notification is deliberately non-blocking, so the E2E assertion must
|
||||
// also cover the fallback poll plus scheduling margin while recovery is gated.
|
||||
const proxyFallbackObservationWindow = 4 * time.Second
|
||||
|
||||
func waitForServerProxyStatus(port int, proxyName, want string, timeout time.Duration) error {
|
||||
return waitForLifecycleCondition(timeout, func() error {
|
||||
body, err := getLifecycleEndpoint(port, "/api/proxies/"+url.PathEscape(proxyName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var status struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &status); err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Status != want {
|
||||
return fmt.Errorf("frps proxy %q status %q, want %q", proxyName, status.Status, want)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type httpHealthStage struct {
|
||||
healthy bool
|
||||
signal chan<- struct{}
|
||||
release <-chan struct{}
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
f := framework.NewDefaultFramework()
|
||||
|
||||
@@ -252,7 +332,10 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
|
||||
ginkgo.Describe("Health Check", func() {
|
||||
ginkgo.It("TCP", func() {
|
||||
serverConf := consts.DefaultServerConfig
|
||||
dashboardPort := f.AllocPort()
|
||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||
webServer.port = %d
|
||||
`, dashboardPort)
|
||||
clientConf := consts.DefaultClientConfig
|
||||
|
||||
fooPort := f.AllocPort()
|
||||
@@ -260,11 +343,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
f.RunServer("", fooServer)
|
||||
|
||||
barPort := f.AllocPort()
|
||||
barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar")))
|
||||
newBarServer := func() *streamserver.Server {
|
||||
return streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar")))
|
||||
}
|
||||
barServer := newBarServer()
|
||||
f.RunServer("", barServer)
|
||||
|
||||
remotePort := f.AllocPort()
|
||||
adminPort := f.AllocPort()
|
||||
clientConf += fmt.Sprintf(`
|
||||
webServer.port = %d
|
||||
|
||||
[[proxies]]
|
||||
name = "foo"
|
||||
type = "tcp"
|
||||
@@ -284,11 +373,16 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
loadBalancer.groupKey = "123"
|
||||
healthCheck.type = "tcp"
|
||||
healthCheck.intervalSeconds = 1
|
||||
`, fooPort, remotePort, barPort, remotePort)
|
||||
healthCheck.maxFailed = 3
|
||||
`, adminPort, fooPort, remotePort, barPort, remotePort)
|
||||
|
||||
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
proxyClient := f.APIClientForFrpc(adminPort)
|
||||
framework.ExpectNoError(waitForProxyStatus(proxyClient, "foo", "running"))
|
||||
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "running"))
|
||||
|
||||
// check foo and bar is ok
|
||||
// Both requests traverse frps, the load-balancing group, frpc, and the
|
||||
// corresponding local backend.
|
||||
results := []string{}
|
||||
for range 10 {
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
|
||||
@@ -298,33 +392,38 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
}
|
||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||
|
||||
// close bar server, check foo is ok
|
||||
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
|
||||
barServer.Close()
|
||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
|
||||
for range 10 {
|
||||
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
|
||||
}
|
||||
// frps removes the group listener before deleting the proxy from
|
||||
// its manager. Its offline status is the removal barrier; frpc's
|
||||
// local check-failed status alone does not acknowledge that work.
|
||||
for range 2 {
|
||||
framework.ExpectNoError(barServer.Close())
|
||||
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "check failed"))
|
||||
framework.ExpectNoError(waitForServerProxyStatus(dashboardPort, "bar", "offline", 5*time.Second))
|
||||
for range 10 {
|
||||
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
|
||||
}
|
||||
|
||||
// resume bar server, check foo and bar is ok
|
||||
successCount := clientProcesses[0].CountOutput("[bar] health check success")
|
||||
f.RunServer("", barServer)
|
||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
|
||||
results = []string{}
|
||||
for range 10 {
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
|
||||
results = append(results, string(resp.Content))
|
||||
return true
|
||||
})
|
||||
barServer = newBarServer()
|
||||
f.RunServer("", barServer)
|
||||
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "running"))
|
||||
results = []string{}
|
||||
for range 10 {
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
|
||||
results = append(results, string(resp.Content))
|
||||
return true
|
||||
})
|
||||
}
|
||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||
}
|
||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||
})
|
||||
|
||||
ginkgo.It("HTTP", func() {
|
||||
vhostPort := f.AllocPort()
|
||||
dashboardPort := f.AllocPort()
|
||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||
vhostHTTPPort = %d
|
||||
`, vhostPort)
|
||||
webServer.port = %d
|
||||
`, vhostPort, dashboardPort)
|
||||
clientConf := consts.DefaultClientConfig
|
||||
|
||||
fooPort := f.AllocPort()
|
||||
@@ -332,10 +431,41 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
f.RunServer("", fooServer)
|
||||
|
||||
barPort := f.AllocPort()
|
||||
barServer := newHTTPServer(barPort, "bar")
|
||||
var stageMu sync.RWMutex
|
||||
stage := &httpHealthStage{healthy: true, signal: make(chan struct{}, 1)}
|
||||
barServer := httpserver.New(
|
||||
httpserver.WithBindPort(barPort),
|
||||
httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
stageMu.RLock()
|
||||
currentStage := stage
|
||||
stageMu.RUnlock()
|
||||
|
||||
if r.URL.Path == "/healthz" {
|
||||
if !currentStage.healthy {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
select {
|
||||
case currentStage.signal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
if currentStage.release != nil {
|
||||
select {
|
||||
case <-currentStage.release:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("bar"))
|
||||
})),
|
||||
)
|
||||
f.RunServer("", barServer)
|
||||
|
||||
adminPort := f.AllocPort()
|
||||
clientConf += fmt.Sprintf(`
|
||||
webServer.port = %d
|
||||
|
||||
[[proxies]]
|
||||
name = "foo"
|
||||
type = "http"
|
||||
@@ -356,12 +486,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
loadBalancer.groupKey = "123"
|
||||
healthCheck.type = "http"
|
||||
healthCheck.intervalSeconds = 1
|
||||
healthCheck.maxFailed = 3
|
||||
healthCheck.timeoutSeconds = 10
|
||||
healthCheck.path = "/healthz"
|
||||
`, fooPort, barPort)
|
||||
`, adminPort, fooPort, barPort)
|
||||
|
||||
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
proxyClient := f.APIClientForFrpc(adminPort)
|
||||
framework.ExpectNoError(waitForProxyStatus(proxyClient, "foo", "running"))
|
||||
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "running"))
|
||||
|
||||
// send first HTTP request
|
||||
// Ordinary requests traverse the real HTTP proxy path.
|
||||
var contents []string
|
||||
framework.NewRequestExpect(f).Port(vhostPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
@@ -371,8 +506,6 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
contents = append(contents, string(resp.Content))
|
||||
return true
|
||||
})
|
||||
|
||||
// send second HTTP request, should be forwarded to another service
|
||||
framework.NewRequestExpect(f).Port(vhostPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
r.HTTP().HTTPHost("example.com")
|
||||
@@ -381,25 +514,83 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
contents = append(contents, string(resp.Content))
|
||||
return true
|
||||
})
|
||||
|
||||
framework.ExpectContainElements(contents, []string{"foo", "bar"})
|
||||
|
||||
// check foo and bar is ok
|
||||
// Every failed health response is gated by the test. This gives each
|
||||
// stage a request-level barrier and avoids cumulative process logs.
|
||||
runFailureRecovery := func() {
|
||||
failureSignals := make(chan struct{}, 1)
|
||||
release := make(chan struct{}, 1)
|
||||
stageMu.Lock()
|
||||
stage = &httpHealthStage{healthy: false, signal: failureSignals, release: release}
|
||||
stageMu.Unlock()
|
||||
|
||||
framework.ExpectNoError(waitForSignal(failureSignals))
|
||||
release <- struct{}{}
|
||||
framework.ExpectNoError(waitForSignal(failureSignals))
|
||||
|
||||
recoverySignals := make(chan struct{}, 1)
|
||||
recoveryRelease := make(chan struct{})
|
||||
stageMu.Lock()
|
||||
stage = &httpHealthStage{healthy: true, signal: recoverySignals, release: recoveryRelease}
|
||||
stageMu.Unlock()
|
||||
release <- struct{}{}
|
||||
|
||||
// The next request can only start after the worker consumed the
|
||||
// second failed response. Hold this success response until the
|
||||
// assertion finishes, so premature removal cannot be hidden by
|
||||
// recovery. Consistently also gives the proxy worker time to act
|
||||
// on an erroneous failure notification without retrying it away.
|
||||
framework.ExpectNoError(waitForSignal(recoverySignals))
|
||||
gomega.Consistently(func() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
status, err := proxyClient.GetProxyStatus(ctx, "bar")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return status.Status
|
||||
}, proxyFallbackObservationWindow, 25*time.Millisecond).Should(gomega.Equal("running"))
|
||||
close(recoveryRelease)
|
||||
}
|
||||
runFailureRecovery()
|
||||
results := doFooBarHTTPRequest(vhostPort, "example.com")
|
||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||
|
||||
// close bar server, check foo is ok
|
||||
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
|
||||
barServer.Close()
|
||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
|
||||
// Repeat the two-failure window to verify that recovery cleared the
|
||||
// counter before another failure sequence began.
|
||||
runFailureRecovery()
|
||||
results = doFooBarHTTPRequest(vhostPort, "example.com")
|
||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||
|
||||
// Three failed responses reach MaxFailed and remove bar from the
|
||||
// group; a healthy response then re-registers it.
|
||||
failureSignals := make(chan struct{}, 1)
|
||||
release := make(chan struct{}, 1)
|
||||
stageMu.Lock()
|
||||
stage = &httpHealthStage{healthy: false, signal: failureSignals, release: release}
|
||||
stageMu.Unlock()
|
||||
for range 3 {
|
||||
framework.ExpectNoError(waitForSignal(failureSignals))
|
||||
release <- struct{}{}
|
||||
}
|
||||
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "check failed"))
|
||||
// HTTPProxy.Close unregisters the group route before frps reports
|
||||
// offline. Only then assert that fresh data-plane requests use foo.
|
||||
framework.ExpectNoError(waitForServerProxyStatus(dashboardPort, "bar", "offline", 5*time.Second))
|
||||
results = doFooBarHTTPRequest(vhostPort, "example.com")
|
||||
framework.ExpectContainElements(results, []string{"foo"})
|
||||
framework.ExpectNotContainElements(results, []string{"bar"})
|
||||
|
||||
// resume bar server, check foo and bar is ok
|
||||
successCount := clientProcesses[0].CountOutput("[bar] health check success")
|
||||
f.RunServer("", barServer)
|
||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
|
||||
recoverySignals := make(chan struct{}, 1)
|
||||
stageMu.Lock()
|
||||
stage = &httpHealthStage{healthy: true, signal: recoverySignals}
|
||||
stageMu.Unlock()
|
||||
// Release any additional failed request already waiting while the
|
||||
// removal and data-plane assertions were in progress.
|
||||
close(release)
|
||||
framework.ExpectNoError(waitForSignal(recoverySignals))
|
||||
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "running"))
|
||||
results = doFooBarHTTPRequest(vhostPort, "example.com")
|
||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package features
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/fatedier/frp/server/http/model"
|
||||
"github.com/fatedier/frp/test/e2e/framework"
|
||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||
)
|
||||
|
||||
func metricsServerConfig(port int, pprof bool) string {
|
||||
return fmt.Sprintf(`
|
||||
metricsServer.port = %d
|
||||
metricsServer.user = "monitor"
|
||||
metricsServer.password = "secret"
|
||||
metricsServer.pprofEnable = %t
|
||||
`, port, pprof)
|
||||
}
|
||||
|
||||
func metricsRequest(port int, path string, authenticated bool) *request.Request {
|
||||
r := framework.NewRequest().HTTP().Port(port).HTTPPath(path)
|
||||
if authenticated {
|
||||
r.HTTPAuth("monitor", "secret")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("[Feature: Metrics Server]", func() {
|
||||
f := framework.NewDefaultFramework()
|
||||
|
||||
// A single connected client must produce exactly one event, including when
|
||||
// both listeners expose /metrics. Checking names alone would also pass with
|
||||
// registered collectors that never receive business events.
|
||||
expectBusinessMetrics := func(port int) {
|
||||
gomega.Eventually(func() bool {
|
||||
resp, err := metricsRequest(port, "/metrics", true).Do()
|
||||
return err == nil && resp.Code == http.StatusOK &&
|
||||
strings.Contains(string(resp.Content), "\nfrp_server_client_counts 1\n")
|
||||
}, 5*time.Second, 50*time.Millisecond).Should(gomega.BeTrue())
|
||||
}
|
||||
|
||||
// frpc serves Go runtime metrics only; no process, business or handler
|
||||
// instrumentation metrics may appear there, on any platform.
|
||||
expectRuntimeMetrics := func(port int) {
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(port, "/metrics", false)).
|
||||
Ensure(framework.ExpectResponseCode(http.StatusUnauthorized))
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(port, "/metrics", true)).Ensure(func(resp *request.Response) bool {
|
||||
body := string(resp.Content)
|
||||
return resp.Code == http.StatusOK &&
|
||||
strings.Contains(body, "go_goroutines") &&
|
||||
!strings.Contains(body, "process_") &&
|
||||
!strings.Contains(body, "frp_") &&
|
||||
!strings.Contains(body, "promhttp_")
|
||||
})
|
||||
}
|
||||
|
||||
ginkgo.It("serves anonymous health alongside both web servers", func() {
|
||||
serverMetrics, clientMetrics := f.AllocPort(), f.AllocPort()
|
||||
serverWeb, clientWeb := f.AllocPort(), f.AllocPort()
|
||||
webConfig := func(port int) string {
|
||||
return fmt.Sprintf(`
|
||||
webServer.port = %d
|
||||
webServer.user = "admin"
|
||||
webServer.password = "admin-secret"
|
||||
`, port)
|
||||
}
|
||||
serverConf := consts.DefaultServerConfig + metricsServerConfig(serverMetrics, false) + webConfig(serverWeb)
|
||||
clientConf := consts.DefaultClientConfig + metricsServerConfig(clientMetrics, false) + webConfig(clientWeb)
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
for _, port := range []int{serverMetrics, clientMetrics, serverWeb, clientWeb} {
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(port, "/healthz", false)).ExpectResp([]byte("")).
|
||||
Ensure(framework.ExpectResponseCode(http.StatusOK), func(resp *request.Response) bool { return len(resp.Content) == 0 })
|
||||
}
|
||||
for _, port := range []int{serverMetrics, clientMetrics} {
|
||||
for _, path := range []string{"/", "/api/serverinfo", "/api/config", "/static/app.js"} {
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(port, path, true)).Ensure(framework.ExpectResponseCode(http.StatusNotFound))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ginkgo.It("authenticates business metrics without enablePrometheus", func() {
|
||||
port := f.AllocPort()
|
||||
serverConf := consts.DefaultServerConfig + metricsServerConfig(port, false) + fmt.Sprintf("\nwebServer.port = %d\n", f.AllocPort())
|
||||
f.RunProcesses(serverConf, []string{consts.DefaultClientConfig})
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(port, "/metrics", false)).Ensure(framework.ExpectResponseCode(http.StatusUnauthorized))
|
||||
expectBusinessMetrics(port)
|
||||
})
|
||||
|
||||
ginkgo.It("requires opt-in and authentication for pprof and excludes cmdline", func() {
|
||||
serverPort, clientPort := f.AllocPort(), f.AllocPort()
|
||||
f.RunProcesses(consts.DefaultServerConfig+metricsServerConfig(serverPort, true),
|
||||
[]string{consts.DefaultClientConfig + metricsServerConfig(clientPort, false)})
|
||||
for _, path := range []string{"/debug/pprof/", "/debug/pprof/goroutine"} {
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(serverPort, path, false)).Ensure(framework.ExpectResponseCode(http.StatusUnauthorized))
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(serverPort, path, true)).Ensure(framework.ExpectResponseCode(http.StatusOK))
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(clientPort, path, true)).Ensure(framework.ExpectResponseCode(http.StatusNotFound))
|
||||
}
|
||||
for _, authenticated := range []bool{false, true} {
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(serverPort, "/debug/pprof/cmdline", authenticated)).
|
||||
Ensure(framework.ExpectResponseCode(http.StatusNotFound))
|
||||
}
|
||||
})
|
||||
|
||||
ginkgo.It("serves health and business metrics without either web server", func() {
|
||||
serverPort, clientPort := f.AllocPort(), f.AllocPort()
|
||||
f.RunProcesses(consts.DefaultServerConfig+metricsServerConfig(serverPort, false),
|
||||
[]string{consts.DefaultClientConfig + metricsServerConfig(clientPort, false)})
|
||||
for _, port := range []int{serverPort, clientPort} {
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(port, "/healthz", false)).Ensure(framework.ExpectResponseCode(http.StatusOK))
|
||||
}
|
||||
expectBusinessMetrics(serverPort)
|
||||
expectRuntimeMetrics(clientPort)
|
||||
})
|
||||
|
||||
ginkgo.It("collects once when both metrics endpoints are enabled", func() {
|
||||
metricsPort, webPort := f.AllocPort(), f.AllocPort()
|
||||
serverConf := consts.DefaultServerConfig + metricsServerConfig(metricsPort, false) + fmt.Sprintf(`
|
||||
enablePrometheus = true
|
||||
webServer.port = %d
|
||||
webServer.user = "monitor"
|
||||
webServer.password = "secret"
|
||||
`, webPort)
|
||||
f.RunProcesses(serverConf, []string{consts.DefaultClientConfig})
|
||||
expectBusinessMetrics(metricsPort)
|
||||
expectBusinessMetrics(webPort)
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(webPort, "/api/serverinfo", true)).Ensure(func(resp *request.Response) bool {
|
||||
var info model.ServerInfoResp
|
||||
return resp.Code == http.StatusOK && json.Unmarshal(resp.Content, &info) == nil && info.ClientCounts == 1
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user