mirror of
https://github.com/fatedier/frp.git
synced 2026-11-05 07:01:39 +08:00
@@ -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.26'
|
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
|
||||||
|
|||||||
@@ -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.26'
|
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)
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ linters:
|
|||||||
- linters:
|
- linters:
|
||||||
- unparam
|
- unparam
|
||||||
text: is always false
|
text: is always false
|
||||||
|
- linters:
|
||||||
|
- staticcheck
|
||||||
|
path: ^pkg/util/net/kcp\.go$
|
||||||
|
text: "SA1019: .*SetStreamMode"
|
||||||
paths:
|
paths:
|
||||||
- .*\.pb\.go
|
- .*\.pb\.go
|
||||||
- .*\.gen\.go
|
- .*\.gen\.go
|
||||||
|
|||||||
@@ -12,17 +12,15 @@ 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">
|
<div align="center">
|
||||||
<a href="https://www.rapidproxy.io/?ref=frp" target="_blank">
|
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_rapidproxy.png">
|
## Recall.ai - API for meeting recordings
|
||||||
<br>
|
|
||||||
<b>High-performance residential and ISP proxies for developers</b>
|
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||||
</a>
|
|
||||||
<br>
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
<sub>90M+ residential IPs worldwide. Rotating IPs, sticky sessions, and traffic that never expires.</sub>
|
|
||||||
<br>
|
</div>
|
||||||
<sub>From $0.55/GB. Use RAPID10 for 10% off. Try it for free.</sub>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
@@ -42,16 +40,6 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
## Recall.ai - API for meeting recordings
|
|
||||||
|
|
||||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
|
||||||
|
|
||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## What is frp?
|
## What is frp?
|
||||||
@@ -628,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
|
||||||
|
|
||||||
@@ -724,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
|
||||||
@@ -817,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).
|
||||||
|
|||||||
+9
-21
@@ -14,17 +14,15 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
<p align="center">
|
<div align="center">
|
||||||
<a href="https://www.rapidproxy.io/?ref=frp" target="_blank">
|
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_rapidproxy.png">
|
## Recall.ai - API for meeting recordings
|
||||||
<br>
|
|
||||||
<b>High-performance residential and ISP proxies for developers</b>
|
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
||||||
</a>
|
|
||||||
<br>
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
<sub>90M+ residential IPs worldwide. Rotating IPs, sticky sessions, and traffic that never expires.</sub>
|
|
||||||
<br>
|
</div>
|
||||||
<sub>From $0.55/GB. Use RAPID10 for 10% off. Try it for free.</sub>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
@@ -44,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
## Recall.ai - API for meeting recordings
|
|
||||||
|
|
||||||
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
|
|
||||||
|
|
||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## 为什么使用 frp ?
|
## 为什么使用 frp ?
|
||||||
|
|||||||
+11
-9
@@ -1,13 +1,15 @@
|
|||||||
## Improvements
|
## Features
|
||||||
|
|
||||||
* Building frp from source now requires Go 1.26.0 or later.
|
* 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
|
||||||
|
|
||||||
* Updated dependencies to fix SSH tunnel gateway channel deadlocks and panics caused by malformed challenges from NTLM-authenticated HTTP proxies.
|
* Fixed proxy and visitor configuration changes being lost when reloading frpc during reconnection.
|
||||||
* Fixed VirtualNet routing issues during reconnection and shutdown, and excessive retries after repeated connection failures.
|
* Updated yamux to fix a 100% CPU busy loop after a session shuts down and a session close that could hang when the underlying connection (for example WebSocket) blocks on a write, which could also delay reconnection.
|
||||||
* Health checks now reset the failure count after success, so `healthCheck.maxFailed` applies to consecutive failures.
|
* frps now closes the multiplexed session of a replaced or failed control connection immediately, releasing its streams and work connections instead of keeping them until keepalive times out.
|
||||||
* Fixed control connections getting stuck after message write failures, which could block reconnection.
|
* Fixed a data race when frpc updates log prefixes while other goroutines are logging, which could crash the process.
|
||||||
* Fixed incorrect daily traffic statistics around daylight-saving transitions and skipped local midnights.
|
* Fixed HTTPS and tcpmux route listeners leaving a pending connection open when the listener is removed during connection handoff; the connection is now closed instead of leaving the client to wait for a read timeout.
|
||||||
* Fixed early-data loss in HTTP virtual-host CONNECT tunnels. Requests with a positive content length or transfer encoding now return HTTP 400.
|
|
||||||
* Fixed work connections remaining open when server-side bandwidth limiting is enabled for TCP, HTTP, and UDP proxies.
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -277,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()
|
||||||
@@ -288,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
|
||||||
}
|
}
|
||||||
|
|||||||
+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()
|
||||||
|
}
|
||||||
+69
-11
@@ -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.
|
||||||
@@ -188,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)
|
||||||
@@ -198,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,
|
||||||
@@ -263,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())
|
||||||
@@ -272,6 +296,16 @@ 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 {
|
||||||
@@ -354,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()
|
||||||
@@ -368,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()
|
||||||
@@ -390,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
|
||||||
@@ -413,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")
|
||||||
@@ -477,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
|
||||||
@@ -556,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,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)
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
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.26 AS building
|
FROM golang:1.26 AS building
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
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.26 AS building
|
FROM golang:1.26 AS building
|
||||||
|
|
||||||
|
|||||||
@@ -4,72 +4,70 @@ 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.21.0
|
||||||
github.com/fatedier/golib v0.8.2
|
github.com/fatedier/golib v0.8.2
|
||||||
github.com/fatedier/yamux v0.2.0
|
github.com/fatedier/yamux v0.3.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.4-0.20250319132907-e064f32e3674
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4
|
github.com/onsi/ginkgo/v2 v2.34.0
|
||||||
github.com/onsi/gomega v1.36.3
|
github.com/onsi/gomega v1.44.0
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0
|
github.com/pelletier/go-toml/v2 v2.4.3
|
||||||
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.25.0
|
||||||
github.com/quic-go/quic-go v0.60.0
|
github.com/prometheus/client_model v0.6.3
|
||||||
github.com/rodaine/table v1.2.0
|
github.com/prometheus/common v0.72.0
|
||||||
github.com/samber/lo v1.47.0
|
github.com/quic-go/quic-go v0.65.0
|
||||||
|
github.com/rodaine/table v1.4.1
|
||||||
|
github.com/samber/lo v1.53.0
|
||||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||||
github.com/spf13/cobra v1.8.0
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.5
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/tidwall/gjson v1.17.1
|
github.com/tidwall/gjson v1.20.0
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.1
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13
|
github.com/xtaci/kcp-go/v5 v5.6.73
|
||||||
golang.org/x/crypto v0.56.0
|
golang.org/x/crypto v0.58.0
|
||||||
golang.org/x/net v0.57.0
|
golang.org/x/net v0.62.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.37.0
|
||||||
golang.org/x/sync v0.22.0
|
golang.org/x/sync v0.24.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.49.0
|
||||||
golang.org/x/time v0.10.0
|
golang.org/x/time v0.16.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.3
|
||||||
k8s.io/apimachinery v0.28.8
|
k8s.io/apimachinery v0.37.2
|
||||||
k8s.io/client-go v0.28.8
|
k8s.io/client-go v0.37.2
|
||||||
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2
|
k8s.io/utils v0.0.0-20261023123148-ca04ba58ecab
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0 // 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.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/prometheus/procfs v0.22.0 // indirect
|
||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
|
||||||
github.com/prometheus/common v0.48.0 // indirect
|
|
||||||
github.com/prometheus/procfs v0.12.0 // indirect
|
|
||||||
github.com/templexxx/cpu v0.1.1 // indirect
|
|
||||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/tools v0.48.0 // indirect
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.43.0 // indirect
|
||||||
|
golang.org/x/tools v0.51.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.12 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
|
||||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,34 +2,45 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
|
|||||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
github.com/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/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
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=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fatedier/golib v0.8.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.2.0 h1:H+2A9iBVh7aJlEOc1Ws1FXWOaecBf2nRv9zpFMPUWg8=
|
github.com/fatedier/yamux v0.3.0 h1:NHcAbiv2YMZJjV413Ibw0OccvRVWP1nTTQjdRdFKI/0=
|
||||||
github.com/fatedier/yamux v0.2.0/go.mod h1:d4FtRDrC9sHvRpiDL6J5EnfjLhqzZZplHe5yToSn2Ac=
|
github.com/fatedier/yamux v0.3.0/go.mod h1:d4FtRDrC9sHvRpiDL6J5EnfjLhqzZZplHe5yToSn2Ac=
|
||||||
|
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||||
|
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||||
|
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||||
|
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||||
|
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||||
|
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||||
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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@@ -43,25 +54,28 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
|||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
|
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||||
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
|
||||||
|
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
|
||||||
|
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||||
|
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=
|
github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=
|
||||||
@@ -70,50 +84,54 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||||
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
|
||||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.34.0 h1:aN3Us823mHHTSWMIi8WIQTdYuSPhl2lWY46E/w3SiAk=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.34.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||||
|
github.com/onsi/gomega v1.44.0 h1:eAiGl3Pw5jz5GQdDff0BcxYpAX1JxW8xD7mFUuwNfZQ=
|
||||||
|
github.com/onsi/gomega v1.44.0/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||||
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
|
||||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
|
||||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
|
||||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
|
||||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
|
||||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
|
||||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
|
||||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
github.com/quic-go/quic-go v0.65.0 h1:BJQSjdTvLPLmWtaCupUphRbgxgpZhdO/GMpr9Eoe3B4=
|
||||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
github.com/quic-go/quic-go v0.65.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
|
||||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
github.com/rodaine/table v1.4.1 h1:E6biWCzTV91H1sGNlW/cUtqqgxUJnmcThmWAyhPiToo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rodaine/table v1.4.1/go.mod h1:bpG+S1IN2VGfI0vC1MymHEX8fTDK5LIsxd+rrpKSQPA=
|
||||||
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
|
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||||
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
|
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
|
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
|
||||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
|
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
|
||||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -121,58 +139,63 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
|
github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto=
|
||||||
github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg=
|
github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||||
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
|
|
||||||
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||||
github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
github.com/xtaci/kcp-go/v5 v5.6.73 h1:Ejk1O0GyjaEQOxTeZUn0jIss0jkk2BJU8VOeJKdOh+Y=
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
github.com/xtaci/kcp-go/v5 v5.6.73/go.mod h1:FRqA8EUPgnBlP4UcXlcCWZ9if7XaqmZMfXOTynYEmc4=
|
||||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=
|
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=
|
||||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
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.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||||
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.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -180,28 +203,28 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||||
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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||||
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=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||||
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=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
|
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee h1:hByBPN/P/BVzJ591rJfvNhdoL94vmBtRbnVw77QJL20=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
@@ -216,29 +239,24 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
|
|||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ=
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY=
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
k8s.io/apimachinery v0.28.8 h1:hi/nrxHwk4QLV+W/SHve1bypTE59HCDorLY1stBIxKQ=
|
k8s.io/apimachinery v0.37.2 h1:G4t9RGoPDbQML38hz/kQQiltttgd3egg2ODCR8aIc2o=
|
||||||
k8s.io/apimachinery v0.28.8/go.mod h1:cBnwIM3fXoRo28SqbV/Ihxf/iviw85KyXOrzxvZQ83U=
|
k8s.io/apimachinery v0.37.2/go.mod h1:qaAvSNvgU2x07wRM95MSHSJTPQ/ob/LeNAGbeDgqQJg=
|
||||||
k8s.io/client-go v0.28.8 h1:TE59Tjd87WKvS2FPBTfIKLFX0nQJ4SSHsnDo5IHjgOw=
|
k8s.io/client-go v0.37.2 h1:sgFtyRZukGkOrsu+QgS7LYnbFFJ5ut3MchdBNduD//E=
|
||||||
k8s.io/client-go v0.28.8/go.mod h1:uDVQ/rPzWpWIy40c6lZ4mUwaEvRWGnpoqSO4FM65P3o=
|
k8s.io/client-go v0.37.2/go.mod h1:bo3LFC8YbCtKFPX21Yj8puMsoJLAv/+SvuOODma0s54=
|
||||||
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk=
|
k8s.io/utils v0.0.0-20261023123148-ca04ba58ecab h1:pjQrNLaF//ytZtWwCAHtW+pqwSKjtWDqzA8+TCX37RI=
|
||||||
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
k8s.io/utils v0.0.0-20261023123148-ca04ba58ecab/go.mod h1:DdQ0BJxlQtuMlKDqdg7X5IMMQCXIk2b3d1/4M9OhUYo=
|
||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/util/validation"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
utilvalidation "github.com/fatedier/frp/pkg/util/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error {
|
func validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error {
|
||||||
@@ -248,7 +247,7 @@ func ValidateAnnotations(annotations map[string]string) error {
|
|||||||
|
|
||||||
var errs error
|
var errs error
|
||||||
for k := range annotations {
|
for k := range annotations {
|
||||||
for _, msg := range validation.IsQualifiedName(strings.ToLower(k)) {
|
for _, msg := range utilvalidation.IsQualifiedName(strings.ToLower(k)) {
|
||||||
errs = AppendError(errs, fmt.Errorf("annotation key %s is invalid: %s", k, msg))
|
errs = AppendError(errs, fmt.Errorf("annotation key %s is invalid: %s", k, msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,3 +74,18 @@ func TestValidateDomainConfigForServerRejectsSubdomainHostCaseInsensitively(t *t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateAnnotationsQualifiedName(t *testing.T) {
|
||||||
|
require.NoError(t, ValidateAnnotations(map[string]string{
|
||||||
|
"frp.e2e.test/foo": "value1",
|
||||||
|
"app.kubernetes.io/name": "value2",
|
||||||
|
}))
|
||||||
|
// The call site lowercases keys before validation.
|
||||||
|
require.NoError(t, ValidateAnnotations(map[string]string{"Example.COM/MyName": "value"}))
|
||||||
|
|
||||||
|
err := ValidateAnnotations(map[string]string{"bad key": "value"})
|
||||||
|
require.ErrorContains(t, err, "annotation key bad key is invalid: name part must consist of alphanumeric characters")
|
||||||
|
|
||||||
|
err = ValidateAnnotations(map[string]string{"example.com/": "value"})
|
||||||
|
require.ErrorContains(t, err, "name part must be non-empty")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,424 @@
|
|||||||
|
// 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 in heap and currently in use. Equals to /memory/classes/heap/objects:bytes.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.Alloc) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_alloc_bytes_total",
|
||||||
|
help: "Total number of bytes allocated in heap until now, even if released already. Equals to /gc/heap/allocs:bytes.",
|
||||||
|
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. Equals to /memory/classes/profiling/buckets:bytes.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.BuckHashSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_frees_total",
|
||||||
|
help: "Total number of heap objects frees. Equals to /gc/heap/frees:objects + /gc/heap/tiny/allocs:objects.",
|
||||||
|
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. Equals to /memory/classes/metadata/other:bytes.",
|
||||||
|
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 currently in use, same as go_memstats_alloc_bytes. Equals to /memory/classes/heap/objects:bytes.",
|
||||||
|
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. Equals to /memory/classes/heap/released:bytes + /memory/classes/heap/free:bytes.",
|
||||||
|
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. Equals to /memory/classes/heap/objects:bytes + /memory/classes/heap/unused:bytes",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapInuse) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_heap_objects",
|
||||||
|
help: "Number of currently allocated objects. Equals to /gc/heap/objects: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. Equals to /memory/classes/heap/released:bytes.",
|
||||||
|
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. Equals to /memory/classes/heap/objects:bytes + /memory/classes/heap/unused:bytes + /memory/classe" +
|
||||||
|
"s/heap/released:bytes + /memory/classes/heap/free:bytes.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_mallocs_total",
|
||||||
|
help: "Total number of heap objects allocated, both live and gc-ed. Semantically a counter version for go_memstats_heap_objects gauge. Equals to /g" +
|
||||||
|
"c/heap/allocs:objects + /gc/heap/tiny/allocs:objects.",
|
||||||
|
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. Equals to /memory/classes/metadata/mcache/inuse:bytes.",
|
||||||
|
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. Equals to /memory/classes/metadata/mcache/inuse:bytes + /memory/classes/met" +
|
||||||
|
"adata/mcache/free:bytes.",
|
||||||
|
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. Equals to /memory/classes/metadata/mspan/inuse:bytes.",
|
||||||
|
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. Equals to /memory/classes/metadata/mspan/inuse:bytes + /memory/classes/metad" +
|
||||||
|
"ata/mspan/free:bytes.",
|
||||||
|
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. Equals to /gc/heap/goal:bytes.",
|
||||||
|
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. Equals to /memory/classes/other:bytes.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.OtherSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_stack_inuse_bytes",
|
||||||
|
help: "Number of bytes obtained from system for stack allocator in non-CGO environments. Equals to /memory/classes/heap/stacks:bytes.",
|
||||||
|
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. Equals to /memory/classes/heap/stacks:bytes + /memory/classes/os-stacks:bytes.",
|
||||||
|
typ: typeGauge,
|
||||||
|
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackSys) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "go_memstats_sys_bytes",
|
||||||
|
help: "Number of bytes obtained from system. Equals to /memory/classes/total:byte.",
|
||||||
|
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 wall-time pause (stop-the-world) duration in 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())}},
|
||||||
|
},
|
||||||
|
family{
|
||||||
|
name: "go_gc_gogc_percent",
|
||||||
|
help: "Heap size target percentage configured by the user, otherwise 100. This value is set by the GOGC environment variable, and the runtime/debug" +
|
||||||
|
".SetGCPercent function. Sourced from /gc/gogc:percent.",
|
||||||
|
typ: typeGauge,
|
||||||
|
samples: []sample{{value: float64(readRuntimeMetric("/gc/gogc:percent"))}},
|
||||||
|
},
|
||||||
|
family{
|
||||||
|
name: "go_gc_gomemlimit_bytes",
|
||||||
|
help: "Go runtime memory limit configured by the user, otherwise math.MaxInt64. This value is set by the GOMEMLIMIT environment variable, and the r" +
|
||||||
|
"untime/debug.SetMemoryLimit function. Sourced from /gc/gomemlimit:bytes.",
|
||||||
|
typ: typeGauge,
|
||||||
|
samples: []sample{{value: float64(readRuntimeMetric("/gc/gomemlimit:bytes"))}},
|
||||||
|
},
|
||||||
|
family{
|
||||||
|
name: "go_sched_gomaxprocs_threads",
|
||||||
|
help: "The current runtime.GOMAXPROCS setting, or the number of operating system threads that can execute user-level Go code simultaneously. Source" +
|
||||||
|
"d from /sched/gomaxprocs:threads.",
|
||||||
|
typ: typeGauge,
|
||||||
|
samples: []sample{{value: float64(runtime.GOMAXPROCS(0))}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRuntimeMetric reads one runtime/metrics gauge.
|
||||||
|
func readRuntimeMetric(name string) uint64 {
|
||||||
|
s := []metrics.Sample{{Name: name}}
|
||||||
|
metrics.Read(s)
|
||||||
|
return s[0].Value.Uint64()
|
||||||
|
}
|
||||||
|
|
||||||
|
func threadCount() int {
|
||||||
|
n, _ := runtime.ThreadCreateProfile(nil)
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// 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_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,125 @@
|
|||||||
|
// 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/prometheus/common/model"
|
||||||
|
"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.NewTextParser(model.UTF8Validation)
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -30,18 +31,45 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Delay one real TCP write without injecting an error. The stream's write
|
// Delay one real TCP write without injecting an error. The stream's write
|
||||||
// failure must come from yamux's ConnectionWriteTimeout.
|
// failure must come from yamux's ConnectionWriteTimeout, which yamux enforces
|
||||||
|
// as a write deadline on the underlying connection, so the delayed write has
|
||||||
|
// to observe that deadline the way a real socket does.
|
||||||
type delayedTCPConn struct {
|
type delayedTCPConn struct {
|
||||||
net.Conn
|
net.Conn
|
||||||
armed atomic.Bool
|
armed atomic.Bool
|
||||||
entered chan struct{}
|
entered chan struct{}
|
||||||
release chan struct{}
|
release chan struct{}
|
||||||
|
|
||||||
|
deadlineMu sync.Mutex
|
||||||
|
deadline time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *delayedTCPConn) SetWriteDeadline(t time.Time) error {
|
||||||
|
c.deadlineMu.Lock()
|
||||||
|
c.deadline = t
|
||||||
|
c.deadlineMu.Unlock()
|
||||||
|
return c.Conn.SetWriteDeadline(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *delayedTCPConn) Write(p []byte) (int, error) {
|
func (c *delayedTCPConn) Write(p []byte) (int, error) {
|
||||||
if c.armed.Swap(false) {
|
if c.armed.Swap(false) {
|
||||||
close(c.entered)
|
close(c.entered)
|
||||||
<-c.release
|
c.deadlineMu.Lock()
|
||||||
|
deadline := c.deadline
|
||||||
|
c.deadlineMu.Unlock()
|
||||||
|
var timeout <-chan time.Time
|
||||||
|
if !deadline.IsZero() {
|
||||||
|
timer := time.NewTimer(time.Until(deadline))
|
||||||
|
defer timer.Stop()
|
||||||
|
timeout = timer.C
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-c.release:
|
||||||
|
case <-timeout:
|
||||||
|
// Same value a real socket returns; yamux maps any timeout error
|
||||||
|
// to ErrConnectionWriteTimeout.
|
||||||
|
return 0, os.ErrDeadlineExceeded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return c.Conn.Write(p)
|
return c.Conn.Write(p)
|
||||||
}
|
}
|
||||||
@@ -206,20 +234,12 @@ func TestDispatcherYamuxWriteTimeoutInterruptsRead(t *testing.T) {
|
|||||||
if observed.closeCalls.Load() != 1 {
|
if observed.closeCalls.Load() != 1 {
|
||||||
t.Fatalf("control closed %d times, want once", observed.closeCalls.Load())
|
t.Fatalf("control closed %d times, want once", observed.closeCalls.Load())
|
||||||
}
|
}
|
||||||
if client.IsClosed() || server.IsClosed() {
|
// yamux enforces ConnectionWriteTimeout on the transport, so the
|
||||||
t.Fatal("yamux session unexpectedly closed")
|
// failed write ends the whole session instead of leaving it blocked
|
||||||
}
|
// but alive.
|
||||||
fresh, freshPeer, freshObserved := loopbackControlPair(t, client, server, protocol)
|
regressionAwait(t, client.CloseChan(), "client yamux session close")
|
||||||
for range 5 {
|
regressionAwait(t, server.CloseChan(), "server yamux session close")
|
||||||
if err := fresh.WriteMsg(&ReqWorkConn{}); err != nil {
|
t.Log("Done/readLoop exited without peer FIN; Close once; write timeout closed both yamux sessions")
|
||||||
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
+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())
|
||||||
|
}
|
||||||
+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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Annotation keys follow the Kubernetes "qualified name" (label key) format.
|
||||||
|
// The constants below mirror k8s.io/apimachinery/pkg/util/validation's
|
||||||
|
// qualified-name validation (v0.28.8 semantics and messages) so that frp does
|
||||||
|
// not need to import that package; newer versions of the package link klog.
|
||||||
|
const (
|
||||||
|
qualifiedNameCharFmt = "[A-Za-z0-9]"
|
||||||
|
qualifiedNameExtCharFmt = "[-A-Za-z0-9_.]"
|
||||||
|
qualifiedNameFmt = "(" + qualifiedNameCharFmt + qualifiedNameExtCharFmt + "*)?" + qualifiedNameCharFmt
|
||||||
|
qualifiedNameErrMsg = "must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character"
|
||||||
|
qualifiedNameMaxLength = 63
|
||||||
|
|
||||||
|
dns1123LabelFmt = "[a-z0-9]([-a-z0-9]*[a-z0-9])?"
|
||||||
|
dns1123SubdomainFmt = dns1123LabelFmt + `(\.` + dns1123LabelFmt + ")*"
|
||||||
|
dns1123SubdomainErrorMsg = "a lowercase RFC 1123 subdomain must consist of lower case " +
|
||||||
|
"alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character"
|
||||||
|
dns1123SubdomainMaxLength = 253
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
qualifiedNameRegexp = regexp.MustCompile("^" + qualifiedNameFmt + "$")
|
||||||
|
dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$")
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsQualifiedName reports whether value is a valid Kubernetes qualified name
|
||||||
|
// (label key), the format used by frp for proxy annotation keys. If value is
|
||||||
|
// invalid, it returns one message per violation; an empty result means valid.
|
||||||
|
//
|
||||||
|
// The function does not normalize case: callers that accept case-insensitive
|
||||||
|
// keys must lowercase value themselves (frp's annotation validation passes
|
||||||
|
// strings.ToLower(key)).
|
||||||
|
func IsQualifiedName(value string) []string {
|
||||||
|
var errs []string
|
||||||
|
parts := strings.Split(value, "/")
|
||||||
|
var name string
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
name = parts[0]
|
||||||
|
case 2:
|
||||||
|
var prefix string
|
||||||
|
prefix, name = parts[0], parts[1]
|
||||||
|
if len(prefix) == 0 {
|
||||||
|
errs = append(errs, "prefix part "+emptyError())
|
||||||
|
} else if msgs := dns1123SubdomainErrors(prefix); len(msgs) != 0 {
|
||||||
|
errs = append(errs, prefixEach(msgs, "prefix part ")...)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return append(errs, "a qualified name "+regexError(qualifiedNameErrMsg, qualifiedNameFmt, "MyName", "my.name", "123-abc")+
|
||||||
|
" with an optional DNS subdomain prefix and '/' (e.g. 'example.com/MyName')")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(name) == 0 {
|
||||||
|
errs = append(errs, "name part "+emptyError())
|
||||||
|
} else if len(name) > qualifiedNameMaxLength {
|
||||||
|
errs = append(errs, "name part "+maxLenError(qualifiedNameMaxLength))
|
||||||
|
}
|
||||||
|
if !qualifiedNameRegexp.MatchString(name) {
|
||||||
|
errs = append(errs, "name part "+regexError(qualifiedNameErrMsg, qualifiedNameFmt, "MyName", "my.name", "123-abc"))
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func dns1123SubdomainErrors(value string) []string {
|
||||||
|
var errs []string
|
||||||
|
if len(value) > dns1123SubdomainMaxLength {
|
||||||
|
errs = append(errs, maxLenError(dns1123SubdomainMaxLength))
|
||||||
|
}
|
||||||
|
if !dns1123SubdomainRegexp.MatchString(value) {
|
||||||
|
errs = append(errs, regexError(dns1123SubdomainErrorMsg, dns1123SubdomainFmt, "example.com"))
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxLenError(length int) string {
|
||||||
|
return fmt.Sprintf("must be no more than %d characters", length)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyError() string {
|
||||||
|
return "must be non-empty"
|
||||||
|
}
|
||||||
|
|
||||||
|
func regexError(msg string, re string, examples ...string) string {
|
||||||
|
if len(examples) == 0 {
|
||||||
|
return msg + " (regex used for validation is '" + re + "')"
|
||||||
|
}
|
||||||
|
msg += " (e.g. "
|
||||||
|
for i := range examples {
|
||||||
|
if i > 0 {
|
||||||
|
msg += " or "
|
||||||
|
}
|
||||||
|
msg += "'" + examples[i] + "', "
|
||||||
|
}
|
||||||
|
msg += "regex used for validation is '" + re + "')"
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefixEach(msgs []string, prefix string) []string {
|
||||||
|
for i := range msgs {
|
||||||
|
msgs[i] = prefix + msgs[i]
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsQualifiedNameValidity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{name: "single letter", value: "a", valid: true},
|
||||||
|
{name: "single digit", value: "0", valid: true},
|
||||||
|
{name: "uppercase name", value: "MyName", valid: true},
|
||||||
|
{name: "allowed punctuation", value: "a-._b", valid: true},
|
||||||
|
{name: "name at max length", value: strings.Repeat("a", 63), valid: true},
|
||||||
|
{name: "name over max length", value: strings.Repeat("a", 64), valid: false},
|
||||||
|
{name: "prefix and name", value: "example.com/MyName", valid: true},
|
||||||
|
{name: "prefix at max length", value: strings.Repeat("a", 253) + "/key", valid: true},
|
||||||
|
{name: "prefix over max length", value: strings.Repeat("a", 254) + "/key", valid: false},
|
||||||
|
{name: "name at max length with prefix", value: "example.com/" + strings.Repeat("a", 63), valid: true},
|
||||||
|
{name: "name over max length with prefix", value: "example.com/" + strings.Repeat("a", 64), valid: false},
|
||||||
|
{name: "empty", value: "", valid: false},
|
||||||
|
{name: "leading hyphen", value: "-a", valid: false},
|
||||||
|
{name: "trailing hyphen", value: "a-", valid: false},
|
||||||
|
{name: "leading underscore", value: "_a", valid: false},
|
||||||
|
{name: "trailing dot", value: "a.", valid: false},
|
||||||
|
{name: "only dot", value: ".", valid: false},
|
||||||
|
{name: "double dot in name", value: "a..b", valid: true},
|
||||||
|
{name: "space", value: "a b", valid: false},
|
||||||
|
{name: "tab", value: "a\tb", valid: false},
|
||||||
|
{name: "newline", value: "a\nb", valid: false},
|
||||||
|
{name: "nul byte", value: "a\x00b", valid: false},
|
||||||
|
{name: "non ascii name", value: "é", valid: false},
|
||||||
|
{name: "non ascii prefix", value: "é/key", valid: false},
|
||||||
|
{name: "empty prefix", value: "/key", valid: false},
|
||||||
|
{name: "empty name", value: "example.com/", valid: false},
|
||||||
|
{name: "two slashes", value: "a//b", valid: false},
|
||||||
|
{name: "three segments", value: "a/b/c", valid: false},
|
||||||
|
{name: "uppercase prefix", value: "Example.com/key", valid: false},
|
||||||
|
{name: "prefix with empty label", value: "example..com/key", valid: false},
|
||||||
|
{name: "prefix with leading hyphen", value: "-example.com/key", valid: false},
|
||||||
|
{name: "prefix with trailing dot", value: "example.com./key", valid: false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
errs := IsQualifiedName(tc.value)
|
||||||
|
if tc.valid {
|
||||||
|
require.Empty(t, errs)
|
||||||
|
} else {
|
||||||
|
require.NotEmpty(t, errs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsQualifiedNameMessages(t *testing.T) {
|
||||||
|
t.Run("name too long", func(t *testing.T) {
|
||||||
|
errs := IsQualifiedName(strings.Repeat("a", 64))
|
||||||
|
require.Contains(t, errs, "name part must be no more than 63 characters")
|
||||||
|
})
|
||||||
|
t.Run("prefix too long", func(t *testing.T) {
|
||||||
|
errs := IsQualifiedName(strings.Repeat("a", 254) + "/key")
|
||||||
|
require.Contains(t, errs, "prefix part must be no more than 253 characters")
|
||||||
|
})
|
||||||
|
t.Run("too many segments", func(t *testing.T) {
|
||||||
|
errs := IsQualifiedName("a/b/c")
|
||||||
|
require.Len(t, errs, 1)
|
||||||
|
require.Contains(t, errs[0], "a qualified name must consist of alphanumeric characters")
|
||||||
|
require.Contains(t, errs[0], "with an optional DNS subdomain prefix and '/'")
|
||||||
|
})
|
||||||
|
t.Run("invalid character", func(t *testing.T) {
|
||||||
|
errs := IsQualifiedName("a b")
|
||||||
|
require.Contains(t, errs[0], "name part must consist of alphanumeric characters")
|
||||||
|
})
|
||||||
|
t.Run("empty prefix", func(t *testing.T) {
|
||||||
|
errs := IsQualifiedName("/key")
|
||||||
|
require.Equal(t, []string{"prefix part must be non-empty"}, errs)
|
||||||
|
})
|
||||||
|
t.Run("empty name", func(t *testing.T) {
|
||||||
|
errs := IsQualifiedName("example.com/")
|
||||||
|
require.Contains(t, errs[0], "name part must be non-empty")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "0.71.1"
|
var version = "0.72.0"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
+17
-10
@@ -17,10 +17,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/errors"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
@@ -138,6 +137,7 @@ func (v *Muxer) Listen(ctx context.Context, cfg *RouteConfig) (l *Listener, err
|
|||||||
password: cfg.Password,
|
password: cfg.Password,
|
||||||
mux: v,
|
mux: v,
|
||||||
accept: make(chan net.Conn),
|
accept: make(chan net.Conn),
|
||||||
|
done: make(chan struct{}),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
}
|
}
|
||||||
err = v.registryRouter.Add(cfg.Domain, cfg.Location, cfg.RouteByHTTPUser, l)
|
err = v.registryRouter.Add(cfg.Domain, cfg.Location, cfg.RouteByHTTPUser, l)
|
||||||
@@ -215,11 +215,12 @@ func (v *Muxer) handle(c net.Conn) {
|
|||||||
c = sConn
|
c = sConn
|
||||||
|
|
||||||
xl.Debugf("new request host [%s] path [%s] httpUser [%s]", name, path, httpUser)
|
xl.Debugf("new request host [%s] path [%s] httpUser [%s]", name, path, httpUser)
|
||||||
err = errors.PanicToError(func() {
|
select {
|
||||||
l.accept <- c
|
case l.accept <- c:
|
||||||
})
|
// The connection is now owned by the Accept caller.
|
||||||
if err != nil {
|
case <-l.done:
|
||||||
xl.Warnf("listener is already closed, ignore this request")
|
xl.Warnf("listener is already closed, ignore this request")
|
||||||
|
_ = c.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +233,17 @@ type Listener struct {
|
|||||||
password string
|
password string
|
||||||
mux *Muxer // for closing Muxer
|
mux *Muxer // for closing Muxer
|
||||||
accept chan net.Conn
|
accept chan net.Conn
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Listener) Accept() (net.Conn, error) {
|
func (l *Listener) Accept() (net.Conn, error) {
|
||||||
xl := xlog.FromContextSafe(l.ctx)
|
xl := xlog.FromContextSafe(l.ctx)
|
||||||
conn, ok := <-l.accept
|
var conn net.Conn
|
||||||
if !ok {
|
select {
|
||||||
|
case conn = <-l.accept:
|
||||||
|
case <-l.done:
|
||||||
return nil, fmt.Errorf("listener closed")
|
return nil, fmt.Errorf("listener closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +263,10 @@ func (l *Listener) Accept() (net.Conn, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Listener) Close() error {
|
func (l *Listener) Close() error {
|
||||||
l.mux.registryRouter.Del(l.name, l.location, l.routeByHTTPUser)
|
l.closeOnce.Do(func() {
|
||||||
close(l.accept)
|
l.mux.registryRouter.Del(l.name, l.location, l.routeByHTTPUser)
|
||||||
|
close(l.done)
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// 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 vhost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newCloseTestMuxer() *Muxer {
|
||||||
|
return &Muxer{
|
||||||
|
timeout: time.Second,
|
||||||
|
registryRouter: NewRouters(),
|
||||||
|
vhostFunc: func(c net.Conn) (net.Conn, map[string]string, error) {
|
||||||
|
return c, map[string]string{"Host": "example.com"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPendingHandoffIsClosed(t *testing.T, client, server net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
m := newCloseTestMuxer()
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
m.SetSuccessHookFunc(func(net.Conn, map[string]string) error {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
l, err := m.Listen(context.Background(), &RouteConfig{Domain: "example.com"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handled := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(handled)
|
||||||
|
m.handle(server)
|
||||||
|
}()
|
||||||
|
<-entered
|
||||||
|
if err := client.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the route after resolution but before the handoff. The pending
|
||||||
|
// connection must be closed, not abandoned.
|
||||||
|
if err := l.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
<-handled
|
||||||
|
|
||||||
|
if _, err := client.Read(make([]byte, 1)); err == nil {
|
||||||
|
t.Fatal("connection was not closed by the muxer")
|
||||||
|
} else if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
|
t.Fatalf("connection was not closed by the muxer: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenerCloseReleasesPendingConn(t *testing.T) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
testPendingHandoffIsClosed(t, client, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenerCloseReleasesPendingTCPConn(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
client, err := net.Dial("tcp", ln.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
server, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
testPendingHandoffIsClosed(t, client, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenerCloseIsIdempotent(t *testing.T) {
|
||||||
|
m := newCloseTestMuxer()
|
||||||
|
l, err := m.Listen(context.Background(), &RouteConfig{Domain: "example.com"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := l.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := l.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := l.Accept(); err == nil {
|
||||||
|
t.Fatal("Accept after Close should return an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
+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()
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
@@ -366,6 +367,9 @@ type SessionContext struct {
|
|||||||
EncryptionKey []byte
|
EncryptionKey []byte
|
||||||
// control connection
|
// control connection
|
||||||
Conn *msg.Conn
|
Conn *msg.Conn
|
||||||
|
// A mux session carries exactly one control. Close it when the control is
|
||||||
|
// replaced or exits fatally to release all remaining streams and work connections.
|
||||||
|
Session io.Closer
|
||||||
// login message
|
// login message
|
||||||
LoginMsg *msg.Login
|
LoginMsg *msg.Login
|
||||||
// server configuration
|
// server configuration
|
||||||
@@ -575,6 +579,10 @@ func (ctl *Control) markReplaced() <-chan struct{} {
|
|||||||
func (ctl *Control) interruptReadAndClose() error {
|
func (ctl *Control) interruptReadAndClose() error {
|
||||||
ctl.interruptOnce.Do(func() {
|
ctl.interruptOnce.Do(func() {
|
||||||
_ = ctl.sessionCtx.Conn.SetReadDeadline(time.Now())
|
_ = ctl.sessionCtx.Conn.SetReadDeadline(time.Now())
|
||||||
|
if ctl.sessionCtx.Session != nil {
|
||||||
|
ctl.interruptErr = ctl.sessionCtx.Session.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
ctl.interruptErr = ctl.sessionCtx.Conn.Close()
|
ctl.interruptErr = ctl.sessionCtx.Conn.Close()
|
||||||
})
|
})
|
||||||
return ctl.interruptErr
|
return ctl.interruptErr
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Copyright 2026 fatedier, fatedier@gmail.com
|
||||||
|
//
|
||||||
|
// 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"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fmux "github.com/fatedier/yamux"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestControlReplacementClosesYamuxSession(t *testing.T) {
|
||||||
|
svr := newControlTestService(t)
|
||||||
|
_, clientA, serverA, _ := newYamuxTestControl(t, svr)
|
||||||
|
_, _, _, connB := newYamuxTestControl(t, svr)
|
||||||
|
|
||||||
|
waitForSignal(t, serverA.CloseChan(), "replaced control's server yamux session to close")
|
||||||
|
waitForSignal(t, clientA.CloseChan(), "replaced control's client yamux session to close")
|
||||||
|
_, err := clientA.OpenStream()
|
||||||
|
require.ErrorIs(t, err, fmux.ErrSessionShutdown)
|
||||||
|
|
||||||
|
require.NoError(t, connB.SetDeadline(time.Now().Add(3*time.Second)))
|
||||||
|
require.NoError(t, connB.WriteMsg(&msg.Ping{}))
|
||||||
|
response, err := connB.ReadMsg()
|
||||||
|
require.NoError(t, err, "replacement control must still receive a Pong")
|
||||||
|
require.Equal(t, &msg.Pong{}, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlCloseClosesYamuxSession(t *testing.T) {
|
||||||
|
ctl, client, server, _ := newYamuxTestControl(t, newControlTestService(t))
|
||||||
|
|
||||||
|
require.NoError(t, ctl.Close())
|
||||||
|
waitForSignal(t, server.CloseChan(), "closed control's server yamux session to close")
|
||||||
|
waitForSignal(t, client.CloseChan(), "closed control's client yamux session to close")
|
||||||
|
_, err := client.OpenStream()
|
||||||
|
require.ErrorIs(t, err, fmux.ErrSessionShutdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlConcurrentReplacementClosesLosingSessions(t *testing.T) {
|
||||||
|
svr := newControlTestService(t)
|
||||||
|
ctlA, clientA, serverA, connA := newYamuxTestControl(t, svr)
|
||||||
|
type result struct {
|
||||||
|
ctl *Control
|
||||||
|
client, server *fmux.Session
|
||||||
|
conn *msg.Conn
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan result, 2)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for range 2 {
|
||||||
|
client, server, clientConn, serverConn := newYamuxTestSession(t)
|
||||||
|
go func() {
|
||||||
|
r := result{client: client, server: server, conn: clientConn}
|
||||||
|
select {
|
||||||
|
case <-start:
|
||||||
|
r.ctl, r.err = svr.RegisterControl(serverConn, &msg.Login{
|
||||||
|
RunID: "shared-run",
|
||||||
|
ClientID: "client",
|
||||||
|
Timestamp: 1,
|
||||||
|
PrivilegeKey: util.GetAuthKey("", 1),
|
||||||
|
}, false, wire.ProtocolV1, "", server)
|
||||||
|
if r.err == nil {
|
||||||
|
r.err = svr.completeControlLogin(r.ctl, func() error { return nil })
|
||||||
|
}
|
||||||
|
if r.err != nil && r.ctl != nil {
|
||||||
|
svr.ctlManager.Remove(r.ctl)
|
||||||
|
_ = r.ctl.Close()
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
r.err = ctx.Err()
|
||||||
|
}
|
||||||
|
results <- r
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
controls := [3]result{{ctl: ctlA, client: clientA, server: serverA, conn: connA}}
|
||||||
|
for i := range 2 {
|
||||||
|
r := waitForResult(t, results, "concurrent control login")
|
||||||
|
if r.ctl != nil {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = r.ctl.Close()
|
||||||
|
waitForControlDone(t, r.ctl)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
require.ErrorIs(t, r.err, errControlReplaced)
|
||||||
|
}
|
||||||
|
controls[i+1] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
current, ok := svr.ctlManager.GetByID("shared-run")
|
||||||
|
require.True(t, ok, "a running current control must remain")
|
||||||
|
require.NotSame(t, ctlA, current)
|
||||||
|
svr.ctlManager.mu.RLock()
|
||||||
|
currentCount := len(svr.ctlManager.ctlsByRunID)
|
||||||
|
svr.ctlManager.mu.RUnlock()
|
||||||
|
require.Equal(t, 1, currentCount)
|
||||||
|
winners := 0
|
||||||
|
for _, r := range controls {
|
||||||
|
if r.ctl == current {
|
||||||
|
winners++
|
||||||
|
require.NoError(t, r.err)
|
||||||
|
require.False(t, r.server.IsClosed(), "winner's server session must stay open")
|
||||||
|
require.False(t, r.client.IsClosed(), "winner's client session must stay open")
|
||||||
|
require.NoError(t, r.conn.SetDeadline(time.Now().Add(3*time.Second)))
|
||||||
|
require.NoError(t, r.conn.WriteMsg(&msg.Ping{}))
|
||||||
|
response, err := r.conn.ReadMsg()
|
||||||
|
require.NoError(t, err, "winner must still receive a Pong")
|
||||||
|
require.Equal(t, &msg.Pong{}, response)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
waitForSignal(t, r.server.CloseChan(), "losing control's server yamux session to close")
|
||||||
|
waitForSignal(t, r.client.CloseChan(), "losing control's client yamux session to close")
|
||||||
|
_, err := r.client.OpenStream()
|
||||||
|
require.ErrorIs(t, err, fmux.ErrSessionShutdown)
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, winners)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newYamuxTestSession(t *testing.T) (*fmux.Session, *fmux.Session, *msg.Conn, *msg.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
require.NoError(t, listener.SetDeadline(time.Now().Add(3*time.Second)))
|
||||||
|
clientTCP, err := net.DialTimeout("tcp", listener.Addr().String(), 3*time.Second)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = clientTCP.Close() })
|
||||||
|
serverTCP, err := listener.Accept()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = serverTCP.Close() })
|
||||||
|
|
||||||
|
client, err := fmux.Client(clientTCP, fmux.DefaultConfig())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
server, err := fmux.Server(serverTCP, fmux.DefaultConfig())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = server.Close() })
|
||||||
|
clientStream, err := client.OpenStream()
|
||||||
|
require.NoError(t, err)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
serverStream, err := server.AcceptStreamWithContext(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return client, server, msg.NewConn(clientStream, msg.NewV1ReadWriter(clientStream)),
|
||||||
|
msg.NewConn(serverStream, msg.NewV1ReadWriter(serverStream))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newYamuxTestControl(t *testing.T, svr *Service) (*Control, *fmux.Session, *fmux.Session, *msg.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
client, server, clientConn, serverConn := newYamuxTestSession(t)
|
||||||
|
ctl, err := svr.RegisterControl(serverConn, &msg.Login{
|
||||||
|
RunID: "shared-run",
|
||||||
|
ClientID: "client",
|
||||||
|
Timestamp: 1,
|
||||||
|
PrivilegeKey: util.GetAuthKey("", 1),
|
||||||
|
}, false, wire.ProtocolV1, "", server)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = ctl.Close()
|
||||||
|
waitForControlDone(t, ctl)
|
||||||
|
})
|
||||||
|
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
||||||
|
return ctl, client, server, clientConn
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-29
@@ -31,6 +31,7 @@ import (
|
|||||||
"github.com/fatedier/golib/crypto"
|
"github.com/fatedier/golib/crypto"
|
||||||
"github.com/fatedier/golib/net/mux"
|
"github.com/fatedier/golib/net/mux"
|
||||||
fmux "github.com/fatedier/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"
|
||||||
|
|
||||||
@@ -120,7 +121,8 @@ 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.
|
// HTTP vhost resources are started and owned by NewService.
|
||||||
httpVhostServer *http.Server
|
httpVhostServer *http.Server
|
||||||
httpVhostListener net.Listener
|
httpVhostListener net.Listener
|
||||||
@@ -152,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
|
||||||
@@ -179,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 {
|
||||||
@@ -217,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)
|
||||||
|
|
||||||
@@ -369,9 +348,58 @@ 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()
|
svr.lifecycleMu.Lock()
|
||||||
@@ -393,6 +421,15 @@ func (svr *Service) Run(ctx context.Context) {
|
|||||||
|
|
||||||
// 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 {
|
||||||
@@ -401,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 {
|
||||||
@@ -459,6 +505,9 @@ func (svr *Service) closeResources() {
|
|||||||
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 {
|
if svr.httpVhostServer != nil {
|
||||||
svr.httpVhostServer.Close()
|
svr.httpVhostServer.Close()
|
||||||
// Close even if Serve has not registered its listener yet.
|
// Close even if Serve has not registered its listener yet.
|
||||||
@@ -472,7 +521,7 @@ func (svr *Service) closeResources() {
|
|||||||
svr.ctlManager.Close()
|
svr.ctlManager.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) {
|
func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool, session io.Closer) {
|
||||||
xl := xlog.FromContextSafe(ctx)
|
xl := xlog.FromContextSafe(ctx)
|
||||||
|
|
||||||
acceptedConn, err := svr.acceptConnection(ctx, conn)
|
acceptedConn, err := svr.acceptConnection(ctx, conn)
|
||||||
@@ -503,7 +552,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol, acceptedConn.udpPacketCodec)
|
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol, acceptedConn.udpPacketCodec, session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,10 +813,10 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) {
|
|||||||
session.Close()
|
session.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go svr.handleConnection(ctx, stream, internal)
|
go svr.handleConnection(ctx, stream, internal, session)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
svr.handleConnection(ctx, frpConn, internal)
|
svr.handleConnection(ctx, frpConn, internal, nil)
|
||||||
}
|
}
|
||||||
}(ctx, c)
|
}(ctx, c)
|
||||||
}
|
}
|
||||||
@@ -790,7 +839,7 @@ func (svr *Service) HandleQUICListener(l *quic.Listener) {
|
|||||||
_ = frpConn.CloseWithError(0, "")
|
_ = frpConn.CloseWithError(0, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go svr.handleConnection(ctx, netpkg.QuicStreamToNetConn(stream, frpConn), false)
|
go svr.handleConnection(ctx, netpkg.QuicStreamToNetConn(stream, frpConn), false, nil)
|
||||||
}
|
}
|
||||||
}(context.Background(), c)
|
}(context.Background(), c)
|
||||||
}
|
}
|
||||||
@@ -802,6 +851,7 @@ func (svr *Service) RegisterControl(
|
|||||||
internal bool,
|
internal bool,
|
||||||
wireProtocol string,
|
wireProtocol string,
|
||||||
udpPacketCodec string,
|
udpPacketCodec string,
|
||||||
|
session io.Closer,
|
||||||
) (*Control, error) {
|
) (*Control, error) {
|
||||||
switch wireProtocol {
|
switch wireProtocol {
|
||||||
case wire.ProtocolV1:
|
case wire.ProtocolV1:
|
||||||
@@ -851,6 +901,7 @@ func (svr *Service) RegisterControl(
|
|||||||
AuthVerifier: authVerifier,
|
AuthVerifier: authVerifier,
|
||||||
EncryptionKey: svr.auth.EncryptionKey(),
|
EncryptionKey: svr.auth.EncryptionKey(),
|
||||||
Conn: ctlConn,
|
Conn: ctlConn,
|
||||||
|
Session: session,
|
||||||
LoginMsg: loginMsg,
|
LoginMsg: loginMsg,
|
||||||
ServerCfg: svr.cfg,
|
ServerCfg: svr.cfg,
|
||||||
WireProtocol: wireProtocol,
|
WireProtocol: wireProtocol,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime/pprof"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -86,6 +91,168 @@ func assertLifecyclePorts(t *testing.T, addresses []string, released bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestServiceRunAfterEarlyShutdown(t *testing.T) {
|
||||||
for _, mode := range []string{"close before Run", "close before Run with web", "parent already cancelled"} {
|
for _, mode := range []string{"close before Run", "close before Run with web", "parent already cancelled"} {
|
||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
@@ -109,6 +276,50 @@ func TestServiceRunAfterEarlyShutdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestServiceCloseMarksClosedBeforeCleanup(t *testing.T) {
|
||||||
svr, listener, addresses := newLifecycleTestService(t, false)
|
svr, listener, addresses := newLifecycleTestService(t, false)
|
||||||
listener.closeEntered, listener.closeResume = make(chan struct{}), make(chan struct{})
|
listener.closeEntered, listener.closeResume = make(chan struct{}), make(chan struct{})
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ func TestServiceWorkConnRoutingClientHelloPolicy(t *testing.T) {
|
|||||||
ClientSpec: msg.ClientSpec{
|
ClientSpec: msg.ClientSpec{
|
||||||
AlwaysAuthPass: true,
|
AlwaysAuthPass: true,
|
||||||
},
|
},
|
||||||
}, true, wire.ProtocolV2, tc.controlUDPPacketCodec)
|
}, true, wire.ProtocolV2, tc.controlUDPPacketCodec, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
|
||||||
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
waitForSignal(t, controlConn.readStarted, "control reader to start")
|
||||||
@@ -634,7 +634,7 @@ func TestServiceRegisterControlRejectsInvalidCodecSelection(t *testing.T) {
|
|||||||
svr := newControlTestService(t)
|
svr := newControlTestService(t)
|
||||||
conn := newDeadlineReadConn()
|
conn := newDeadlineReadConn()
|
||||||
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
||||||
ctl, err := svr.RegisterControl(msgConn, &msg.Login{}, true, tc.wireProtocol, tc.udpPacketCodec)
|
ctl, err := svr.RegisterControl(msgConn, &msg.Login{}, true, tc.wireProtocol, tc.udpPacketCodec, nil)
|
||||||
require.Nil(t, ctl)
|
require.Nil(t, ctl)
|
||||||
require.ErrorContains(t, err, tc.errorSubstring)
|
require.ErrorContains(t, err, tc.errorSubstring)
|
||||||
})
|
})
|
||||||
@@ -650,7 +650,7 @@ func TestServiceRegisterControlRejectsInvalidRunID(t *testing.T) {
|
|||||||
svr := newControlTestService(t)
|
svr := newControlTestService(t)
|
||||||
conn := newDeadlineReadConn()
|
conn := newDeadlineReadConn()
|
||||||
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
|
||||||
ctl, err := svr.RegisterControl(msgConn, &msg.Login{RunID: runID}, true, wire.ProtocolV1, "")
|
ctl, err := svr.RegisterControl(msgConn, &msg.Login{RunID: runID}, true, wire.ProtocolV1, "", nil)
|
||||||
require.Nil(t, ctl)
|
require.Nil(t, ctl)
|
||||||
require.ErrorContains(t, err, "invalid run id")
|
require.ErrorContains(t, err, "invalid run id")
|
||||||
})
|
})
|
||||||
@@ -684,7 +684,7 @@ func TestServiceRegisterControlPoolCountBoundaries(t *testing.T) {
|
|||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
PrivilegeKey: util.GetAuthKey("", timestamp),
|
PrivilegeKey: util.GetAuthKey("", timestamp),
|
||||||
PoolCount: tc.poolCount,
|
PoolCount: tc.poolCount,
|
||||||
}, false, wire.ProtocolV1, "")
|
}, false, wire.ProtocolV1, "", nil)
|
||||||
if tc.wantErr {
|
if tc.wantErr {
|
||||||
require.Nil(t, ctl)
|
require.Nil(t, ctl)
|
||||||
require.ErrorContains(t, err, "unexpected error when creating new controller")
|
require.ErrorContains(t, err, "unexpected error when creating new controller")
|
||||||
@@ -758,7 +758,7 @@ func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) {
|
|||||||
ClientSpec: msg.ClientSpec{
|
ClientSpec: msg.ClientSpec{
|
||||||
AlwaysAuthPass: true,
|
AlwaysAuthPass: true,
|
||||||
},
|
},
|
||||||
}, true, wire.ProtocolV1, "")
|
}, true, wire.ProtocolV1, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
timestamp := time.Now().Unix()
|
timestamp := time.Now().Unix()
|
||||||
@@ -802,7 +802,7 @@ func TestServiceVisitorRoutingCarriesControlPacketCodec(t *testing.T) {
|
|||||||
ClientSpec: msg.ClientSpec{
|
ClientSpec: msg.ClientSpec{
|
||||||
AlwaysAuthPass: true,
|
AlwaysAuthPass: true,
|
||||||
},
|
},
|
||||||
}, true, wire.ProtocolV2, wire.UDPPacketCodecBinary)
|
}, true, wire.ProtocolV2, wire.UDPPacketCodecBinary, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
timestamp := time.Now().Unix()
|
timestamp := time.Now().Unix()
|
||||||
@@ -891,7 +891,7 @@ func registerLifecycleTestControl(svr *Service) (*Control, *deadlineReadConn, er
|
|||||||
ClientSpec: msg.ClientSpec{
|
ClientSpec: msg.ClientSpec{
|
||||||
AlwaysAuthPass: true,
|
AlwaysAuthPass: true,
|
||||||
},
|
},
|
||||||
}, true, wire.ProtocolV1, "")
|
}, true, wire.ProtocolV1, "", nil)
|
||||||
return ctl, conn, err
|
return ctl, conn, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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