mirror of
https://github.com/fatedier/frp.git
synced 2026-10-24 23:43: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:
|
jobs:
|
||||||
go-version-latest:
|
go-version-latest:
|
||||||
docker:
|
docker:
|
||||||
- image: cimg/go:1.25-node
|
- image: cimg/go:1.26-node
|
||||||
resource_class: large
|
resource_class: large
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@@ -16,10 +16,11 @@ jobs:
|
|||||||
make env fmt
|
make env fmt
|
||||||
git diff --exit-code
|
git diff --exit-code
|
||||||
make build
|
make build
|
||||||
- run: make alltest
|
- run:
|
||||||
|
name: Run all tests
|
||||||
|
command: make alltest
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
version: 2
|
|
||||||
build_and_test:
|
build_and_test:
|
||||||
jobs:
|
jobs:
|
||||||
- go-version-latest
|
- go-version-latest
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# environment
|
# environment
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: '0'
|
fetch-depth: '0'
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ jobs:
|
|||||||
name: lint
|
name: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.26'
|
||||||
cache: false
|
cache: false
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Test and build web assets
|
- name: Test and build web assets
|
||||||
@@ -28,4 +28,4 @@ jobs:
|
|||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
||||||
version: v2.11
|
version: v2.12.2
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.26'
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Build web assets (frps)
|
- 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>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--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">
|
<div align="center">
|
||||||
|
|
||||||
## Recall.ai - API for meeting recordings
|
## 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>
|
<b>The complete IDE crafted for professional Go developers</b>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## What is frp?
|
## 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.
|
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
|
#### 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
|
### Authenticating the Client
|
||||||
|
|
||||||
@@ -711,6 +730,10 @@ transport.tls.keyFile = "certificate.key"
|
|||||||
transport.tls.trustedCaFile = "ca.crt"
|
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:**
|
**`frps` TLS settings:**
|
||||||
|
|
||||||
```toml
|
```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.
|
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'.**
|
**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).
|
`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>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--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">
|
<div align="center">
|
||||||
|
|
||||||
## Recall.ai - API for meeting recordings
|
## 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>
|
<b>The complete IDE crafted for professional Go developers</b>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## 为什么使用 frp ?
|
## 为什么使用 frp ?
|
||||||
|
|||||||
+6
-4
@@ -1,9 +1,11 @@
|
|||||||
## Features
|
## 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
|
## 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 proxy and visitor configuration changes being lost when reloading frpc during reconnection.
|
||||||
* 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.
|
|
||||||
|
|||||||
@@ -45,10 +45,29 @@ func (m *serviceConfigManager) ReloadFromFile(strict bool) error {
|
|||||||
return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
|
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)
|
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")
|
log.Infof("success reload conf")
|
||||||
return nil
|
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"
|
"time"
|
||||||
|
|
||||||
libnet "github.com/fatedier/golib/net"
|
libnet "github.com/fatedier/golib/net"
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
@@ -109,11 +109,12 @@ func (c *defaultConnectorImpl) Open() error {
|
|||||||
sn = c.cfg.ServerAddr
|
sn = c.cfg.ServerAddr
|
||||||
}
|
}
|
||||||
if lo.FromPtr(c.cfg.Transport.TLS.Enable) {
|
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.CertFile,
|
||||||
c.cfg.Transport.TLS.KeyFile,
|
c.cfg.Transport.TLS.KeyFile,
|
||||||
c.cfg.Transport.TLS.TrustedCaFile,
|
c.cfg.Transport.TLS.TrustedCaFile,
|
||||||
sn)
|
sn,
|
||||||
|
c.cfg.Transport.TLS.UseSystemCa)
|
||||||
} else {
|
} else {
|
||||||
tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn)
|
tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn)
|
||||||
}
|
}
|
||||||
@@ -194,11 +195,12 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) {
|
|||||||
sn = c.cfg.ServerAddr
|
sn = c.cfg.ServerAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsConfig, err = transport.NewClientTLSConfig(
|
tlsConfig, err = transport.NewClientTLSConfigWithSystemCA(
|
||||||
c.cfg.Transport.TLS.CertFile,
|
c.cfg.Transport.TLS.CertFile,
|
||||||
c.cfg.Transport.TLS.KeyFile,
|
c.cfg.Transport.TLS.KeyFile,
|
||||||
c.cfg.Transport.TLS.TrustedCaFile,
|
c.cfg.Transport.TLS.TrustedCaFile,
|
||||||
sn)
|
sn,
|
||||||
|
c.cfg.Transport.TLS.UseSystemCa)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("fail to build tls configuration, err: %v", err)
|
xl.Warnf("fail to build tls configuration, err: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+59
-22
@@ -30,6 +30,11 @@ import (
|
|||||||
|
|
||||||
var ErrHealthCheckType = errors.New("error health check type")
|
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 {
|
type Monitor struct {
|
||||||
checkType string
|
checkType string
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
@@ -49,6 +54,9 @@ type Monitor struct {
|
|||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
doneCh chan struct{}
|
||||||
|
|
||||||
|
timerFactory func(time.Duration) (<-chan time.Time, func())
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
|
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,
|
statusFailedFn: statusFailedFn,
|
||||||
ctx: newctx,
|
ctx: newctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
|
doneCh: make(chan struct{}),
|
||||||
|
timerFactory: newHealthTimer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,39 +112,66 @@ func (monitor *Monitor) Stop() {
|
|||||||
monitor.cancel()
|
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() {
|
func (monitor *Monitor) checkWorker() {
|
||||||
xl := xlog.FromContextSafe(monitor.ctx)
|
defer close(monitor.doneCh)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
if monitor.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))
|
doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))
|
||||||
err := monitor.doCheck(doCtx)
|
err := monitor.doCheck(doCtx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
// check if this monitor has been closed
|
// check if this monitor has been closed
|
||||||
select {
|
if monitor.ctx.Err() != nil {
|
||||||
case <-monitor.ctx.Done():
|
|
||||||
cancel()
|
|
||||||
return
|
return
|
||||||
default:
|
|
||||||
cancel()
|
|
||||||
}
|
}
|
||||||
|
monitor.handleCheckResult(err)
|
||||||
|
|
||||||
if err == nil {
|
if !monitor.waitForNextCheck() {
|
||||||
xl.Tracef("do one health check success")
|
return
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
psr.Plugin = baseCfg.Plugin.Type
|
||||||
|
|
||||||
if status.Err == "" {
|
if status.Err == "" || status.Phase == proxy.ProxyPhaseRunning {
|
||||||
psr.RemoteAddr = status.RemoteAddr
|
psr.RemoteAddr = status.RemoteAddr
|
||||||
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
|
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
|
||||||
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
|
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) {
|
func TestReloadErrorMapping(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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
|
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() {
|
func (pxy *BaseProxy) Close() {
|
||||||
if pxy.proxyPlugin != nil {
|
if pxy.proxyPlugin != nil {
|
||||||
pxy.proxyPlugin.Close()
|
pxy.proxyPlugin.Close()
|
||||||
|
|||||||
@@ -139,6 +139,41 @@ func (pm *Manager) GetProxyStatus(name string) (*WorkingStatus, bool) {
|
|||||||
return nil, false
|
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) {
|
func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
|
||||||
xl := xlog.FromContextSafe(pm.ctx)
|
xl := xlog.FromContextSafe(pm.ctx)
|
||||||
proxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string {
|
proxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/naming"
|
"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/transport"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/pkg/vnet"
|
"github.com/fatedier/frp/pkg/vnet"
|
||||||
@@ -267,8 +268,9 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
|
|||||||
xl := pw.xl
|
xl := pw.xl
|
||||||
pw.mu.RLock()
|
pw.mu.RLock()
|
||||||
pxy := pw.pxy
|
pxy := pw.pxy
|
||||||
|
running := pw.Phase == ProxyPhaseRunning
|
||||||
pw.mu.RUnlock()
|
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())
|
xl.Debugf("start a new work connection, localAddr: %s remoteAddr: %s", workConn.LocalAddr().String(), workConn.RemoteAddr().String())
|
||||||
go pxy.InWorkConn(workConn, m)
|
go pxy.InWorkConn(workConn, m)
|
||||||
} else {
|
} 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 {
|
func (pw *Wrapper) GetStatus() *WorkingStatus {
|
||||||
pw.mu.RLock()
|
pw.mu.RLock()
|
||||||
defer pw.mu.RUnlock()
|
defer pw.mu.RUnlock()
|
||||||
@@ -287,5 +308,15 @@ func (pw *Wrapper) GetStatus() *WorkingStatus {
|
|||||||
Cfg: pw.Cfg,
|
Cfg: pw.Cfg,
|
||||||
RemoteAddr: pw.RemoteAddr,
|
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
|
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.mu.Lock()
|
||||||
pxy.workConn = workConn
|
pxy.workConn = workConn
|
||||||
pxy.readCh = make(chan *msg.UDPPacket, 1024)
|
readCh := make(chan *msg.UDPPacket, 1024)
|
||||||
pxy.sendCh = make(chan msg.Message, 1024)
|
sendCh := make(chan msg.Message, 1024)
|
||||||
|
pxy.readCh = readCh
|
||||||
|
pxy.sendCh = sendCh
|
||||||
pxy.closed = false
|
pxy.closed = false
|
||||||
pxy.mu.Unlock()
|
pxy.mu.Unlock()
|
||||||
|
|
||||||
@@ -160,10 +162,10 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnSenderFn(payloadRW, pxy.sendCh)
|
go workConnSenderFn(payloadRW, sendCh)
|
||||||
go workConnReaderFn(payloadRW, pxy.readCh)
|
go workConnReaderFn(payloadRW, readCh)
|
||||||
go heartbeatFn(pxy.sendCh)
|
go heartbeatFn(sendCh)
|
||||||
|
|
||||||
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
// 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"
|
"reflect"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
"github.com/quic-go/quic-go"
|
"github.com/quic-go/quic-go"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
|||||||
+113
-19
@@ -34,6 +34,7 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/config/source"
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/text"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/policy/security"
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
@@ -123,13 +124,14 @@ type Service struct {
|
|||||||
auth *auth.ClientAuth
|
auth *auth.ClientAuth
|
||||||
|
|
||||||
// web server for admin UI and apis
|
// web server for admin UI and apis
|
||||||
webServer *httppkg.Server
|
webServer *httppkg.Server
|
||||||
|
metricsServer *httppkg.Server
|
||||||
|
|
||||||
vnetController *vnet.Controller
|
vnetController *vnet.Controller
|
||||||
|
|
||||||
cfgMu sync.RWMutex
|
cfgMu sync.RWMutex
|
||||||
// reloadMu serializes reload transactions to keep reloadCommon and applied
|
// reloadMu serializes config updates with control startup and publication,
|
||||||
// config in sync across concurrent API operations.
|
// keeping reloadCommon and applied config in sync across reloads and reconnects.
|
||||||
reloadMu sync.Mutex
|
reloadMu sync.Mutex
|
||||||
common *v1.ClientCommonConfig
|
common *v1.ClientCommonConfig
|
||||||
// reloadCommon is used for filtering/defaulting during config-source reloads.
|
// reloadCommon is used for filtering/defaulting during config-source reloads.
|
||||||
@@ -155,6 +157,9 @@ type Service struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
// call cancel to stop service
|
// call cancel to stop service
|
||||||
cancel context.CancelCauseFunc
|
cancel context.CancelCauseFunc
|
||||||
|
// Protect cancel publication and shutdown before the first Run.
|
||||||
|
lifecycleMu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
|
||||||
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
|
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
|
||||||
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
|
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
|
||||||
@@ -185,8 +190,8 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
||||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
||||||
|
|
||||||
// Create the web server after all fallible steps so its listener is not
|
// Create HTTP servers after all other fallible steps, rolling back the web
|
||||||
// leaked when an earlier error causes NewService to return.
|
// listener if the metrics server cannot be created.
|
||||||
var webServer *httppkg.Server
|
var webServer *httppkg.Server
|
||||||
if options.Common.WebServer.Port > 0 {
|
if options.Common.WebServer.Port > 0 {
|
||||||
ws, err := httppkg.NewServer(options.Common.WebServer)
|
ws, err := httppkg.NewServer(options.Common.WebServer)
|
||||||
@@ -195,11 +200,27 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
}
|
}
|
||||||
webServer = ws
|
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{
|
s := &Service{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
auth: authRuntime,
|
auth: authRuntime,
|
||||||
webServer: webServer,
|
webServer: webServer,
|
||||||
|
metricsServer: metricsServer,
|
||||||
common: options.Common,
|
common: options.Common,
|
||||||
reloadCommon: options.Common,
|
reloadCommon: options.Common,
|
||||||
configFilePath: options.ConfigFilePath,
|
configFilePath: options.ConfigFilePath,
|
||||||
@@ -225,8 +246,20 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
|
|
||||||
func (svr *Service) Run(ctx context.Context) error {
|
func (svr *Service) Run(ctx context.Context) error {
|
||||||
ctx, cancel := context.WithCancelCause(ctx)
|
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.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx))
|
||||||
svr.cancel = cancel
|
svr.cancel = cancel
|
||||||
|
svr.lifecycleMu.Unlock()
|
||||||
|
defer cancel(nil)
|
||||||
|
defer svr.stop()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// set custom DNSServer
|
// set custom DNSServer
|
||||||
if svr.common.DNSServer != "" {
|
if svr.common.DNSServer != "" {
|
||||||
@@ -237,7 +270,6 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
vnetController := svr.vnetController
|
vnetController := svr.vnetController
|
||||||
if err := svr.vnetController.Init(); err != nil {
|
if err := svr.vnetController.Init(); err != nil {
|
||||||
log.Errorf("init virtual network controller error: %v", err)
|
log.Errorf("init virtual network controller error: %v", err)
|
||||||
svr.stop()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -249,6 +281,12 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if svr.webServer != nil {
|
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
|
webServer := svr.webServer
|
||||||
go func() {
|
go func() {
|
||||||
log.Infof("admin server listen on %s", webServer.Address())
|
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
|
// first login to frps
|
||||||
svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit))
|
svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit))
|
||||||
if svr.ctl == nil {
|
if svr.ctl == nil {
|
||||||
cancelCause := cancelErr{}
|
cancelCause := cancelErr{}
|
||||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
_ = 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)
|
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go svr.keepControllerWorking()
|
go svr.keepControllerWorking()
|
||||||
|
|
||||||
<-svr.ctx.Done()
|
<-svr.ctx.Done()
|
||||||
svr.stop()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) keepControllerWorking() {
|
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,
|
// 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.
|
// 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
|
// loopLoginUntilSuccess is another layer of loop that will continuously attempt to
|
||||||
// login to the server until successful.
|
// login to the server until successful.
|
||||||
svr.loopLoginUntilSuccess(20*time.Second, false)
|
svr.loopLoginUntilSuccess(20*time.Second, false)
|
||||||
if svr.ctl != nil {
|
svr.ctlMu.RLock()
|
||||||
<-svr.ctl.Done()
|
ctl := svr.ctl
|
||||||
|
svr.ctlMu.RUnlock()
|
||||||
|
if ctl != nil {
|
||||||
|
<-ctl.Done()
|
||||||
return false, errors.New("control is closed and try another loop")
|
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.
|
// 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.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
|
||||||
xl.Infof("login to server success, get run id [%s]", 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)
|
ctl, err := NewControl(svr.ctx, sessionCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sessionCtx.Conn.Close()
|
sessionCtx.Conn.Close()
|
||||||
@@ -345,6 +397,15 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
}
|
}
|
||||||
ctl.SetInWorkConnCallback(svr.handleWorkConnCb)
|
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)
|
ctl.Run(proxyCfgs, visitorCfgs)
|
||||||
// close and replace previous control
|
// close and replace previous control
|
||||||
svr.ctlMu.Lock()
|
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 {
|
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.cfgMu.Lock()
|
||||||
svr.proxyCfgs = proxyCfgs
|
svr.proxyCfgs = proxyCfgs
|
||||||
svr.visitorCfgs = visitorCfgs
|
svr.visitorCfgs = visitorCfgs
|
||||||
@@ -377,7 +445,7 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
|
|||||||
svr.ctlMu.RUnlock()
|
svr.ctlMu.RUnlock()
|
||||||
|
|
||||||
if ctl != nil {
|
if ctl != nil {
|
||||||
return svr.ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
|
return ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -390,6 +458,15 @@ func (svr *Service) UpdateConfigSource(
|
|||||||
svr.reloadMu.Lock()
|
svr.reloadMu.Lock()
|
||||||
defer svr.reloadMu.Unlock()
|
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
|
cfgSource := svr.configSource
|
||||||
if cfgSource == nil {
|
if cfgSource == nil {
|
||||||
return fmt.Errorf("config source is not available")
|
return fmt.Errorf("config source is not available")
|
||||||
@@ -417,7 +494,20 @@ func (svr *Service) Close() {
|
|||||||
|
|
||||||
func (svr *Service) GracefulClose(d time.Duration) {
|
func (svr *Service) GracefulClose(d time.Duration) {
|
||||||
svr.gracefulShutdownDuration.Store(int64(d))
|
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() {
|
func (svr *Service) stop() {
|
||||||
@@ -441,6 +531,10 @@ func (svr *Service) stop() {
|
|||||||
svr.webServer.Close()
|
svr.webServer.Close()
|
||||||
svr.webServer = nil
|
svr.webServer = nil
|
||||||
}
|
}
|
||||||
|
if svr.metricsServer != nil {
|
||||||
|
svr.metricsServer.Close()
|
||||||
|
svr.metricsServer = nil
|
||||||
|
}
|
||||||
if svr.vnetController != nil {
|
if svr.vnetController != nil {
|
||||||
_ = svr.vnetController.Stop()
|
_ = svr.vnetController.Stop()
|
||||||
svr.vnetController = nil
|
svr.vnetController = nil
|
||||||
@@ -520,7 +614,7 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Atomically replace the entire configuration
|
// Atomically replace the entire configuration
|
||||||
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
|
if err := svr.updateAllConfigurerLocked(proxies, visitors); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
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"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
)
|
)
|
||||||
@@ -49,7 +47,7 @@ func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
|
|||||||
|
|
||||||
svr, err := NewService(ServiceOptions{
|
svr, err := NewService(ServiceOptions{
|
||||||
Common: &v1.ClientCommonConfig{
|
Common: &v1.ClientCommonConfig{
|
||||||
LoginFailExit: lo.ToPtr(true),
|
LoginFailExit: new(true),
|
||||||
WebServer: v1.WebServerConfig{
|
WebServer: v1.WebServerConfig{
|
||||||
Addr: "127.0.0.1",
|
Addr: "127.0.0.1",
|
||||||
Port: port,
|
Port: port,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
libio "github.com/fatedier/golib/io"
|
libio "github.com/fatedier/golib/io"
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,23 @@ webServer.password = "admin"
|
|||||||
# Admin assets directory. By default, these assets are bundled with frpc.
|
# Admin assets directory. By default, these assets are bundled with frpc.
|
||||||
# webServer.assetsDir = "./static"
|
# webServer.assetsDir = "./static"
|
||||||
|
|
||||||
|
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
|
||||||
# Enable golang pprof handlers in admin listener.
|
# Enable golang pprof handlers in admin listener.
|
||||||
webServer.pprofEnable = false
|
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.
|
# The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds.
|
||||||
# transport.dialServerTimeout = 10
|
# transport.dialServerTimeout = 10
|
||||||
|
|
||||||
@@ -131,6 +145,12 @@ transport.tls.enable = true
|
|||||||
# transport.tls.trustedCaFile = "ca.crt"
|
# transport.tls.trustedCaFile = "ca.crt"
|
||||||
# transport.tls.serverName = "example.com"
|
# 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
|
# If the disableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the
|
||||||
# first custom byte when tls is enabled.
|
# 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.
|
# 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)
|
# dashboard assets directory(only for debug mode)
|
||||||
# webServer.assetsDir = "./static"
|
# webServer.assetsDir = "./static"
|
||||||
|
|
||||||
|
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
|
||||||
# Enable golang pprof handlers in dashboard listener.
|
# Enable golang pprof handlers in dashboard listener.
|
||||||
# Dashboard port must be set first
|
# Dashboard port must be set first
|
||||||
webServer.pprofEnable = false
|
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
|
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
|
# console or real logFile path like ./frps.log
|
||||||
log.to = "./frps.log"
|
log.to = "./frps.log"
|
||||||
# trace, debug, info, warn, error
|
# trace, debug, info, warn, error
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ For the version compatibility policy that bounds these support windows, see the
|
|||||||
|
|
||||||
## Active
|
## 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
|
### Wire protocol v1
|
||||||
|
|
||||||
- **Deprecated since:** v0.70.0 (planned, when v2 becomes the default).
|
- **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
|
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
|
WORKDIR /web
|
||||||
RUN npm install
|
COPY web/package.json web/package-lock.json ./
|
||||||
WORKDIR /web/frpc
|
COPY web/shared/package.json ./shared/package.json
|
||||||
RUN npm run build
|
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 . /building
|
||||||
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
|
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
FROM node:22 AS web-builder
|
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
|
WORKDIR /web
|
||||||
RUN npm install
|
COPY web/package.json web/package-lock.json ./
|
||||||
WORKDIR /web/frps
|
COPY web/shared/package.json ./shared/package.json
|
||||||
RUN npm run build
|
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 . /building
|
||||||
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
|
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
module github.com/fatedier/frp
|
module github.com/fatedier/frp
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0
|
github.com/coreos/go-oidc/v3 v3.18.0
|
||||||
github.com/fatedier/golib v0.8.2
|
github.com/fatedier/golib v0.8.2
|
||||||
|
github.com/fatedier/yamux v0.2.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/hashicorp/yamux v0.1.1
|
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4
|
github.com/onsi/ginkgo/v2 v2.23.4
|
||||||
github.com/onsi/gomega v1.36.3
|
github.com/onsi/gomega v1.36.3
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0
|
github.com/pelletier/go-toml/v2 v2.2.0
|
||||||
github.com/pires/go-proxyproto v0.15.0
|
github.com/pires/go-proxyproto v0.15.0
|
||||||
github.com/prometheus/client_golang v1.19.1
|
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/quic-go/quic-go v0.60.0
|
||||||
github.com/rodaine/table v1.2.0
|
github.com/rodaine/table v1.2.0
|
||||||
github.com/samber/lo v1.47.0
|
github.com/samber/lo v1.47.0
|
||||||
@@ -25,8 +27,8 @@ require (
|
|||||||
github.com/tidwall/gjson v1.17.1
|
github.com/tidwall/gjson v1.17.1
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13
|
github.com/xtaci/kcp-go/v5 v5.6.13
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.56.0
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.57.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sync v0.22.0
|
golang.org/x/sync v0.22.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
@@ -39,7 +41,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
@@ -54,8 +56,6 @@ require (
|
|||||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
|
||||||
github.com/prometheus/common v0.48.0 // indirect
|
|
||||||
github.com/prometheus/procfs v0.12.0 // indirect
|
github.com/prometheus/procfs v0.12.0 // indirect
|
||||||
github.com/templexxx/cpu v0.1.1 // indirect
|
github.com/templexxx/cpu v0.1.1 // indirect
|
||||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
github.com/templexxx/xorsimd v0.4.3 // indirect
|
||||||
@@ -64,8 +64,8 @@ require (
|
|||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
golang.org/x/tools v0.47.0 // indirect
|
golang.org/x/tools v0.48.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
@@ -73,6 +73,3 @@ require (
|
|||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
|
|
||||||
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
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.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
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/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 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
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/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 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww=
|
||||||
github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.2.0 h1:H+2A9iBVh7aJlEOc1Ws1FXWOaecBf2nRv9zpFMPUWg8=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.2.0/go.mod h1:d4FtRDrC9sHvRpiDL6J5EnfjLhqzZZplHe5yToSn2Ac=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
@@ -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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
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/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
@@ -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-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
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.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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
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/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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
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 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -195,8 +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-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
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.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ package legacy
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/types"
|
"github.com/fatedier/frp/pkg/config/types"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
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.ConnectServerLocalIP = conf.ConnectServerLocalIP
|
||||||
out.Transport.ProxyURL = conf.HTTPProxy
|
out.Transport.ProxyURL = conf.HTTPProxy
|
||||||
out.Transport.PoolCount = conf.PoolCount
|
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.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
|
||||||
out.Transport.Protocol = conf.Protocol
|
out.Transport.Protocol = conf.Protocol
|
||||||
out.Transport.HeartbeatInterval = conf.HeartbeatInterval
|
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.KeepalivePeriod = conf.QUICKeepalivePeriod
|
||||||
out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout
|
out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout
|
||||||
out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
|
out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
|
||||||
out.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable)
|
out.Transport.TLS.Enable = new(conf.TLSEnable)
|
||||||
out.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte)
|
out.Transport.TLS.DisableCustomTLSFirstByte = new(conf.DisableCustomTLSFirstByte)
|
||||||
out.Transport.TLS.CertFile = conf.TLSCertFile
|
out.Transport.TLS.CertFile = conf.TLSCertFile
|
||||||
out.Transport.TLS.KeyFile = conf.TLSKeyFile
|
out.Transport.TLS.KeyFile = conf.TLSKeyFile
|
||||||
out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile
|
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.WebServer.PprofEnable = conf.PprofEnable
|
||||||
|
|
||||||
out.DNSServer = conf.DNSServer
|
out.DNSServer = conf.DNSServer
|
||||||
out.LoginFailExit = lo.ToPtr(conf.LoginFailExit)
|
out.LoginFailExit = new(conf.LoginFailExit)
|
||||||
out.Start = conf.Start
|
out.Start = conf.Start
|
||||||
out.UDPPacketSize = conf.UDPPacketSize
|
out.UDPPacketSize = conf.UDPPacketSize
|
||||||
out.Metadatas = conf.Metas
|
out.Metadatas = conf.Metas
|
||||||
@@ -134,14 +132,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
|
|||||||
out.Log.MaxDays = conf.LogMaxDays
|
out.Log.MaxDays = conf.LogMaxDays
|
||||||
out.Log.DisablePrintColor = conf.DisableLogColor
|
out.Log.DisablePrintColor = conf.DisableLogColor
|
||||||
|
|
||||||
out.DetailedErrorsToClient = lo.ToPtr(conf.DetailedErrorsToClient)
|
out.DetailedErrorsToClient = new(conf.DetailedErrorsToClient)
|
||||||
out.SubDomainHost = conf.SubDomainHost
|
out.SubDomainHost = conf.SubDomainHost
|
||||||
out.Custom404Page = conf.Custom404Page
|
out.Custom404Page = conf.Custom404Page
|
||||||
out.UserConnTimeout = conf.UserConnTimeout
|
out.UserConnTimeout = conf.UserConnTimeout
|
||||||
out.UDPPacketSize = conf.UDPPacketSize
|
out.UDPPacketSize = conf.UDPPacketSize
|
||||||
out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours
|
out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours
|
||||||
|
|
||||||
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
|
out.Transport.TCPMux = new(conf.TCPMux)
|
||||||
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
|
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
|
||||||
out.Transport.TCPKeepAlive = conf.TCPKeepAlive
|
out.Transport.TCPKeepAlive = conf.TCPKeepAlive
|
||||||
out.Transport.MaxPoolCount = conf.MaxPoolCount
|
out.Transport.MaxPoolCount = conf.MaxPoolCount
|
||||||
|
|||||||
+10
-8
@@ -60,10 +60,11 @@ type ClientCommonConfig struct {
|
|||||||
// set.
|
// set.
|
||||||
Start []string `json:"start,omitempty"`
|
Start []string `json:"start,omitempty"`
|
||||||
|
|
||||||
Log LogConfig `json:"log,omitempty"`
|
Log LogConfig `json:"log,omitempty"`
|
||||||
WebServer WebServerConfig `json:"webServer,omitempty"`
|
WebServer WebServerConfig `json:"webServer,omitempty"`
|
||||||
Transport ClientTransportConfig `json:"transport,omitempty"`
|
MetricsServer MetricsServerConfig `json:"metricsServer,omitempty"`
|
||||||
VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"`
|
Transport ClientTransportConfig `json:"transport,omitempty"`
|
||||||
|
VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"`
|
||||||
|
|
||||||
// FeatureGates specifies a set of feature gates to enable or disable.
|
// FeatureGates specifies a set of feature gates to enable or disable.
|
||||||
// This can be used to enable alpha/beta features or disable default features.
|
// 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 {
|
func (c *ClientCommonConfig) Complete() error {
|
||||||
c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0")
|
c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0")
|
||||||
c.ServerPort = util.EmptyOr(c.ServerPort, 7000)
|
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")
|
c.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, "stun.easyvoip.com:3478")
|
||||||
|
|
||||||
if err := c.Auth.Complete(); err != nil {
|
if err := c.Auth.Complete(); err != nil {
|
||||||
@@ -94,6 +95,7 @@ func (c *ClientCommonConfig) Complete() error {
|
|||||||
c.Log.Complete()
|
c.Log.Complete()
|
||||||
c.Transport.Complete()
|
c.Transport.Complete()
|
||||||
c.WebServer.Complete()
|
c.WebServer.Complete()
|
||||||
|
c.MetricsServer.Complete()
|
||||||
|
|
||||||
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
||||||
return nil
|
return nil
|
||||||
@@ -151,7 +153,7 @@ func (c *ClientTransportConfig) Complete() {
|
|||||||
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
|
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
|
||||||
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
|
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
|
||||||
c.PoolCount = util.EmptyOr(c.PoolCount, 1)
|
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)
|
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
|
||||||
if lo.FromPtr(c.TCPMux) {
|
if lo.FromPtr(c.TCPMux) {
|
||||||
// If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in 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() {
|
func (c *TLSClientConfig) Complete() {
|
||||||
c.Enable = util.EmptyOr(c.Enable, lo.ToPtr(true))
|
c.Enable = util.EmptyOr(c.Enable, new(true))
|
||||||
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, lo.ToPtr(true))
|
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, new(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthClientConfig struct {
|
type AuthClientConfig struct {
|
||||||
|
|||||||
@@ -72,6 +72,26 @@ func (c *WebServerConfig) Complete() {
|
|||||||
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
|
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 {
|
type TLSConfig struct {
|
||||||
// CertFile specifies the path of the cert file that client will load.
|
// CertFile specifies the path of the cert file that client will load.
|
||||||
CertFile string `json:"certFile,omitempty"`
|
CertFile string `json:"certFile,omitempty"`
|
||||||
@@ -79,6 +99,10 @@ type TLSConfig struct {
|
|||||||
KeyFile string `json:"keyFile,omitempty"`
|
KeyFile string `json:"keyFile,omitempty"`
|
||||||
// TrustedCaFile specifies the path of the trusted ca file that will load.
|
// TrustedCaFile specifies the path of the trusted ca file that will load.
|
||||||
TrustedCaFile string `json:"trustedCaFile,omitempty"`
|
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
|
// ServerName specifies the custom server name of tls certificate. By
|
||||||
// default, server name if same to ServerAddr.
|
// default, server name if same to ServerAddr.
|
||||||
ServerName string `json:"serverName,omitempty"`
|
ServerName string `json:"serverName,omitempty"`
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/jsonx"
|
"github.com/fatedier/frp/pkg/util/jsonx"
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
)
|
)
|
||||||
@@ -125,7 +123,7 @@ type HTTPS2HTTPPluginOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPPluginOptions) Complete() {
|
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 {
|
func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
|
||||||
@@ -149,7 +147,7 @@ type HTTPS2HTTPSPluginOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *HTTPS2HTTPSPluginOptions) Complete() {
|
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 {
|
func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ type ServerConfig struct {
|
|||||||
|
|
||||||
SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"`
|
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
|
// EnablePrometheus will export prometheus metrics on webserver address
|
||||||
// in /metrics api.
|
// in /metrics api.
|
||||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||||
@@ -105,6 +106,7 @@ func (c *ServerConfig) Complete() error {
|
|||||||
c.Log.Complete()
|
c.Log.Complete()
|
||||||
c.Transport.Complete()
|
c.Transport.Complete()
|
||||||
c.WebServer.Complete()
|
c.WebServer.Complete()
|
||||||
|
c.MetricsServer.Complete()
|
||||||
c.SSHTunnelGateway.Complete()
|
c.SSHTunnelGateway.Complete()
|
||||||
|
|
||||||
c.BindAddr = util.EmptyOr(c.BindAddr, "0.0.0.0")
|
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.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.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
|
||||||
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
|
||||||
c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)
|
c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)
|
||||||
@@ -180,7 +182,7 @@ type ServerTransportConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ServerTransportConfig) Complete() {
|
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.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
|
||||||
c.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200)
|
c.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200)
|
||||||
c.MaxPoolCount = util.EmptyOr(c.MaxPoolCount, 5)
|
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 v.validateAuthConfig(&c.Auth) },
|
||||||
func() (Warning, error) { return nil, validateLogConfig(&c.Log) },
|
func() (Warning, error) { return nil, validateLogConfig(&c.Log) },
|
||||||
func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) },
|
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 validateTransportConfig(&c.Transport) },
|
||||||
func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) },
|
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.certFile", c.TLS.CertFile))
|
||||||
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile))
|
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile))
|
||||||
warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.TLS.TrustedCaFile))
|
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) {
|
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import (
|
|||||||
|
|
||||||
func validateWebServerConfig(c *v1.WebServerConfig) error {
|
func validateWebServerConfig(c *v1.WebServerConfig) error {
|
||||||
if c.TLS != nil {
|
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 == "" {
|
if c.TLS.CertFile == "" {
|
||||||
return fmt.Errorf("tls.certFile must be specified when tls is enabled")
|
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")
|
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
|
// ValidatePort checks that the network port is in range
|
||||||
func ValidatePort(port int, fieldPath string) error {
|
func ValidatePort(port int, fieldPath string) error {
|
||||||
if 0 <= port && port <= 65535 {
|
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 {
|
if err := validateWebServerConfig(&c.WebServer); err != nil {
|
||||||
errs = AppendError(errs, err)
|
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.BindPort, "bindPort"))
|
||||||
errs = AppendError(errs, ValidatePort(c.KCPBindPort, "kcpBindPort"))
|
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"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/proto/wire"
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
)
|
)
|
||||||
@@ -111,14 +113,22 @@ type Dispatcher struct {
|
|||||||
|
|
||||||
sendCh chan Message
|
sendCh chan Message
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
msgHandlers map[reflect.Type]func(Message)
|
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 {
|
func NewDispatcher(rw ReadWriter) *Dispatcher {
|
||||||
return &Dispatcher{
|
return &Dispatcher{
|
||||||
rw: rw,
|
rw: rw,
|
||||||
sendCh: make(chan Message, 100),
|
sendCh: make(chan Message, 100),
|
||||||
doneCh: make(chan struct{}),
|
doneCh: make(chan struct{}),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
msgHandlers: make(map[reflect.Type]func(Message)),
|
msgHandlers: make(map[reflect.Type]func(Message)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,37 +139,78 @@ func (d *Dispatcher) Run() {
|
|||||||
go d.readLoop()
|
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() {
|
func (d *Dispatcher) sendLoop() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-d.doneCh:
|
case <-d.stopCh:
|
||||||
return
|
return
|
||||||
case m := <-d.sendCh:
|
case m := <-d.sendCh:
|
||||||
_ = d.rw.WriteMsg(m)
|
if err := d.rw.WriteMsg(m); err != nil {
|
||||||
|
d.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) readLoop() {
|
func (d *Dispatcher) readLoop() {
|
||||||
|
defer close(d.doneCh)
|
||||||
|
defer d.stop()
|
||||||
for {
|
for {
|
||||||
m, err := d.rw.ReadMsg()
|
m, err := d.rw.ReadMsg()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
close(d.doneCh)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-d.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
|
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
|
||||||
handler(m)
|
handler(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send queues m for writing. A nil error does not guarantee delivery.
|
||||||
func (d *Dispatcher) Send(m Message) error {
|
func (d *Dispatcher) Send(m Message) error {
|
||||||
select {
|
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
|
return io.EOF
|
||||||
case d.sendCh <- m:
|
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 {
|
type httpBridgePlugin struct {
|
||||||
l *Listener
|
l *Listener
|
||||||
s *http.Server
|
s *http.Server
|
||||||
|
httpsServer *httpsserver.Server
|
||||||
|
|
||||||
useSourceRemoteAddr bool
|
useSourceRemoteAddr bool
|
||||||
}
|
}
|
||||||
@@ -87,7 +88,8 @@ func newHTTPSBridgePluginServer(
|
|||||||
}
|
}
|
||||||
p := &httpBridgePlugin{
|
p := &httpBridgePlugin{
|
||||||
l: listener,
|
l: listener,
|
||||||
s: server,
|
s: server.Server,
|
||||||
|
httpsServer: server,
|
||||||
useSourceRemoteAddr: useSourceRemoteAddr,
|
useSourceRemoteAddr: useSourceRemoteAddr,
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -124,3 +126,18 @@ func (p *httpBridgePlugin) Close() error {
|
|||||||
_ = p.l.Close()
|
_ = p.l.Close()
|
||||||
return err
|
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
|
package httpsserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
@@ -28,13 +32,38 @@ import (
|
|||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*http.Server, error) {
|
// snapshot is immutable after publication. Errors retain the last usable pair.
|
||||||
tlsConfig, err := transport.NewServerTLSConfig(crtPath, keyPath, "")
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := &http.Server{
|
server.Server = &http.Server{
|
||||||
Handler: withMisdirectedRequestCheck(handler),
|
Handler: withMisdirectedRequestCheck(handler),
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
ReadHeaderTimeout: 60 * time.Second,
|
||||||
TLSConfig: tlsConfig,
|
TLSConfig: tlsConfig,
|
||||||
@@ -58,3 +87,51 @@ func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
|||||||
handler.ServeHTTP(w, r)
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -33,10 +34,16 @@ func init() {
|
|||||||
Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)
|
Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type clientRouteController interface {
|
||||||
|
RegisterClientRoute(context.Context, string, []net.IPNet, io.ReadWriteCloser)
|
||||||
|
UnregisterClientRoute(string, io.Writer) bool
|
||||||
|
}
|
||||||
|
|
||||||
type VirtualNetPlugin struct {
|
type VirtualNetPlugin struct {
|
||||||
pluginCtx PluginContext
|
pluginCtx PluginContext
|
||||||
|
|
||||||
routes []net.IPNet
|
routeController clientRouteController
|
||||||
|
routes []net.IPNet
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
controllerConn net.Conn
|
controllerConn net.Conn
|
||||||
@@ -48,6 +55,11 @@ type VirtualNetPlugin struct {
|
|||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
virtualNetReconnectBaseDelay = 60 * time.Second
|
||||||
|
virtualNetReconnectMaxDelay = 300 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {
|
func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.VirtualNetVisitorPluginOptions)
|
opts := options.(*v1.VirtualNetVisitorPluginOptions)
|
||||||
|
|
||||||
@@ -55,6 +67,9 @@ func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOption
|
|||||||
pluginCtx: pluginCtx,
|
pluginCtx: pluginCtx,
|
||||||
routes: make([]net.IPNet, 0),
|
routes: make([]net.IPNet, 0),
|
||||||
}
|
}
|
||||||
|
if pluginCtx.VnetController != nil {
|
||||||
|
p.routeController = pluginCtx.VnetController
|
||||||
|
}
|
||||||
|
|
||||||
p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)
|
p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)
|
||||||
|
|
||||||
@@ -85,7 +100,7 @@ func (p *VirtualNetPlugin) Name() string {
|
|||||||
|
|
||||||
func (p *VirtualNetPlugin) Start() {
|
func (p *VirtualNetPlugin) Start() {
|
||||||
xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
|
xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
|
||||||
if p.pluginCtx.VnetController == nil {
|
if p.routeController == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,16 +126,17 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
select {
|
select {
|
||||||
case <-p.ctx.Done():
|
case <-p.ctx.Done():
|
||||||
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name)
|
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name)
|
||||||
p.cleanupControllerConn(xl)
|
p.cleanupCurrentControllerConn(xl)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
controllerConn, pluginConn := net.Pipe()
|
controllerConn, pluginConn := net.Pipe()
|
||||||
|
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
|
||||||
p.mu.Lock()
|
if !p.registerControllerConn(controllerConn, pluginConn) {
|
||||||
p.controllerConn = controllerConn
|
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before route registration).", p.pluginCtx.Name)
|
||||||
p.mu.Unlock()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap with CloseNotifyConn which supports both close notification and error recording
|
// Wrap with CloseNotifyConn which supports both close notification and error recording
|
||||||
var closeErr error
|
var closeErr error
|
||||||
@@ -129,8 +145,6 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
close(currentCloseSignal) // Signal the run loop on close.
|
close(currentCloseSignal) // Signal the run loop on close.
|
||||||
})
|
})
|
||||||
|
|
||||||
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
|
|
||||||
p.pluginCtx.VnetController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
|
|
||||||
xl.Infof("successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name)
|
xl.Infof("successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name)
|
||||||
|
|
||||||
// Pass the CloseNotifyConn to the visitor for handling.
|
// Pass the CloseNotifyConn to the visitor for handling.
|
||||||
@@ -141,7 +155,7 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
select {
|
select {
|
||||||
case <-p.ctx.Done():
|
case <-p.ctx.Done():
|
||||||
xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name)
|
xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name)
|
||||||
p.cleanupControllerConn(xl)
|
p.cleanupControllerConn(xl, controllerConn)
|
||||||
return
|
return
|
||||||
case <-currentCloseSignal:
|
case <-currentCloseSignal:
|
||||||
// Determine reconnect delay based on error with exponential backoff
|
// Determine reconnect delay based on error with exponential backoff
|
||||||
@@ -152,8 +166,7 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
p.pluginCtx.Name, p.consecutiveErrors, closeErr)
|
p.pluginCtx.Name, p.consecutiveErrors, closeErr)
|
||||||
|
|
||||||
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
|
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
|
||||||
baseDelay := 60 * time.Second
|
reconnectDelay = virtualNetReconnectDelay(p.consecutiveErrors)
|
||||||
reconnectDelay = min(baseDelay*time.Duration(1<<uint(p.consecutiveErrors-1)), 300*time.Second)
|
|
||||||
} else {
|
} else {
|
||||||
// Reset consecutive errors on successful connection
|
// Reset consecutive errors on successful connection
|
||||||
if p.consecutiveErrors > 0 {
|
if p.consecutiveErrors > 0 {
|
||||||
@@ -167,7 +180,7 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The visitor closed the plugin side. Close the controller side.
|
// The visitor closed the plugin side. Close the controller side.
|
||||||
p.cleanupControllerConn(xl)
|
p.cleanupControllerConn(xl, controllerConn)
|
||||||
|
|
||||||
xl.Infof("waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name)
|
xl.Infof("waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name)
|
||||||
select {
|
select {
|
||||||
@@ -182,16 +195,66 @@ func (p *VirtualNetPlugin) run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupControllerConn closes the current controllerConn (if it exists) under lock.
|
// registerControllerConn publishes and registers controllerConn atomically with
|
||||||
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) {
|
// respect to Close. A canceled plugin cannot register a new route.
|
||||||
|
func (p *VirtualNetPlugin) registerControllerConn(controllerConn, pluginConn net.Conn) bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.ctx.Err() != nil || p.routeController == nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
_ = controllerConn.Close()
|
||||||
|
_ = pluginConn.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
p.controllerConn = controllerConn
|
||||||
|
p.routeController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
|
||||||
|
p.mu.Unlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// virtualNetReconnectDelay returns a bounded reconnect delay without allowing
|
||||||
|
// the exponential shift to overflow for large consecutive error counts.
|
||||||
|
func virtualNetReconnectDelay(consecutiveErrors int) time.Duration {
|
||||||
|
if consecutiveErrors <= 1 {
|
||||||
|
return virtualNetReconnectBaseDelay
|
||||||
|
}
|
||||||
|
if consecutiveErrors >= 4 {
|
||||||
|
return virtualNetReconnectMaxDelay
|
||||||
|
}
|
||||||
|
return virtualNetReconnectBaseDelay * time.Duration(1<<uint(consecutiveErrors-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupControllerConn unregisters and closes one connection round without
|
||||||
|
// affecting a replacement route owned by another connection.
|
||||||
|
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger, controllerConn net.Conn) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
if p.controllerConn != nil {
|
p.cleanupControllerConnLocked(xl, controllerConn)
|
||||||
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
|
}
|
||||||
p.controllerConn.Close()
|
|
||||||
p.controllerConn = nil
|
func (p *VirtualNetPlugin) cleanupCurrentControllerConn(xl *xlog.Logger) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.cleanupControllerConnLocked(xl, p.controllerConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupControllerConnLocked must be called with p.mu held.
|
||||||
|
func (p *VirtualNetPlugin) cleanupControllerConnLocked(xl *xlog.Logger, controllerConn net.Conn) {
|
||||||
|
if controllerConn == nil {
|
||||||
|
p.closeSignal = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.routeController != nil &&
|
||||||
|
p.routeController.UnregisterClientRoute(p.pluginCtx.Name, controllerConn) {
|
||||||
|
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
|
||||||
|
}
|
||||||
|
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
|
||||||
|
_ = controllerConn.Close()
|
||||||
|
if p.controllerConn == controllerConn {
|
||||||
|
p.controllerConn = nil
|
||||||
|
p.closeSignal = nil
|
||||||
}
|
}
|
||||||
p.closeSignal = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close initiates the plugin shutdown.
|
// Close initiates the plugin shutdown.
|
||||||
@@ -202,15 +265,9 @@ func (p *VirtualNetPlugin) Close() error {
|
|||||||
// Signal the run loop goroutine to stop.
|
// Signal the run loop goroutine to stop.
|
||||||
p.cancel()
|
p.cancel()
|
||||||
|
|
||||||
// Unregister the route from the controller.
|
// Unregister and close the current connection while holding the same lock
|
||||||
if p.pluginCtx.VnetController != nil {
|
// used to check cancellation and register a route in run.
|
||||||
p.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name)
|
p.cleanupCurrentControllerConn(xl)
|
||||||
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicitly close the controller side of the pipe.
|
|
||||||
// This ensures the pipe is broken even if the run loop is stuck or the visitor hasn't closed its end.
|
|
||||||
p.cleanupControllerConn(xl)
|
|
||||||
xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name)
|
xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//go:build !frps
|
||||||
|
|
||||||
|
package visitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testVirtualNetVisitorName = "vnet-visitor"
|
||||||
|
|
||||||
|
type fakeClientRouteController struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
routes map[string]io.Writer
|
||||||
|
|
||||||
|
beforeRegister func()
|
||||||
|
registerCalls int
|
||||||
|
unregisterCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeClientRouteController() *fakeClientRouteController {
|
||||||
|
return &fakeClientRouteController{
|
||||||
|
routes: make(map[string]io.Writer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClientRouteController) RegisterClientRoute(
|
||||||
|
_ context.Context,
|
||||||
|
name string,
|
||||||
|
_ []net.IPNet,
|
||||||
|
conn io.ReadWriteCloser,
|
||||||
|
) {
|
||||||
|
if c.beforeRegister != nil {
|
||||||
|
c.beforeRegister()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.registerCalls++
|
||||||
|
c.routes[name] = conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClientRouteController) UnregisterClientRoute(name string, conn io.Writer) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.unregisterCalls++
|
||||||
|
owner, ok := c.routes[name]
|
||||||
|
if !ok || owner != conn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(c.routes, name)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClientRouteController) owner() io.Writer {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.routes[testVirtualNetVisitorName]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClientRouteController) callCounts() (register, unregister int) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.registerCalls, c.unregisterCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackedConn struct {
|
||||||
|
net.Conn
|
||||||
|
closed atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedConn) Close() error {
|
||||||
|
c.closed.Store(true)
|
||||||
|
return c.Conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrackedPipe(t *testing.T) (*trackedConn, *trackedConn) {
|
||||||
|
t.Helper()
|
||||||
|
left, right := net.Pipe()
|
||||||
|
trackedLeft := &trackedConn{Conn: left}
|
||||||
|
trackedRight := &trackedConn{Conn: right}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = trackedLeft.Close()
|
||||||
|
_ = trackedRight.Close()
|
||||||
|
})
|
||||||
|
return trackedLeft, trackedRight
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestVirtualNetPlugin(t *testing.T, controller *fakeClientRouteController) *VirtualNetPlugin {
|
||||||
|
t.Helper()
|
||||||
|
pluginCtx := context.Background()
|
||||||
|
ctx, cancel := context.WithCancel(pluginCtx)
|
||||||
|
p := &VirtualNetPlugin{
|
||||||
|
pluginCtx: PluginContext{
|
||||||
|
Name: testVirtualNetVisitorName,
|
||||||
|
Ctx: pluginCtx,
|
||||||
|
},
|
||||||
|
routeController: controller,
|
||||||
|
routes: []net.IPNet{{
|
||||||
|
IP: net.ParseIP("10.1.0.1"),
|
||||||
|
Mask: net.CIDRMask(32, 32),
|
||||||
|
}},
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = p.Close()
|
||||||
|
})
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitResult[T any](t *testing.T, ch <-chan T) T {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case result := <-ch:
|
||||||
|
return result
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for concurrent operation")
|
||||||
|
var zero T
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVirtualNetReconnectDelay verifies the documented exponential backoff and
|
||||||
|
// ensures large error counts remain capped instead of overflowing to zero.
|
||||||
|
func TestVirtualNetReconnectDelay(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
consecutiveErrors int
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{name: "first error", consecutiveErrors: 1, want: 60 * time.Second},
|
||||||
|
{name: "second error", consecutiveErrors: 2, want: 120 * time.Second},
|
||||||
|
{name: "third error", consecutiveErrors: 3, want: 240 * time.Second},
|
||||||
|
{name: "fourth error", consecutiveErrors: 4, want: 300 * time.Second},
|
||||||
|
{name: "shift width boundary", consecutiveErrors: 64, want: 300 * time.Second},
|
||||||
|
{name: "observed retry storm", consecutiveErrors: 329769, want: 300 * time.Second},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, virtualNetReconnectDelay(tt.consecutiveErrors))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVirtualNetPluginCloseBeforeRegisterDoesNotReplaceNewRoute(t *testing.T) {
|
||||||
|
controller := newFakeClientRouteController()
|
||||||
|
oldPlugin := newTestVirtualNetPlugin(t, controller)
|
||||||
|
newPlugin := newTestVirtualNetPlugin(t, controller)
|
||||||
|
oldControllerConn, oldPluginConn := newTrackedPipe(t)
|
||||||
|
newControllerConn, newPluginConn := newTrackedPipe(t)
|
||||||
|
|
||||||
|
allowOldRegister := make(chan struct{})
|
||||||
|
oldRegisterResult := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
<-allowOldRegister
|
||||||
|
oldRegisterResult <- oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn)
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.NoError(t, oldPlugin.Close())
|
||||||
|
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
|
||||||
|
close(allowOldRegister)
|
||||||
|
|
||||||
|
require.False(t, waitResult(t, oldRegisterResult))
|
||||||
|
require.Same(t, newControllerConn, controller.owner())
|
||||||
|
registerCalls, _ := controller.callCounts()
|
||||||
|
require.Equal(t, 1, registerCalls)
|
||||||
|
require.True(t, oldControllerConn.closed.Load())
|
||||||
|
require.True(t, oldPluginConn.closed.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVirtualNetPluginRegisterBeforeCloseIsCleanedUp(t *testing.T) {
|
||||||
|
controller := newFakeClientRouteController()
|
||||||
|
p := newTestVirtualNetPlugin(t, controller)
|
||||||
|
controllerConn, pluginConn := newTrackedPipe(t)
|
||||||
|
registerEntered := make(chan struct{})
|
||||||
|
var registerEnteredOnce sync.Once
|
||||||
|
controller.beforeRegister = func() {
|
||||||
|
registerEnteredOnce.Do(func() {
|
||||||
|
close(registerEntered)
|
||||||
|
})
|
||||||
|
<-p.ctx.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
registerResult := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
registerResult <- p.registerControllerConn(controllerConn, pluginConn)
|
||||||
|
}()
|
||||||
|
waitResult(t, registerEntered)
|
||||||
|
|
||||||
|
closeResult := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
closeResult <- p.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.NoError(t, waitResult(t, closeResult))
|
||||||
|
require.True(t, waitResult(t, registerResult))
|
||||||
|
require.Nil(t, controller.owner())
|
||||||
|
registerCalls, unregisterCalls := controller.callCounts()
|
||||||
|
require.Equal(t, 1, registerCalls)
|
||||||
|
require.Equal(t, 1, unregisterCalls)
|
||||||
|
require.True(t, controllerConn.closed.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVirtualNetPluginOldConnectionCleanupKeepsReplacementRoute(t *testing.T) {
|
||||||
|
controller := newFakeClientRouteController()
|
||||||
|
oldPlugin := newTestVirtualNetPlugin(t, controller)
|
||||||
|
newPlugin := newTestVirtualNetPlugin(t, controller)
|
||||||
|
oldControllerConn, oldPluginConn := newTrackedPipe(t)
|
||||||
|
newControllerConn, newPluginConn := newTrackedPipe(t)
|
||||||
|
|
||||||
|
require.True(t, oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn))
|
||||||
|
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
|
||||||
|
require.Same(t, newControllerConn, controller.owner())
|
||||||
|
|
||||||
|
oldPlugin.cleanupControllerConn(xlog.FromContextSafe(oldPlugin.ctx), oldControllerConn)
|
||||||
|
|
||||||
|
require.Same(t, newControllerConn, controller.owner())
|
||||||
|
require.True(t, oldControllerConn.closed.Load())
|
||||||
|
}
|
||||||
+49
-5
@@ -80,17 +80,23 @@ func newRandomTLSKeyPair() (*tls.Certificate, error) {
|
|||||||
// Only support one ca file to add
|
// Only support one ca file to add
|
||||||
func newCertPool(caPath string) (*x509.CertPool, error) {
|
func newCertPool(caPath string) (*x509.CertPool, error) {
|
||||||
pool := x509.NewCertPool()
|
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)
|
caCrt, err := os.ReadFile(caPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !pool.AppendCertsFromPEM(caCrt) {
|
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) {
|
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) {
|
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{}
|
base := &tls.Config{}
|
||||||
|
|
||||||
if certPath != "" && keyPath != "" {
|
if certPath != "" && keyPath != "" {
|
||||||
@@ -139,7 +160,26 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf
|
|||||||
|
|
||||||
base.ServerName = serverName
|
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)
|
pool, err := newCertPool(caPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -147,7 +187,11 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf
|
|||||||
|
|
||||||
base.RootCAs = pool
|
base.RootCAs = pool
|
||||||
base.InsecureSkipVerify = false
|
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
|
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) {
|
func NewServer(cfg v1.WebServerConfig) (*Server, error) {
|
||||||
assets.Load(cfg.AssetsDir)
|
assets.Load(cfg.AssetsDir)
|
||||||
|
|
||||||
addr := net.JoinHostPort(cfg.Addr, strconv.Itoa(cfg.Port))
|
s, err := newServer(cfg.Addr, cfg.Port, cfg.User, cfg.Password, cfg.TLS)
|
||||||
if addr == ":" {
|
|
||||||
addr = ":http"
|
|
||||||
}
|
|
||||||
|
|
||||||
ln, err := net.Listen("tcp", addr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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()
|
router := mux.NewRouter()
|
||||||
hs := &http.Server{
|
hs := &http.Server{
|
||||||
@@ -67,15 +73,11 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) {
|
|||||||
}
|
}
|
||||||
s := &Server{
|
s := &Server{
|
||||||
addr: addr,
|
addr: addr,
|
||||||
ln: ln,
|
|
||||||
hs: hs,
|
hs: hs,
|
||||||
router: router,
|
router: router,
|
||||||
}
|
}
|
||||||
if cfg.PprofEnable {
|
if tlsConfig != nil {
|
||||||
s.registerPprofHandlers()
|
cert, err := tls.LoadX509KeyPair(tlsConfig.CertFile, tlsConfig.KeyFile)
|
||||||
}
|
|
||||||
if cfg.TLS != nil {
|
|
||||||
cert, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -83,7 +85,14 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) {
|
|||||||
Certificates: []tls.Certificate{cert},
|
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
|
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,
|
reserveDays: reserveDays,
|
||||||
counts: make([]int64, reserveDays),
|
counts: make([]int64, reserveDays),
|
||||||
clock: clk,
|
clock: clk,
|
||||||
lastUpdateDate: startOfDay(clk.Now()),
|
lastUpdateDate: calendarDate(clk.Now()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ func (c *StandardDateCounter) Clear() {
|
|||||||
// rotate
|
// rotate
|
||||||
// Must hold the lock before calling this function.
|
// Must hold the lock before calling this function.
|
||||||
func (c *StandardDateCounter) rotate(now time.Time) {
|
func (c *StandardDateCounter) rotate(now time.Time) {
|
||||||
now = startOfDay(now)
|
now = calendarDate(now)
|
||||||
days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
|
days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
|
||||||
reserveDays := int(c.reserveDays)
|
reserveDays := int(c.reserveDays)
|
||||||
|
|
||||||
@@ -132,7 +132,8 @@ func (c *StandardDateCounter) rotate(now time.Time) {
|
|||||||
c.lastUpdateDate = now
|
c.lastUpdateDate = now
|
||||||
}
|
}
|
||||||
|
|
||||||
// startOfDay returns midnight in t's location.
|
// calendarDate represents t's local date as midnight UTC for day arithmetic.
|
||||||
func startOfDay(t time.Time) time.Time {
|
// This avoids variable day lengths and nonexistent local midnights during DST.
|
||||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
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) {
|
func TestDateCounterRotate(t *testing.T) {
|
||||||
loc := time.FixedZone("test", 8*60*60)
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -55,19 +55,19 @@ func TestDateCounterRotate(t *testing.T) {
|
|||||||
name: "one day",
|
name: "one day",
|
||||||
now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc),
|
now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc),
|
||||||
want: []int64{0, 10, 7},
|
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",
|
name: "two days",
|
||||||
now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc),
|
now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc),
|
||||||
want: []int64{0, 0, 10},
|
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",
|
name: "all reserved days elapsed",
|
||||||
now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc),
|
now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc),
|
||||||
want: []int64{0, 0, 0},
|
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()
|
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
|
package net
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
kcp "github.com/xtaci/kcp-go/v5"
|
kcp "github.com/xtaci/kcp-go/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrKCPListenerClosed = errors.New("kcp listener closed")
|
||||||
|
|
||||||
type KCPListener struct {
|
type KCPListener struct {
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
acceptCh chan net.Conn
|
acceptCh chan net.Conn
|
||||||
closeFlag bool
|
done chan struct{}
|
||||||
|
|
||||||
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListenKcp(address string) (l *KCPListener, err error) {
|
func ListenKcp(address string) (l *KCPListener, err error) {
|
||||||
@@ -36,20 +41,21 @@ func ListenKcp(address string) (l *KCPListener, err error) {
|
|||||||
_ = listener.SetWriteBuffer(4194304)
|
_ = listener.SetWriteBuffer(4194304)
|
||||||
|
|
||||||
l = &KCPListener{
|
l = &KCPListener{
|
||||||
listener: listener,
|
listener: listener,
|
||||||
acceptCh: make(chan net.Conn),
|
acceptCh: make(chan net.Conn),
|
||||||
closeFlag: false,
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
conn, err := listener.AcceptKCP()
|
conn, err := listener.AcceptKCP()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if l.closeFlag {
|
select {
|
||||||
close(l.acceptCh)
|
case <-l.done:
|
||||||
return
|
return
|
||||||
|
default:
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
conn.SetStreamMode(true)
|
conn.SetStreamMode(true)
|
||||||
conn.SetWriteDelay(true)
|
conn.SetWriteDelay(true)
|
||||||
@@ -58,25 +64,38 @@ func ListenKcp(address string) (l *KCPListener, err error) {
|
|||||||
conn.SetWindowSize(1024, 1024)
|
conn.SetWindowSize(1024, 1024)
|
||||||
conn.SetACKNoDelay(false)
|
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
|
return l, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *KCPListener) Accept() (net.Conn, error) {
|
func (l *KCPListener) Accept() (net.Conn, error) {
|
||||||
conn, ok := <-l.acceptCh
|
select {
|
||||||
if !ok {
|
case <-l.done:
|
||||||
return conn, fmt.Errorf("channel for kcp listener closed")
|
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 {
|
func (l *KCPListener) Close() error {
|
||||||
if !l.closeFlag {
|
l.closeOnce.Do(func() {
|
||||||
l.closeFlag = true
|
close(l.done)
|
||||||
l.listener.Close()
|
l.listener.Close()
|
||||||
}
|
})
|
||||||
return nil
|
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"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/websocket"
|
"golang.org/x/net/websocket"
|
||||||
@@ -18,8 +19,11 @@ const (
|
|||||||
type WebsocketListener struct {
|
type WebsocketListener struct {
|
||||||
ln net.Listener
|
ln net.Listener
|
||||||
acceptCh chan net.Conn
|
acceptCh chan net.Conn
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
server *http.Server
|
server *http.Server
|
||||||
|
|
||||||
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebsocketListener to handle websocket connections
|
// NewWebsocketListener to handle websocket connections
|
||||||
@@ -28,6 +32,7 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
|||||||
wl = &WebsocketListener{
|
wl = &WebsocketListener{
|
||||||
ln: ln,
|
ln: ln,
|
||||||
acceptCh: make(chan net.Conn),
|
acceptCh: make(chan net.Conn),
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
muxer := http.NewServeMux()
|
muxer := http.NewServeMux()
|
||||||
@@ -41,7 +46,14 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
|||||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||||
close(notifyCh)
|
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
|
<-notifyCh
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -58,14 +70,21 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *WebsocketListener) Accept() (net.Conn, error) {
|
func (p *WebsocketListener) Accept() (net.Conn, error) {
|
||||||
c, ok := <-p.acceptCh
|
select {
|
||||||
if !ok {
|
case <-p.done:
|
||||||
return nil, ErrWebsocketListenerClosed
|
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 {
|
func (p *WebsocketListener) Close() error {
|
||||||
|
p.closeOnce.Do(func() { close(p.done) })
|
||||||
return p.server.Close()
|
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
|
package version
|
||||||
|
|
||||||
var version = "0.71.0"
|
var version = "0.71.1"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
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) {
|
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)
|
hj, ok := rw.(http.Hijacker)
|
||||||
if !ok {
|
if !ok {
|
||||||
rw.WriteHeader(http.StatusInternalServerError)
|
rw.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
rw.WriteHeader(http.StatusInternalServerError)
|
rw.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -224,8 +240,12 @@ func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Req
|
|||||||
client.Close()
|
client.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = req.Write(remote)
|
if err := outreq.Write(remote); err != nil {
|
||||||
go libio.Join(remote, client)
|
_ = remote.Close()
|
||||||
|
_ = client.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go libio.Join(remote, libio.WrapReadWriteCloser(buffered.Reader, client, client.Close))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRequestRouteUser(req *http.Request) string {
|
func getRequestRouteUser(req *http.Request) string {
|
||||||
|
|||||||
@@ -3,17 +3,266 @@ package vhost
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
|
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) {
|
func TestHTTPServerProtocols(t *testing.T) {
|
||||||
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
||||||
protocols := new(http.Protocols)
|
protocols := new(http.Protocols)
|
||||||
|
|||||||
+29
-10
@@ -17,6 +17,8 @@ package xlog
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
)
|
)
|
||||||
@@ -30,11 +32,13 @@ type LogPrefix struct {
|
|||||||
Priority int
|
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 {
|
type Logger struct {
|
||||||
|
mu sync.Mutex
|
||||||
prefixes []LogPrefix
|
prefixes []LogPrefix
|
||||||
|
|
||||||
prefixString string
|
prefixString atomic.Pointer[string]
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Logger {
|
func New() *Logger {
|
||||||
@@ -44,9 +48,11 @@ func New() *Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) ResetPrefixes() (old []LogPrefix) {
|
func (l *Logger) ResetPrefixes() (old []LogPrefix) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
old = l.prefixes
|
old = l.prefixes
|
||||||
l.prefixes = make([]LogPrefix, 0)
|
l.prefixes = make([]LogPrefix, 0)
|
||||||
l.prefixString = ""
|
l.prefixString.Store(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +65,8 @@ func (l *Logger) AppendPrefix(prefix string) *Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) AddPrefix(prefix LogPrefix) *Logger {
|
func (l *Logger) AddPrefix(prefix LogPrefix) *Logger {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
found := false
|
found := false
|
||||||
if prefix.Priority <= 0 {
|
if prefix.Priority <= 0 {
|
||||||
prefix.Priority = 10
|
prefix.Priority = 10
|
||||||
@@ -78,39 +86,50 @@ func (l *Logger) AddPrefix(prefix LogPrefix) *Logger {
|
|||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The caller must hold l.mu or exclusively own an unpublished logger.
|
||||||
func (l *Logger) renderPrefixString() {
|
func (l *Logger) renderPrefixString() {
|
||||||
slices.SortStableFunc(l.prefixes, func(a, b LogPrefix) int {
|
slices.SortStableFunc(l.prefixes, func(a, b LogPrefix) int {
|
||||||
return cmp.Compare(a.Priority, b.Priority)
|
return cmp.Compare(a.Priority, b.Priority)
|
||||||
})
|
})
|
||||||
l.prefixString = ""
|
var prefixString string
|
||||||
for _, v := range l.prefixes {
|
for _, v := range l.prefixes {
|
||||||
l.prefixString += "[" + v.Value + "] "
|
prefixString += "[" + v.Value + "] "
|
||||||
}
|
}
|
||||||
|
l.prefixString.Store(&prefixString)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Spawn() *Logger {
|
func (l *Logger) Spawn() *Logger {
|
||||||
nl := New()
|
nl := New()
|
||||||
|
l.mu.Lock()
|
||||||
nl.prefixes = append(nl.prefixes, l.prefixes...)
|
nl.prefixes = append(nl.prefixes, l.prefixes...)
|
||||||
|
l.mu.Unlock()
|
||||||
nl.renderPrefixString()
|
nl.renderPrefixString()
|
||||||
return nl
|
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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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)
|
go c.readLoopClient(ctx, conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnregisterClientRoute Remove client route from routing table
|
// UnregisterClientRoute removes a client route only when it is still owned by conn.
|
||||||
func (c *Controller) UnregisterClientRoute(name string) {
|
func (c *Controller) UnregisterClientRoute(name string, conn io.Writer) bool {
|
||||||
c.clientRouter.delRoute(name)
|
return c.clientRouter.delRoute(name, conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartServerConnReadLoop starts the read loop for a server connection
|
// StartServerConnReadLoop starts the read loop for a server connection
|
||||||
@@ -304,10 +304,15 @@ func (r *clientRouter) findConn(dst net.IP) (io.Writer, error) {
|
|||||||
return nil, fmt.Errorf("no route found for destination %s", dst)
|
return nil, fmt.Errorf("no route found for destination %s", dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *clientRouter) delRoute(name string) {
|
func (r *clientRouter) delRoute(name string, conn io.Writer) bool {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
re, ok := r.routes[name]
|
||||||
|
if !ok || re.conn != conn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
delete(r.routes, name)
|
delete(r.routes, name)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *clientRouter) removeConnRoute(conn io.Writer) {
|
func (r *clientRouter) removeConnRoute(conn io.Writer) {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package vnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestClientRouterDeleteRouteRequiresMatchingConnection verifies that a stale
|
||||||
|
// visitor cannot remove a replacement route registered under the same name.
|
||||||
|
func TestClientRouterDeleteRouteRequiresMatchingConnection(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
controller := NewController(v1.VirtualNetConfig{})
|
||||||
|
|
||||||
|
_, route, err := net.ParseCIDR("10.1.0.1/32")
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
oldConn, oldPeer := net.Pipe()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = oldConn.Close()
|
||||||
|
_ = oldPeer.Close()
|
||||||
|
})
|
||||||
|
replacementConn, replacementPeer := net.Pipe()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = replacementConn.Close()
|
||||||
|
_ = replacementPeer.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
controller.clientRouter.addRoute("vnet-visitor", []net.IPNet{*route}, oldConn)
|
||||||
|
controller.clientRouter.addRoute("vnet-visitor", []net.IPNet{*route}, replacementConn)
|
||||||
|
|
||||||
|
require.False(controller.UnregisterClientRoute("vnet-visitor", oldConn))
|
||||||
|
got, err := controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
|
||||||
|
require.NoError(err)
|
||||||
|
require.Same(replacementConn, got)
|
||||||
|
|
||||||
|
// The read loop for an old connection can exit after a replacement route
|
||||||
|
// has already been registered. Its deferred cleanup must keep the new owner.
|
||||||
|
controller.clientRouter.removeConnRoute(oldConn)
|
||||||
|
got, err = controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
|
||||||
|
require.NoError(err)
|
||||||
|
require.Same(replacementConn, got)
|
||||||
|
|
||||||
|
require.True(controller.UnregisterClientRoute("vnet-visitor", replacementConn))
|
||||||
|
_, err = controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
|
||||||
|
require.Error(err)
|
||||||
|
}
|
||||||
@@ -615,7 +615,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
|||||||
name: "tcp remote port",
|
name: "tcp remote port",
|
||||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||||
Type: "tcp",
|
Type: "tcp",
|
||||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
TCP: &model.V2TCPProxySpec{RemotePort: new(6000)},
|
||||||
}},
|
}},
|
||||||
q: "6000",
|
q: "6000",
|
||||||
want: true,
|
want: true,
|
||||||
@@ -624,7 +624,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
|||||||
name: "udp remote port",
|
name: "udp remote port",
|
||||||
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
|
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
|
||||||
Type: "udp",
|
Type: "udp",
|
||||||
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
|
UDP: &model.V2UDPProxySpec{RemotePort: new(7000)},
|
||||||
}},
|
}},
|
||||||
q: "7000",
|
q: "7000",
|
||||||
want: true,
|
want: true,
|
||||||
@@ -633,7 +633,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
|||||||
name: "remote port does not match colon form",
|
name: "remote port does not match colon form",
|
||||||
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
|
||||||
Type: "tcp",
|
Type: "tcp",
|
||||||
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
|
TCP: &model.V2TCPProxySpec{RemotePort: new(6000)},
|
||||||
}},
|
}},
|
||||||
q: ":6000",
|
q: ":6000",
|
||||||
want: false,
|
want: false,
|
||||||
@@ -767,10 +767,6 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func v2TestIntPtr(value int) *int {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|
||||||
func newV2TestController(t *testing.T) *Controller {
|
func newV2TestController(t *testing.T) *Controller {
|
||||||
t.Helper()
|
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 {
|
if pxy.GetLimiter() != nil {
|
||||||
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {
|
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), rwc.Close)
|
||||||
return rwc.Close()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
|
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
|
||||||
|
|||||||
@@ -308,9 +308,7 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if pxy.GetLimiter() != nil {
|
if pxy.GetLimiter() != nil {
|
||||||
local = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), func() error {
|
local = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), local.Close)
|
||||||
return local.Close()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
xl.Debugf("join connections, workConn(l[%s] r[%s]) userConn(l[%s] r[%s])", workConn.LocalAddr().String(),
|
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 {
|
if pxy.GetLimiter() != nil {
|
||||||
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {
|
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), rwc.Close)
|
||||||
return rwc.Close()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
|
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
|
||||||
|
|||||||
+115
-34
@@ -25,11 +25,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/crypto"
|
"github.com/fatedier/golib/crypto"
|
||||||
"github.com/fatedier/golib/net/mux"
|
"github.com/fatedier/golib/net/mux"
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/fatedier/yamux"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
@@ -119,7 +121,11 @@ type Service struct {
|
|||||||
rc *controller.ResourceController
|
rc *controller.ResourceController
|
||||||
|
|
||||||
// web server for dashboard UI and apis
|
// 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
|
sshTunnelGateway *ssh.Gateway
|
||||||
|
|
||||||
@@ -133,7 +139,10 @@ type Service struct {
|
|||||||
// service context
|
// service context
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
// call cancel to stop service
|
// 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) {
|
func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
||||||
@@ -145,20 +154,6 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
|||||||
return nil, err
|
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)
|
authRuntime, err := auth.BuildServerAuth(&cfg.Auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -172,20 +167,14 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
|||||||
pluginManager: plugin.NewManager(),
|
pluginManager: plugin.NewManager(),
|
||||||
rc: &controller.ResourceController{
|
rc: &controller.ResourceController{
|
||||||
VisitorManager: visitor.NewManager(),
|
VisitorManager: visitor.NewManager(),
|
||||||
TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts),
|
|
||||||
UDPPortManager: ports.NewManager("udp", cfg.ProxyBindAddr, cfg.AllowPorts),
|
|
||||||
},
|
},
|
||||||
sshTunnelListener: netpkg.NewInternalListener(),
|
sshTunnelListener: netpkg.NewInternalListener(),
|
||||||
httpVhostRouter: vhost.NewRouters(),
|
httpVhostRouter: vhost.NewRouters(),
|
||||||
auth: authRuntime,
|
auth: authRuntime,
|
||||||
webServer: webServer,
|
|
||||||
tlsConfig: tlsConfig,
|
tlsConfig: tlsConfig,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
}
|
}
|
||||||
if webServer != nil {
|
|
||||||
webServer.RouteRegister(svr.registerRouteHandlers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create tcpmux httpconnect multiplexer.
|
// Create tcpmux httpconnect multiplexer.
|
||||||
if cfg.TCPMuxHTTPConnectPort > 0 {
|
if cfg.TCPMuxHTTPConnectPort > 0 {
|
||||||
@@ -210,9 +199,6 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
|||||||
}
|
}
|
||||||
svr.rc.PluginManager = svr.pluginManager
|
svr.rc.PluginManager = svr.pluginManager
|
||||||
|
|
||||||
// Init group controller
|
|
||||||
svr.rc.TCPGroupCtl = group.NewTCPGroupCtl(svr.rc.TCPPortManager)
|
|
||||||
|
|
||||||
// Init HTTP group controller
|
// Init HTTP group controller
|
||||||
svr.rc.HTTPGroupCtl = group.NewHTTPGroupController(svr.httpVhostRouter)
|
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)
|
return nil, fmt.Errorf("create vhost http listener error, %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
svr.httpVhostServer = server
|
||||||
|
svr.httpVhostListener = l
|
||||||
go func() {
|
go func() {
|
||||||
_ = server.Serve(l)
|
_ = 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)
|
return nil, fmt.Errorf("create nat hole controller error, %v", err)
|
||||||
}
|
}
|
||||||
svr.rc.NatHoleController = nc
|
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
|
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) {
|
func (svr *Service) Run(ctx context.Context) {
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
svr.lifecycleMu.Lock()
|
||||||
|
if svr.closed {
|
||||||
|
svr.lifecycleMu.Unlock()
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
svr.ctx = ctx
|
svr.ctx = ctx
|
||||||
svr.cancel = cancel
|
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.
|
// run dashboard web server.
|
||||||
if svr.webServer != nil {
|
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() {
|
go func() {
|
||||||
log.Infof("dashboard listen on %s", svr.webServer.Address())
|
log.Infof("dashboard listen on %s", svr.webServer.Address())
|
||||||
if err := svr.webServer.Run(); err != nil {
|
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)
|
go svr.HandleListener(svr.sshTunnelListener, true)
|
||||||
|
|
||||||
if svr.kcpListener != nil {
|
if svr.kcpListener != nil {
|
||||||
@@ -400,13 +469,21 @@ func (svr *Service) Run(ctx context.Context) {
|
|||||||
svr.HandleListener(svr.listener, false)
|
svr.HandleListener(svr.listener, false)
|
||||||
|
|
||||||
<-svr.ctx.Done()
|
<-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 {
|
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 {
|
if svr.kcpListener != nil {
|
||||||
svr.kcpListener.Close()
|
svr.kcpListener.Close()
|
||||||
}
|
}
|
||||||
@@ -428,16 +505,20 @@ func (svr *Service) Close() error {
|
|||||||
if svr.webServer != nil {
|
if svr.webServer != nil {
|
||||||
svr.webServer.Close()
|
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 {
|
if svr.sshTunnelGateway != nil {
|
||||||
svr.sshTunnelGateway.Close()
|
svr.sshTunnelGateway.Close()
|
||||||
}
|
}
|
||||||
svr.rc.Close()
|
svr.rc.Close()
|
||||||
svr.muxer.Close()
|
svr.muxer.Close()
|
||||||
svr.ctlManager.Close()
|
svr.ctlManager.Close()
|
||||||
if svr.cancel != nil {
|
|
||||||
svr.cancel()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) {
|
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()
|
f := framework.NewDefaultFramework()
|
||||||
|
|
||||||
ginkgo.BeforeEach(func() {
|
ginkgo.BeforeEach(func() {
|
||||||
supportsV2, knownVersion := baselineSupportsControlWireProtocolV2(compatCtx.BaselineVersion)
|
supportsV2, knownVersion := baselineSupportsUDPWireProtocolV2(compatCtx.BaselineVersion)
|
||||||
if !knownVersion || !supportsV2 {
|
if !knownVersion {
|
||||||
ginkgo.Skip(fmt.Sprintf("baseline version %q does not have known wire protocol v2 support", compatCtx.BaselineVersion))
|
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() {
|
// The negotiated payload codec may be JSON or binary-v1; both use v2 framing.
|
||||||
portName := port.GenName("CompatBinaryUDPBaselineFRPC")
|
ginkgo.It("current frps exchanges v2-framed UDP with baseline frpc", func() {
|
||||||
clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`)
|
portName := port.GenName("CompatV2UDPBaselineFRPC")
|
||||||
|
clientConf := udpClientConfig(portName, `transport.wireProtocol = "v2"`)
|
||||||
f.RunProcessesWithBinaries(
|
f.RunProcessesWithBinaries(
|
||||||
compatCtx.CurrentFRPSPath,
|
compatCtx.CurrentFRPSPath,
|
||||||
compatCtx.BaselineFRPCPath,
|
compatCtx.BaselineFRPCPath,
|
||||||
@@ -214,9 +247,9 @@ var _ = ginkgo.Describe("[Compatibility: BinaryUDPPacket]", func() {
|
|||||||
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
|
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
ginkgo.It("current frpc falls back to JSON for baseline frps", func() {
|
ginkgo.It("current frpc exchanges v2-framed UDP with baseline frps", func() {
|
||||||
portName := port.GenName("CompatBinaryUDPBaselineFRPS")
|
portName := port.GenName("CompatV2UDPBaselineFRPS")
|
||||||
clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`)
|
clientConf := udpClientConfig(portName, `transport.wireProtocol = "v2"`)
|
||||||
f.RunProcessesWithBinaries(
|
f.RunProcessesWithBinaries(
|
||||||
compatCtx.BaselineFRPSPath,
|
compatCtx.BaselineFRPSPath,
|
||||||
compatCtx.CurrentFRPCPath,
|
compatCtx.CurrentFRPCPath,
|
||||||
@@ -243,7 +276,7 @@ remotePort = {{ .%s }}
|
|||||||
`, consts.PortServerName, extra, proxyName, framework.TCPEchoServerPort, remotePortName)
|
`, 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(`
|
return fmt.Sprintf(`
|
||||||
serverAddr = "127.0.0.1"
|
serverAddr = "127.0.0.1"
|
||||||
serverPort = {{ .%s }}
|
serverPort = {{ .%s }}
|
||||||
@@ -252,11 +285,11 @@ log.level = "trace"
|
|||||||
%s
|
%s
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "%s"
|
name = "udp"
|
||||||
type = "udp"
|
type = "udp"
|
||||||
localPort = {{ .%s }}
|
localPort = {{ .%s }}
|
||||||
remotePort = {{ .%s }}
|
remotePort = {{ .%s }}
|
||||||
`, consts.PortServerName, extra, proxyName, framework.UDPEchoServerPort, remotePortName)
|
`, consts.PortServerName, extra, framework.UDPEchoServerPort, remotePortName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func expectProcessExit(p *process.Process, timeout time.Duration) {
|
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) {
|
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")
|
version = strings.TrimPrefix(version, "v")
|
||||||
parts := strings.Split(version, ".")
|
parts := strings.Split(version, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
@@ -287,7 +328,39 @@ func baselineSupportsControlWireProtocolV2(version string) (supports bool, known
|
|||||||
return false, false
|
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 {
|
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",
|
client: "transport.tls.enable = false",
|
||||||
expectError: true,
|
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() {
|
ginkgo.Describe("TLS with custom certificate", func() {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package features
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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())
|
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
|
package features
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
"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/pkg/transport"
|
||||||
"github.com/fatedier/frp/test/e2e/framework"
|
"github.com/fatedier/frp/test/e2e/framework"
|
||||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||||
@@ -17,6 +23,80 @@ import (
|
|||||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
"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() {
|
var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||||
f := framework.NewDefaultFramework()
|
f := framework.NewDefaultFramework()
|
||||||
|
|
||||||
@@ -252,7 +332,10 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
|||||||
|
|
||||||
ginkgo.Describe("Health Check", func() {
|
ginkgo.Describe("Health Check", func() {
|
||||||
ginkgo.It("TCP", func() {
|
ginkgo.It("TCP", func() {
|
||||||
serverConf := consts.DefaultServerConfig
|
dashboardPort := f.AllocPort()
|
||||||
|
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||||
|
webServer.port = %d
|
||||||
|
`, dashboardPort)
|
||||||
clientConf := consts.DefaultClientConfig
|
clientConf := consts.DefaultClientConfig
|
||||||
|
|
||||||
fooPort := f.AllocPort()
|
fooPort := f.AllocPort()
|
||||||
@@ -260,11 +343,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
|||||||
f.RunServer("", fooServer)
|
f.RunServer("", fooServer)
|
||||||
|
|
||||||
barPort := f.AllocPort()
|
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)
|
f.RunServer("", barServer)
|
||||||
|
|
||||||
remotePort := f.AllocPort()
|
remotePort := f.AllocPort()
|
||||||
|
adminPort := f.AllocPort()
|
||||||
clientConf += fmt.Sprintf(`
|
clientConf += fmt.Sprintf(`
|
||||||
|
webServer.port = %d
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "foo"
|
name = "foo"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
@@ -284,11 +373,16 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
|||||||
loadBalancer.groupKey = "123"
|
loadBalancer.groupKey = "123"
|
||||||
healthCheck.type = "tcp"
|
healthCheck.type = "tcp"
|
||||||
healthCheck.intervalSeconds = 1
|
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{}
|
results := []string{}
|
||||||
for range 10 {
|
for range 10 {
|
||||||
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
|
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"})
|
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||||
|
|
||||||
// close bar server, check foo is ok
|
// frps removes the group listener before deleting the proxy from
|
||||||
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
|
// its manager. Its offline status is the removal barrier; frpc's
|
||||||
barServer.Close()
|
// local check-failed status alone does not acknowledge that work.
|
||||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
|
for range 2 {
|
||||||
for range 10 {
|
framework.ExpectNoError(barServer.Close())
|
||||||
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
|
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
|
barServer = newBarServer()
|
||||||
successCount := clientProcesses[0].CountOutput("[bar] health check success")
|
f.RunServer("", barServer)
|
||||||
f.RunServer("", barServer)
|
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "running"))
|
||||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
|
results = []string{}
|
||||||
results = []string{}
|
for range 10 {
|
||||||
for range 10 {
|
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
|
||||||
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
|
results = append(results, string(resp.Content))
|
||||||
results = append(results, string(resp.Content))
|
return true
|
||||||
return true
|
})
|
||||||
})
|
}
|
||||||
|
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||||
}
|
}
|
||||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ginkgo.It("HTTP", func() {
|
ginkgo.It("HTTP", func() {
|
||||||
vhostPort := f.AllocPort()
|
vhostPort := f.AllocPort()
|
||||||
|
dashboardPort := f.AllocPort()
|
||||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||||
vhostHTTPPort = %d
|
vhostHTTPPort = %d
|
||||||
`, vhostPort)
|
webServer.port = %d
|
||||||
|
`, vhostPort, dashboardPort)
|
||||||
clientConf := consts.DefaultClientConfig
|
clientConf := consts.DefaultClientConfig
|
||||||
|
|
||||||
fooPort := f.AllocPort()
|
fooPort := f.AllocPort()
|
||||||
@@ -332,10 +431,41 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
|||||||
f.RunServer("", fooServer)
|
f.RunServer("", fooServer)
|
||||||
|
|
||||||
barPort := f.AllocPort()
|
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)
|
f.RunServer("", barServer)
|
||||||
|
|
||||||
|
adminPort := f.AllocPort()
|
||||||
clientConf += fmt.Sprintf(`
|
clientConf += fmt.Sprintf(`
|
||||||
|
webServer.port = %d
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "foo"
|
name = "foo"
|
||||||
type = "http"
|
type = "http"
|
||||||
@@ -356,12 +486,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
|||||||
loadBalancer.groupKey = "123"
|
loadBalancer.groupKey = "123"
|
||||||
healthCheck.type = "http"
|
healthCheck.type = "http"
|
||||||
healthCheck.intervalSeconds = 1
|
healthCheck.intervalSeconds = 1
|
||||||
|
healthCheck.maxFailed = 3
|
||||||
|
healthCheck.timeoutSeconds = 10
|
||||||
healthCheck.path = "/healthz"
|
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
|
var contents []string
|
||||||
framework.NewRequestExpect(f).Port(vhostPort).
|
framework.NewRequestExpect(f).Port(vhostPort).
|
||||||
RequestModify(func(r *request.Request) {
|
RequestModify(func(r *request.Request) {
|
||||||
@@ -371,8 +506,6 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
|||||||
contents = append(contents, string(resp.Content))
|
contents = append(contents, string(resp.Content))
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// send second HTTP request, should be forwarded to another service
|
|
||||||
framework.NewRequestExpect(f).Port(vhostPort).
|
framework.NewRequestExpect(f).Port(vhostPort).
|
||||||
RequestModify(func(r *request.Request) {
|
RequestModify(func(r *request.Request) {
|
||||||
r.HTTP().HTTPHost("example.com")
|
r.HTTP().HTTPHost("example.com")
|
||||||
@@ -381,25 +514,83 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
|||||||
contents = append(contents, string(resp.Content))
|
contents = append(contents, string(resp.Content))
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
framework.ExpectContainElements(contents, []string{"foo", "bar"})
|
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")
|
results := doFooBarHTTPRequest(vhostPort, "example.com")
|
||||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||||
|
|
||||||
// close bar server, check foo is ok
|
// Repeat the two-failure window to verify that recovery cleared the
|
||||||
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
|
// counter before another failure sequence began.
|
||||||
barServer.Close()
|
runFailureRecovery()
|
||||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
|
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")
|
results = doFooBarHTTPRequest(vhostPort, "example.com")
|
||||||
framework.ExpectContainElements(results, []string{"foo"})
|
framework.ExpectContainElements(results, []string{"foo"})
|
||||||
framework.ExpectNotContainElements(results, []string{"bar"})
|
framework.ExpectNotContainElements(results, []string{"bar"})
|
||||||
|
|
||||||
// resume bar server, check foo and bar is ok
|
recoverySignals := make(chan struct{}, 1)
|
||||||
successCount := clientProcesses[0].CountOutput("[bar] health check success")
|
stageMu.Lock()
|
||||||
f.RunServer("", barServer)
|
stage = &httpHealthStage{healthy: true, signal: recoverySignals}
|
||||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
|
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")
|
results = doFooBarHTTPRequest(vhostPort, "example.com")
|
||||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
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