mirror of
https://github.com/fatedier/frp.git
synced 2026-10-24 05:53:27 +08:00
Compare commits
14
Commits
2d9d1ace3c
...
v0.69.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8c1e5116c | ||
|
|
3e19ef9bfd | ||
|
|
e20f974d61 | ||
|
|
a88e0e9a49 | ||
|
|
ad07d27914 | ||
|
|
8666e3643f | ||
|
|
4ec8de973f | ||
|
|
5bfcea3d0c | ||
|
|
0a1b4ab21f | ||
|
|
5f575b8442 | ||
|
|
a1348cdf00 | ||
|
|
2f5e1f7945 | ||
|
|
22ae8166d3 | ||
|
|
af6bc6369d |
@@ -13,6 +13,14 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
|
<br>
|
||||||
|
<b>The complete IDE crafted for professional Go developers</b>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/beclab/Olares" target="_blank">
|
<a href="https://github.com/beclab/Olares" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
||||||
@@ -32,24 +40,6 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
|
|||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
|
||||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
|
||||||
<br>
|
|
||||||
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
|
||||||
<br>
|
|
||||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
|
||||||
<br>
|
|
||||||
<b>The complete IDE crafted for professional Go developers</b>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## What is frp?
|
## What is frp?
|
||||||
|
|||||||
+8
-18
@@ -15,6 +15,14 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
|
<br>
|
||||||
|
<b>The complete IDE crafted for professional Go developers</b>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/beclab/Olares" target="_blank">
|
<a href="https://github.com/beclab/Olares" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
||||||
@@ -34,24 +42,6 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
|
|||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
|
||||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
|
||||||
<br>
|
|
||||||
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
|
||||||
<br>
|
|
||||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
|
||||||
<br>
|
|
||||||
<b>The complete IDE crafted for professional Go developers</b>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## 为什么使用 frp ?
|
## 为什么使用 frp ?
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ This release introduces wire protocol v2 as a transition path for future frpc/fr
|
|||||||
|
|
||||||
**The default value of `transport.wireProtocol` remains `v1` in this release.** Users can keep the default for now. To test v2 early, upgrade both frpc and frps to versions that support it, then set `transport.wireProtocol = "v2"` in frpc. A v2-enabled frpc cannot connect to an older frps.
|
**The default value of `transport.wireProtocol` remains `v1` in this release.** Users can keep the default for now. To test v2 early, upgrade both frpc and frps to versions that support it, then set `transport.wireProtocol = "v2"` in frpc. A v2-enabled frpc cannot connect to an older frps.
|
||||||
|
|
||||||
|
When `transport.wireProtocol = "v2"` is enabled, the control channel uses negotiated AEAD encryption after the login handshake. Both frpc and frps must be upgraded to this release to use v2.
|
||||||
|
|
||||||
v1 will be deprecated when v2 becomes the default in a future release. It will continue to be supported until v0.78.0 is released, and may be removed in v0.78.0 or later.
|
v1 will be deprecated when v2 becomes the default in a future release. It will continue to be supported until v0.78.0 is released, and may be removed in v0.78.0 or later.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Added `transport.wireProtocol` for frpc to select the internal message protocol used between frpc and frps. Supported values are `v1` and `v2`.
|
* Added `transport.wireProtocol` for frpc to select the internal message protocol used between frpc and frps. Supported values are `v1` and `v2`.
|
||||||
* Added client protocol visibility in the frps dashboard and `/api/clients` API. Online clients now report their negotiated protocol as `v1` or `v2`.
|
* Added client protocol visibility in the frps dashboard and `/api/clients` API. Online clients now report their negotiated protocol as `v1` or `v2`.
|
||||||
|
* Wire protocol v2 now negotiates AEAD control-channel encryption. Supported algorithms are `xchacha20-poly1305` and `aes-256-gcm`; frpc advertises its preferred order based on local AES-GCM hardware support, and frps selects the first supported algorithm from that list.
|
||||||
|
|||||||
@@ -74,17 +74,18 @@ func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
loginRespMsg, err := d.exchangeLogin(conn, loginMsg)
|
loginResult, err := d.exchangeLogin(conn, loginMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
loginRespMsg := loginResult.resp
|
||||||
if loginRespMsg.Error != "" {
|
if loginRespMsg.Error != "" {
|
||||||
return nil, errors.New(loginRespMsg.Error)
|
return nil, errors.New(loginRespMsg.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var controlRW io.ReadWriter = conn
|
var controlRW io.ReadWriter = conn
|
||||||
if d.clientSpec == nil || d.clientSpec.Type != "ssh-tunnel" {
|
if d.clientSpec == nil || d.clientSpec.Type != "ssh-tunnel" {
|
||||||
controlRW, err = netpkg.NewCryptoReadWriter(conn, d.auth.EncryptionKey())
|
controlRW, err = d.newControlReadWriter(conn, loginResult.crypto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create control crypto read writer: %w", err)
|
return nil, fmt.Errorf("create control crypto read writer: %w", err)
|
||||||
}
|
}
|
||||||
@@ -125,9 +126,16 @@ func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login,
|
|||||||
return loginMsg, nil
|
return loginMsg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*msg.LoginResp, error) {
|
type loginExchangeResult struct {
|
||||||
|
resp *msg.LoginResp
|
||||||
|
crypto *wire.CryptoContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
|
||||||
rw := msg.NewV1ReadWriter(conn)
|
rw := msg.NewV1ReadWriter(conn)
|
||||||
var wireConn *wire.Conn
|
var wireConn *wire.Conn
|
||||||
|
var clientHello wire.ClientHello
|
||||||
|
var clientHelloPayload []byte
|
||||||
|
|
||||||
if d.common.Transport.WireProtocol == wire.ProtocolV2 {
|
if d.common.Transport.WireProtocol == wire.ProtocolV2 {
|
||||||
if err := wire.WriteMagic(conn); err != nil {
|
if err := wire.WriteMagic(conn); err != nil {
|
||||||
@@ -136,14 +144,23 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
|
|||||||
|
|
||||||
wireConn = wire.NewConn(conn)
|
wireConn = wire.NewConn(conn)
|
||||||
rw = msg.NewV2ReadWriterWithConn(wireConn)
|
rw = msg.NewV2ReadWriterWithConn(wireConn)
|
||||||
hello := wire.DefaultClientHello(wire.BootstrapInfo{
|
var err error
|
||||||
|
clientHello, err = wire.NewClientHello(wire.BootstrapInfo{
|
||||||
Transport: d.common.Transport.Protocol,
|
Transport: d.common.Transport.Protocol,
|
||||||
TLS: lo.FromPtr(d.common.Transport.TLS.Enable) || d.common.Transport.Protocol == "wss" || d.common.Transport.Protocol == "quic",
|
TLS: lo.FromPtr(d.common.Transport.TLS.Enable) || d.common.Transport.Protocol == "wss" || d.common.Transport.Protocol == "quic",
|
||||||
TCPMux: lo.FromPtr(d.common.Transport.TCPMux),
|
TCPMux: lo.FromPtr(d.common.Transport.TCPMux),
|
||||||
})
|
})
|
||||||
if err := wireConn.WriteJSONFrame(wire.FrameTypeClientHello, hello); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
clientHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeClientHello, clientHello)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := wireConn.WriteFrame(clientHelloFrame); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clientHelloPayload = clientHelloFrame.Payload
|
||||||
}
|
}
|
||||||
if err := rw.WriteMsg(loginMsg); err != nil {
|
if err := rw.WriteMsg(loginMsg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -154,19 +171,50 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
|
|||||||
_ = conn.SetReadDeadline(time.Time{})
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var cryptoContext *wire.CryptoContext
|
||||||
if wireConn != nil {
|
if wireConn != nil {
|
||||||
|
serverHelloFrame, err := wireConn.ReadFrame()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if serverHelloFrame.Type != wire.FrameTypeServerHello {
|
||||||
|
return nil, fmt.Errorf("unexpected frame type %d, want %d", serverHelloFrame.Type, wire.FrameTypeServerHello)
|
||||||
|
}
|
||||||
var serverHello wire.ServerHello
|
var serverHello wire.ServerHello
|
||||||
if err := wireConn.ReadJSONFrame(wire.FrameTypeServerHello, &serverHello); err != nil {
|
if err := wireConn.UnmarshalFrame(serverHelloFrame, &serverHello); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if serverHello.Error != "" {
|
if serverHello.Error != "" {
|
||||||
return nil, errors.New(serverHello.Error)
|
return nil, errors.New(serverHello.Error)
|
||||||
}
|
}
|
||||||
|
cryptoContext, err = wire.NewClientCryptoContext(clientHelloPayload, serverHelloFrame.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var loginRespMsg msg.LoginResp
|
var loginRespMsg msg.LoginResp
|
||||||
if err := rw.ReadMsgInto(&loginRespMsg); err != nil {
|
if err := rw.ReadMsgInto(&loginRespMsg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &loginRespMsg, nil
|
return &loginExchangeResult{
|
||||||
|
resp: &loginRespMsg,
|
||||||
|
crypto: cryptoContext,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *controlSessionDialer) newControlReadWriter(conn net.Conn, cryptoContext *wire.CryptoContext) (io.ReadWriter, error) {
|
||||||
|
if d.common.Transport.WireProtocol == wire.ProtocolV2 {
|
||||||
|
if cryptoContext == nil {
|
||||||
|
return nil, errors.New("missing v2 crypto negotiation")
|
||||||
|
}
|
||||||
|
return netpkg.NewAEADCryptoReadWriter(
|
||||||
|
conn,
|
||||||
|
d.auth.EncryptionKey(),
|
||||||
|
netpkg.AEADCryptoRoleClient,
|
||||||
|
cryptoContext.Algorithm,
|
||||||
|
cryptoContext.TranscriptHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return netpkg.NewCryptoReadWriter(conn, d.auth.EncryptionKey())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,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/proto/wire"
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testConnector struct {
|
type testConnector struct {
|
||||||
@@ -140,8 +141,17 @@ func TestControlSessionDialerDialV2(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wireConn := wire.NewConn(serverRaw)
|
wireConn := wire.NewConn(serverRaw)
|
||||||
|
clientHelloFrame, err := wireConn.ReadFrame()
|
||||||
|
if err != nil {
|
||||||
|
serverErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if clientHelloFrame.Type != wire.FrameTypeClientHello {
|
||||||
|
serverErrCh <- fmt.Errorf("unexpected frame type %d, want %d", clientHelloFrame.Type, wire.FrameTypeClientHello)
|
||||||
|
return
|
||||||
|
}
|
||||||
var hello wire.ClientHello
|
var hello wire.ClientHello
|
||||||
if err := wireConn.ReadJSONFrame(wire.FrameTypeClientHello, &hello); err != nil {
|
if err := wireConn.UnmarshalFrame(clientHelloFrame, &hello); err != nil {
|
||||||
serverErrCh <- err
|
serverErrCh <- err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -160,11 +170,52 @@ func TestControlSessionDialerDialV2(t *testing.T) {
|
|||||||
serverErrCh <- fmt.Errorf("unexpected user: %s", loginMsg.User)
|
serverErrCh <- fmt.Errorf("unexpected user: %s", loginMsg.User)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := wireConn.WriteJSONFrame(wire.FrameTypeServerHello, wire.DefaultServerHello()); err != nil {
|
serverHello, err := wire.NewServerHello(hello)
|
||||||
|
if err != nil {
|
||||||
serverErrCh <- err
|
serverErrCh <- err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
serverErrCh <- rw.WriteMsg(&msg.LoginResp{RunID: "run-v2"})
|
serverHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeServerHello, serverHello)
|
||||||
|
if err != nil {
|
||||||
|
serverErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cryptoContext := wire.NewCryptoContext(
|
||||||
|
serverHello.Selected.Crypto.Algorithm,
|
||||||
|
clientHelloFrame.Payload,
|
||||||
|
serverHelloFrame.Payload,
|
||||||
|
)
|
||||||
|
if err := wireConn.WriteFrame(serverHelloFrame); err != nil {
|
||||||
|
serverErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := rw.WriteMsg(&msg.LoginResp{RunID: "run-v2"}); err != nil {
|
||||||
|
serverErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controlRW, err := netpkg.NewAEADCryptoReadWriter(
|
||||||
|
serverRaw,
|
||||||
|
[]byte("token"),
|
||||||
|
netpkg.AEADCryptoRoleServer,
|
||||||
|
cryptoContext.Algorithm,
|
||||||
|
cryptoContext.TranscriptHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controlMsgRW := msg.NewReadWriter(controlRW, wire.ProtocolV2)
|
||||||
|
var ping msg.Ping
|
||||||
|
if err := controlMsgRW.ReadMsgInto(&ping); err != nil {
|
||||||
|
serverErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ping.PrivilegeKey != "v2-ping" || ping.Timestamp != 12345 {
|
||||||
|
serverErrCh <- fmt.Errorf("unexpected ping: %+v", ping)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverErrCh <- nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
dialer := newTestControlSessionDialer(t, wire.ProtocolV2, connector, nil)
|
dialer := newTestControlSessionDialer(t, wire.ProtocolV2, connector, nil)
|
||||||
@@ -177,6 +228,7 @@ func TestControlSessionDialerDialV2(t *testing.T) {
|
|||||||
require.NotNil(t, sessionCtx.Conn)
|
require.NotNil(t, sessionCtx.Conn)
|
||||||
require.NotNil(t, sessionCtx.Connector)
|
require.NotNil(t, sessionCtx.Connector)
|
||||||
require.False(t, connector.closed.Load())
|
require.False(t, connector.closed.Load())
|
||||||
|
require.NoError(t, sessionCtx.Conn.WriteMsg(&msg.Ping{PrivilegeKey: "v2-ping", Timestamp: 12345}))
|
||||||
require.NoError(t, <-serverErrCh)
|
require.NoError(t, <-serverErrCh)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-3
@@ -33,7 +33,51 @@ git commit -m "bump version to vX.Y.Z"
|
|||||||
git push origin dev
|
git push origin dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Merge dev → master
|
## 3. Pre-release Validation
|
||||||
|
|
||||||
|
Run the standard e2e suite locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
For releases that touch compatibility-sensitive areas such as login, control
|
||||||
|
connections, work connections, visitors, transport, or wire protocol handling,
|
||||||
|
also run the manual compatibility e2e suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make e2e-compatibility
|
||||||
|
make e2e-compatibility-floor
|
||||||
|
```
|
||||||
|
|
||||||
|
`make e2e-compatibility` builds the current `frps` and `frpc`, resolves the
|
||||||
|
recent stable release baselines from GitHub, downloads or reuses their binaries,
|
||||||
|
and tests current binaries against those historical releases. The default number
|
||||||
|
of recent baselines is controlled by `FRP_COMPAT_BASELINE_COUNT` in the
|
||||||
|
`Makefile`.
|
||||||
|
|
||||||
|
Downloaded release binaries are cached under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.cache/e2e-compat/<version>/<os>_<arch>/
|
||||||
|
```
|
||||||
|
|
||||||
|
For a release validation run that must be exactly reproducible, pass an explicit
|
||||||
|
baseline matrix instead of using the floating recent-release list:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FRP_COMPAT_BASELINE_VERSIONS="0.X.0 0.Y.0" make e2e-compatibility
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `make e2e-compatibility-smoke` for a quick single-baseline check while
|
||||||
|
iterating locally. If GitHub release metadata requests are rate-limited, set
|
||||||
|
`GITHUB_TOKEN` or use `FRP_COMPAT_BASELINE_VERSIONS`.
|
||||||
|
|
||||||
|
The compatibility floor is a support-policy decision, not a value that should
|
||||||
|
change every release. Update `FRP_COMPAT_FLOOR_VERSION` only when the declared
|
||||||
|
compatibility window changes.
|
||||||
|
|
||||||
|
## 4. Merge dev → master
|
||||||
|
|
||||||
Create a PR from `dev` to `master`:
|
Create a PR from `dev` to `master`:
|
||||||
|
|
||||||
@@ -43,7 +87,7 @@ gh pr create --base master --head dev --title "bump version"
|
|||||||
|
|
||||||
Wait for CI to pass, then merge using **merge commit** (not squash).
|
Wait for CI to pass, then merge using **merge commit** (not squash).
|
||||||
|
|
||||||
## 4. Tag the Release
|
## 5. Tag the Release
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout master
|
git checkout master
|
||||||
@@ -52,7 +96,7 @@ git tag -a vX.Y.Z -m "bump version"
|
|||||||
git push origin vX.Y.Z
|
git push origin vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. Trigger GoReleaser
|
## 6. Trigger GoReleaser
|
||||||
|
|
||||||
Manually trigger the `goreleaser` workflow in GitHub Actions:
|
Manually trigger the `goreleaser` workflow in GitHub Actions:
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/coreos/go-oidc/v3 v3.14.1
|
||||||
github.com/fatedier/golib v0.6.0
|
github.com/fatedier/golib v0.7.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
@@ -30,11 +30,13 @@ require (
|
|||||||
golang.org/x/net v0.52.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/oauth2 v0.28.0
|
golang.org/x/oauth2 v0.28.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.20.0
|
||||||
|
golang.org/x/sys v0.42.0
|
||||||
golang.org/x/time v0.10.0
|
golang.org/x/time v0.10.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
k8s.io/apimachinery v0.28.8
|
k8s.io/apimachinery v0.28.8
|
||||||
k8s.io/client-go v0.28.8
|
k8s.io/client-go v0.28.8
|
||||||
|
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -68,14 +70,12 @@ require (
|
|||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect
|
|
||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.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.6.0 h1:/mgBZZbkbMhIEZoXf7nV8knpUDzas/b+2ruYKxx1lww=
|
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
||||||
github.com/fatedier/golib v0.6.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
|
|||||||
@@ -14,11 +14,7 @@
|
|||||||
|
|
||||||
package source
|
package source
|
||||||
|
|
||||||
import (
|
import v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConfigSource implements Source for in-memory configuration.
|
// ConfigSource implements Source for in-memory configuration.
|
||||||
// All operations are thread-safe.
|
// All operations are thread-safe.
|
||||||
@@ -39,23 +35,17 @@ func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.Vi
|
|||||||
|
|
||||||
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
|
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
|
||||||
for _, p := range proxies {
|
for _, p := range proxies {
|
||||||
if p == nil {
|
name, err := validateProxyName(p)
|
||||||
return fmt.Errorf("proxy cannot be nil")
|
if err != nil {
|
||||||
}
|
return err
|
||||||
name := p.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
}
|
||||||
nextProxies[name] = p
|
nextProxies[name] = p
|
||||||
}
|
}
|
||||||
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
|
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
|
||||||
for _, v := range visitors {
|
for _, v := range visitors {
|
||||||
if v == nil {
|
name, err := validateVisitorName(v)
|
||||||
return fmt.Errorf("visitor cannot be nil")
|
if err != nil {
|
||||||
}
|
return err
|
||||||
name := v.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
}
|
||||||
nextVisitors[name] = v
|
nextVisitors[name] = v
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-119
@@ -43,6 +43,11 @@ var (
|
|||||||
ErrNotFound = errors.New("not found")
|
ErrNotFound = errors.New("not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
storeKindProxy = "proxy"
|
||||||
|
storeKindVisitor = "visitor"
|
||||||
|
)
|
||||||
|
|
||||||
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
|
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
|
||||||
if cfg.Path == "" {
|
if cfg.Path == "" {
|
||||||
return nil, fmt.Errorf("path is required")
|
return nil, fmt.Errorf("path is required")
|
||||||
@@ -172,79 +177,111 @@ func (s *StoreSource) saveToFileUnlocked() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
|
func (s *StoreSource) persistOrRollbackUnlocked(rollback func()) error {
|
||||||
if proxy == nil {
|
if err := s.saveToFileUnlocked(); err != nil {
|
||||||
return fmt.Errorf("proxy cannot be nil")
|
rollback()
|
||||||
|
return fmt.Errorf("failed to persist: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
name := proxy.GetBaseConfig().Name
|
// Store map selectors return the target map for generic helpers.
|
||||||
|
func proxyStoreEntries(s *StoreSource) map[string]v1.ProxyConfigurer {
|
||||||
|
return s.proxies
|
||||||
|
}
|
||||||
|
|
||||||
|
func visitorStoreEntries(s *StoreSource) map[string]v1.VisitorConfigurer {
|
||||||
|
return s.visitors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store entry helpers share mutation, persistence, and rollback for proxy and visitor maps.
|
||||||
|
// T is intentionally limited by callers to v1.ProxyConfigurer or v1.VisitorConfigurer.
|
||||||
|
func addStoreEntry[T any](
|
||||||
|
s *StoreSource,
|
||||||
|
entriesFn func(*StoreSource) map[string]T,
|
||||||
|
kind string,
|
||||||
|
name string,
|
||||||
|
value T,
|
||||||
|
) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
entries := entriesFn(s)
|
||||||
|
if _, exists := entries[name]; exists {
|
||||||
|
return fmt.Errorf("%w: %s %q", ErrAlreadyExists, kind, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries[name] = value
|
||||||
|
return s.persistOrRollbackUnlocked(func() {
|
||||||
|
delete(entries, name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStoreEntry[T any](
|
||||||
|
s *StoreSource,
|
||||||
|
entriesFn func(*StoreSource) map[string]T,
|
||||||
|
kind string,
|
||||||
|
name string,
|
||||||
|
value T,
|
||||||
|
) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
entries := entriesFn(s)
|
||||||
|
old, exists := entries[name]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("%w: %s %q", ErrNotFound, kind, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries[name] = value
|
||||||
|
return s.persistOrRollbackUnlocked(func() {
|
||||||
|
entries[name] = old
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeStoreEntry[T any](
|
||||||
|
s *StoreSource,
|
||||||
|
entriesFn func(*StoreSource) map[string]T,
|
||||||
|
kind string,
|
||||||
|
name string,
|
||||||
|
) error {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
return fmt.Errorf("%s name cannot be empty", kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := s.proxies[name]; exists {
|
entries := entriesFn(s)
|
||||||
return fmt.Errorf("%w: proxy %q", ErrAlreadyExists, name)
|
old, exists := entries[name]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("%w: %s %q", ErrNotFound, kind, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.proxies[name] = proxy
|
delete(entries, name)
|
||||||
|
return s.persistOrRollbackUnlocked(func() {
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
entries[name] = old
|
||||||
delete(s.proxies, name)
|
})
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
|
||||||
|
name, err := validateProxyName(proxy)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return addStoreEntry(s, proxyStoreEntries, storeKindProxy, name, proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
|
func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
|
||||||
if proxy == nil {
|
name, err := validateProxyName(proxy)
|
||||||
return fmt.Errorf("proxy cannot be nil")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return updateStoreEntry(s, proxyStoreEntries, storeKindProxy, name, proxy)
|
||||||
name := proxy.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldProxy, exists := s.proxies[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.proxies[name] = proxy
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.proxies[name] = oldProxy
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) RemoveProxy(name string) error {
|
func (s *StoreSource) RemoveProxy(name string) error {
|
||||||
if name == "" {
|
return removeStoreEntry(s, proxyStoreEntries, storeKindProxy, name)
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldProxy, exists := s.proxies[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(s.proxies, name)
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.proxies[name] = oldProxy
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
|
func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
|
||||||
@@ -259,78 +296,23 @@ func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
|
func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
|
||||||
if visitor == nil {
|
name, err := validateVisitorName(visitor)
|
||||||
return fmt.Errorf("visitor cannot be nil")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return addStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
|
||||||
name := visitor.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := s.visitors[name]; exists {
|
|
||||||
return fmt.Errorf("%w: visitor %q", ErrAlreadyExists, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.visitors[name] = visitor
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
delete(s.visitors, name)
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
|
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
|
||||||
if visitor == nil {
|
name, err := validateVisitorName(visitor)
|
||||||
return fmt.Errorf("visitor cannot be nil")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return updateStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
|
||||||
name := visitor.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldVisitor, exists := s.visitors[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.visitors[name] = visitor
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.visitors[name] = oldVisitor
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) RemoveVisitor(name string) error {
|
func (s *StoreSource) RemoveVisitor(name string) error {
|
||||||
if name == "" {
|
return removeStoreEntry(s, visitorStoreEntries, storeKindVisitor, name)
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldVisitor, exists := s.visitors[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(s.visitors, name)
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.visitors[name] = oldVisitor
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
|
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ package source
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -59,6 +60,101 @@ func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T
|
|||||||
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
|
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStoreSource_UpdateAndRemoveProxyAndVisitor(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
storeSource := newTestStoreSource(t)
|
||||||
|
|
||||||
|
proxyCfg := mockProxy("proxy1")
|
||||||
|
visitorCfg := mockVisitor("visitor1")
|
||||||
|
|
||||||
|
require.NoError(storeSource.AddProxy(proxyCfg))
|
||||||
|
require.NoError(storeSource.AddVisitor(visitorCfg))
|
||||||
|
require.ErrorIs(storeSource.AddProxy(proxyCfg), ErrAlreadyExists)
|
||||||
|
require.ErrorIs(storeSource.AddVisitor(visitorCfg), ErrAlreadyExists)
|
||||||
|
require.ErrorContains(storeSource.RemoveProxy(""), "proxy name cannot be empty")
|
||||||
|
require.ErrorContains(storeSource.RemoveVisitor(""), "visitor name cannot be empty")
|
||||||
|
|
||||||
|
updatedProxy := mockProxy("proxy1").(*v1.TCPProxyConfig)
|
||||||
|
updatedProxy.RemotePort = 19090
|
||||||
|
require.NoError(storeSource.UpdateProxy(updatedProxy))
|
||||||
|
require.Equal(19090, storeSource.GetProxy("proxy1").(*v1.TCPProxyConfig).RemotePort)
|
||||||
|
|
||||||
|
updatedVisitor := mockVisitor("visitor1").(*v1.STCPVisitorConfig)
|
||||||
|
updatedVisitor.ServerName = "updated-server"
|
||||||
|
require.NoError(storeSource.UpdateVisitor(updatedVisitor))
|
||||||
|
require.Equal("updated-server", storeSource.GetVisitor("visitor1").(*v1.STCPVisitorConfig).ServerName)
|
||||||
|
|
||||||
|
require.NoError(storeSource.RemoveProxy("proxy1"))
|
||||||
|
require.Nil(storeSource.GetProxy("proxy1"))
|
||||||
|
require.ErrorIs(storeSource.RemoveProxy("proxy1"), ErrNotFound)
|
||||||
|
|
||||||
|
require.NoError(storeSource.RemoveVisitor("visitor1"))
|
||||||
|
require.Nil(storeSource.GetVisitor("visitor1"))
|
||||||
|
require.ErrorIs(storeSource.RemoveVisitor("visitor1"), ErrNotFound)
|
||||||
|
|
||||||
|
require.ErrorIs(storeSource.UpdateProxy(updatedProxy), ErrNotFound)
|
||||||
|
require.ErrorIs(storeSource.UpdateVisitor(updatedVisitor), ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreSource_MutationRollsBackOnPersistFailure(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("chmod does not make directories unwritable on Windows")
|
||||||
|
}
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("chmod does not block writes for uid 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "store.json")
|
||||||
|
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
proxyCfg := mockProxy("proxy1")
|
||||||
|
visitorCfg := mockVisitor("visitor1")
|
||||||
|
originalRemotePort := proxyCfg.(*v1.TCPProxyConfig).RemotePort
|
||||||
|
originalServerName := visitorCfg.(*v1.STCPVisitorConfig).ServerName
|
||||||
|
require.NoError(storeSource.AddProxy(proxyCfg))
|
||||||
|
require.NoError(storeSource.AddVisitor(visitorCfg))
|
||||||
|
|
||||||
|
require.NoError(os.Chmod(dir, 0o500))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chmod(dir, 0o700)
|
||||||
|
})
|
||||||
|
|
||||||
|
requirePersistError := func(err error) {
|
||||||
|
t.Helper()
|
||||||
|
require.Error(err)
|
||||||
|
require.ErrorContains(err, "failed to persist")
|
||||||
|
require.NotErrorIs(err, ErrAlreadyExists)
|
||||||
|
require.NotErrorIs(err, ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
requirePersistError(storeSource.AddProxy(mockProxy("proxy2")))
|
||||||
|
require.Nil(storeSource.GetProxy("proxy2"))
|
||||||
|
|
||||||
|
updatedProxy := mockProxy("proxy1").(*v1.TCPProxyConfig)
|
||||||
|
updatedProxy.RemotePort = 19090
|
||||||
|
requirePersistError(storeSource.UpdateProxy(updatedProxy))
|
||||||
|
require.Equal(originalRemotePort, storeSource.GetProxy("proxy1").(*v1.TCPProxyConfig).RemotePort)
|
||||||
|
|
||||||
|
requirePersistError(storeSource.RemoveProxy("proxy1"))
|
||||||
|
require.NotNil(storeSource.GetProxy("proxy1"))
|
||||||
|
|
||||||
|
requirePersistError(storeSource.AddVisitor(mockVisitor("visitor2")))
|
||||||
|
require.Nil(storeSource.GetVisitor("visitor2"))
|
||||||
|
|
||||||
|
updatedVisitor := mockVisitor("visitor1").(*v1.STCPVisitorConfig)
|
||||||
|
updatedVisitor.ServerName = "updated-server"
|
||||||
|
requirePersistError(storeSource.UpdateVisitor(updatedVisitor))
|
||||||
|
require.Equal(originalServerName, storeSource.GetVisitor("visitor1").(*v1.STCPVisitorConfig).ServerName)
|
||||||
|
|
||||||
|
requirePersistError(storeSource.RemoveVisitor("visitor1"))
|
||||||
|
require.NotNil(storeSource.GetVisitor("visitor1"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
|
func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// 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 source
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateProxyName(proxy v1.ProxyConfigurer) (string, error) {
|
||||||
|
if proxy == nil {
|
||||||
|
return "", fmt.Errorf("proxy cannot be nil")
|
||||||
|
}
|
||||||
|
name := proxy.GetBaseConfig().Name
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("proxy name cannot be empty")
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateVisitorName(visitor v1.VisitorConfigurer) (string, error) {
|
||||||
|
if visitor == nil {
|
||||||
|
return "", fmt.Errorf("visitor cannot be nil")
|
||||||
|
}
|
||||||
|
name := visitor.GetBaseConfig().Name
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("visitor name cannot be empty")
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// 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"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v *ConfigValidator) validateAuthTokenSource(token string, tokenSource *v1.ValueSource) error {
|
||||||
|
var errs error
|
||||||
|
// Preserve the previous client/server validation order for joined errors.
|
||||||
|
if token != "" && tokenSource != nil {
|
||||||
|
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
|
||||||
|
}
|
||||||
|
if tokenSource == nil {
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenSource.Type == "exec" {
|
||||||
|
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
|
||||||
|
errs = AppendError(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tokenSource.Validate(); err != nil {
|
||||||
|
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// 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"
|
||||||
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenSourceConflictErr = "cannot specify both auth.token and auth.tokenSource"
|
||||||
|
tokenSourceExecErr = "unsafe feature \"TokenSourceExec\" is not enabled. To enable it, ensure it is allowed in the configuration or command line flags"
|
||||||
|
invalidFileSourceErr = "invalid auth.tokenSource: file configuration is required when type is 'file'"
|
||||||
|
unsupportedSourceErr = "invalid auth.tokenSource: unsupported value source type: env (only 'file' and 'exec' are supported)"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateAuthTokenSource(t *testing.T) {
|
||||||
|
for _, tc := range authTokenSourceTestCases() {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
|
||||||
|
err := validator.validateAuthTokenSource(tc.token, tc.tokenSource())
|
||||||
|
requireValidationErrors(t, err, tc.wantErrs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateClientAuthTokenSource(t *testing.T) {
|
||||||
|
for _, tc := range authTokenSourceTestCases() {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
auth := v1.AuthClientConfig{
|
||||||
|
Method: v1.AuthMethodToken,
|
||||||
|
Token: tc.token,
|
||||||
|
TokenSource: tc.tokenSource(),
|
||||||
|
}
|
||||||
|
validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
|
||||||
|
_, err := validator.ValidateClientCommonConfig(validClientConfigWithAuth(auth))
|
||||||
|
requireValidationErrors(t, err, tc.wantErrs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateServerAuthTokenSource(t *testing.T) {
|
||||||
|
for _, tc := range authTokenSourceTestCases() {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
auth := v1.AuthServerConfig{
|
||||||
|
Method: v1.AuthMethodToken,
|
||||||
|
Token: tc.token,
|
||||||
|
TokenSource: tc.tokenSource(),
|
||||||
|
}
|
||||||
|
validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
|
||||||
|
_, err := validator.ValidateServerConfig(validServerConfigWithAuth(auth))
|
||||||
|
requireValidationErrors(t, err, tc.wantErrs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type authTokenSourceTestCase struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
tokenSource func() *v1.ValueSource
|
||||||
|
unsafeAllowed bool
|
||||||
|
wantErrs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func authTokenSourceTestCases() []authTokenSourceTestCase {
|
||||||
|
return []authTokenSourceTestCase{
|
||||||
|
{
|
||||||
|
name: "empty token config",
|
||||||
|
tokenSource: nilTokenSource,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid file tokenSource",
|
||||||
|
tokenSource: validFileTokenSource,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal token without tokenSource",
|
||||||
|
token: "token",
|
||||||
|
tokenSource: nilTokenSource,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal token conflicts with file tokenSource",
|
||||||
|
token: "token",
|
||||||
|
tokenSource: validFileTokenSource,
|
||||||
|
wantErrs: []string{tokenSourceConflictErr},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exec tokenSource requires unsafe feature",
|
||||||
|
tokenSource: validExecTokenSource,
|
||||||
|
wantErrs: []string{tokenSourceExecErr},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exec tokenSource with unsafe feature allowed",
|
||||||
|
tokenSource: validExecTokenSource,
|
||||||
|
unsafeAllowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal token conflicts with exec tokenSource and unsafe feature disabled",
|
||||||
|
token: "token",
|
||||||
|
tokenSource: validExecTokenSource,
|
||||||
|
wantErrs: []string{
|
||||||
|
tokenSourceConflictErr,
|
||||||
|
tokenSourceExecErr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal token conflicts with exec tokenSource and unsafe feature allowed",
|
||||||
|
token: "token",
|
||||||
|
tokenSource: validExecTokenSource,
|
||||||
|
unsafeAllowed: true,
|
||||||
|
wantErrs: []string{tokenSourceConflictErr},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid file tokenSource is wrapped",
|
||||||
|
tokenSource: invalidFileTokenSource,
|
||||||
|
wantErrs: []string{invalidFileSourceErr},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported tokenSource type is wrapped",
|
||||||
|
tokenSource: unsupportedTokenSource,
|
||||||
|
wantErrs: []string{unsupportedSourceErr},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthTokenSourceValidator(unsafeAllowed bool) *ConfigValidator {
|
||||||
|
if !unsafeAllowed {
|
||||||
|
return NewConfigValidator(nil)
|
||||||
|
}
|
||||||
|
return NewConfigValidator(security.NewUnsafeFeatures([]string{security.TokenSourceExec}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireValidationErrors(t *testing.T, err error, wantErrs []string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(wantErrs) == 0 {
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Error(t, err)
|
||||||
|
// Client/server validators may wrap joined errors in another join layer; compare leaf errors.
|
||||||
|
gotErrs := unwrapValidationErrors(err)
|
||||||
|
require.Len(t, gotErrs, len(wantErrs))
|
||||||
|
for i, wantErr := range wantErrs {
|
||||||
|
require.EqualError(t, gotErrs[i], wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrapValidationErrors(err error) []error {
|
||||||
|
type joinedError interface {
|
||||||
|
Unwrap() []error
|
||||||
|
}
|
||||||
|
joined, ok := err.(joinedError)
|
||||||
|
if !ok {
|
||||||
|
return []error{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for _, err := range joined.Unwrap() {
|
||||||
|
errs = append(errs, unwrapValidationErrors(err)...)
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// nilTokenSource keeps the shared table shape uniform for cases without a tokenSource.
|
||||||
|
func nilTokenSource() *v1.ValueSource {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFileTokenSource() *v1.ValueSource {
|
||||||
|
return &v1.ValueSource{
|
||||||
|
Type: "file",
|
||||||
|
File: &v1.FileSource{Path: "token.txt"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validExecTokenSource() *v1.ValueSource {
|
||||||
|
return &v1.ValueSource{
|
||||||
|
Type: "exec",
|
||||||
|
Exec: &v1.ExecSource{Command: "print-token"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidFileTokenSource() *v1.ValueSource {
|
||||||
|
return &v1.ValueSource{
|
||||||
|
Type: "file",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsupportedTokenSource() *v1.ValueSource {
|
||||||
|
return &v1.ValueSource{Type: "env"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validClientConfigWithAuth(auth v1.AuthClientConfig) *v1.ClientCommonConfig {
|
||||||
|
return &v1.ClientCommonConfig{
|
||||||
|
Auth: auth,
|
||||||
|
Log: v1.LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
},
|
||||||
|
Transport: v1.ClientTransportConfig{
|
||||||
|
Protocol: "tcp",
|
||||||
|
WireProtocol: "v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validServerConfigWithAuth(auth v1.AuthServerConfig) *v1.ServerConfig {
|
||||||
|
return &v1.ServerConfig{
|
||||||
|
Auth: auth,
|
||||||
|
Log: v1.LogConfig{
|
||||||
|
Level: "info",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,22 +68,7 @@ func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, e
|
|||||||
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate token/tokenSource mutual exclusivity
|
errs = AppendError(errs, v.validateAuthTokenSource(c.Token, c.TokenSource))
|
||||||
if c.Token != "" && c.TokenSource != nil {
|
|
||||||
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate tokenSource if specified
|
|
||||||
if c.TokenSource != nil {
|
|
||||||
if c.TokenSource.Type == "exec" {
|
|
||||||
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
|
|
||||||
errs = AppendError(errs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := c.TokenSource.Validate(); err != nil {
|
|
||||||
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
|
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
|
||||||
errs = AppendError(errs, err)
|
errs = AppendError(errs, err)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/policy/security"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
|
func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
|
||||||
@@ -36,22 +35,7 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
|
|||||||
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate token/tokenSource mutual exclusivity
|
errs = AppendError(errs, v.validateAuthTokenSource(c.Auth.Token, c.Auth.TokenSource))
|
||||||
if c.Auth.Token != "" && c.Auth.TokenSource != nil {
|
|
||||||
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate tokenSource if specified
|
|
||||||
if c.Auth.TokenSource != nil {
|
|
||||||
if c.Auth.TokenSource.Type == "exec" {
|
|
||||||
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
|
|
||||||
errs = AppendError(errs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := c.Auth.TokenSource.Validate(); err != nil {
|
|
||||||
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateLogConfig(&c.Log); err != nil {
|
if err := validateLogConfig(&c.Log); err != nil {
|
||||||
errs = AppendError(errs, err)
|
errs = AppendError(errs, err)
|
||||||
|
|||||||
+30
-10
@@ -18,6 +18,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/utils/clock"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
"github.com/fatedier/frp/pkg/util/metric"
|
"github.com/fatedier/frp/pkg/util/metric"
|
||||||
server "github.com/fatedier/frp/server/metrics"
|
server "github.com/fatedier/frp/server/metrics"
|
||||||
@@ -38,11 +40,20 @@ func init() {
|
|||||||
|
|
||||||
type serverMetrics struct {
|
type serverMetrics struct {
|
||||||
info *ServerStatistics
|
info *ServerStatistics
|
||||||
|
clock clock.WithTicker
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServerMetrics() *serverMetrics {
|
func newServerMetrics() *serverMetrics {
|
||||||
|
return newServerMetricsWithClock(clock.RealClock{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServerMetricsWithClock(clk clock.WithTicker) *serverMetrics {
|
||||||
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
return &serverMetrics{
|
return &serverMetrics{
|
||||||
|
clock: clk,
|
||||||
info: &ServerStatistics{
|
info: &ServerStatistics{
|
||||||
TotalTrafficIn: metric.NewDateCounter(ReserveDays),
|
TotalTrafficIn: metric.NewDateCounter(ReserveDays),
|
||||||
TotalTrafficOut: metric.NewDateCounter(ReserveDays),
|
TotalTrafficOut: metric.NewDateCounter(ReserveDays),
|
||||||
@@ -57,14 +68,23 @@ func newServerMetrics() *serverMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) run() {
|
func (m *serverMetrics) run() {
|
||||||
go func() {
|
go m.runUntil(nil)
|
||||||
for {
|
}
|
||||||
time.Sleep(12 * time.Hour)
|
|
||||||
start := time.Now()
|
func (m *serverMetrics) runUntil(stopCh <-chan struct{}) {
|
||||||
count, total := m.clearUselessInfo(time.Duration(7*24) * time.Hour)
|
ticker := m.clock.NewTicker(12 * time.Hour)
|
||||||
log.Debugf("clear useless proxy statistics data count %d/%d, cost %v", count, total, time.Since(start))
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C():
|
||||||
|
start := m.clock.Now()
|
||||||
|
count, total := m.clearUselessInfo(time.Duration(7*24) * time.Hour)
|
||||||
|
log.Debugf("clear useless proxy statistics data count %d/%d, cost %v", count, total, m.clock.Since(start))
|
||||||
|
case <-stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration) (int, int) {
|
func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration) (int, int) {
|
||||||
@@ -77,7 +97,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
|||||||
for name, data := range m.info.ProxyStatistics {
|
for name, data := range m.info.ProxyStatistics {
|
||||||
if !data.LastCloseTime.IsZero() &&
|
if !data.LastCloseTime.IsZero() &&
|
||||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||||
time.Since(data.LastCloseTime) > continuousOfflineDuration {
|
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
||||||
delete(m.info.ProxyStatistics, name)
|
delete(m.info.ProxyStatistics, name)
|
||||||
count++
|
count++
|
||||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||||
@@ -121,7 +141,7 @@ func (m *serverMetrics) NewProxy(name string, proxyType string, user string, cli
|
|||||||
}
|
}
|
||||||
proxyStats.User = user
|
proxyStats.User = user
|
||||||
proxyStats.ClientID = clientID
|
proxyStats.ClientID = clientID
|
||||||
proxyStats.LastStartTime = time.Now()
|
proxyStats.LastStartTime = m.clock.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) CloseProxy(name string, proxyType string) {
|
func (m *serverMetrics) CloseProxy(name string, proxyType string) {
|
||||||
@@ -131,7 +151,7 @@ func (m *serverMetrics) CloseProxy(name string, proxyType string) {
|
|||||||
counter.Dec(1)
|
counter.Dec(1)
|
||||||
}
|
}
|
||||||
if proxyStats, ok := m.info.ProxyStatistics[name]; ok {
|
if proxyStats, ok := m.info.ProxyStatistics[name]; ok {
|
||||||
proxyStats.LastCloseTime = time.Now()
|
proxyStats.LastCloseTime = m.clock.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package mem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
clocktesting "k8s.io/utils/clock/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start)
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
|
||||||
|
metrics.NewProxy("proxy", "tcp", "user", "client-id")
|
||||||
|
require.Equal(start, metrics.info.ProxyStatistics["proxy"].LastStartTime)
|
||||||
|
|
||||||
|
closedAt := start.Add(time.Minute)
|
||||||
|
clk.SetTime(closedAt)
|
||||||
|
metrics.CloseProxy("proxy", "tcp")
|
||||||
|
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start.Add(25 * time.Hour))
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
|
||||||
|
Name: "proxy",
|
||||||
|
LastStartTime: start.Add(-time.Hour),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
|
||||||
|
count, total := metrics.clearUselessInfo(24 * time.Hour)
|
||||||
|
|
||||||
|
require.Equal(1, count)
|
||||||
|
require.Equal(1, total)
|
||||||
|
require.Empty(metrics.info.ProxyStatistics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start)
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
|
||||||
|
Name: "proxy",
|
||||||
|
LastStartTime: start.Add(-time.Hour),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCh := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
metrics.runUntil(stopCh)
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(stopCh)
|
||||||
|
<-done
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Eventually(clk.HasWaiters, time.Second, time.Millisecond)
|
||||||
|
clk.Step(8 * 24 * time.Hour)
|
||||||
|
|
||||||
|
require.Eventually(func() bool {
|
||||||
|
return !metrics.hasProxyStatistics("proxy")
|
||||||
|
}, time.Second, time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *serverMetrics) hasProxyStatistics(name string) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
_, ok := m.info.ProxyStatistics[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -112,7 +112,6 @@ type Dispatcher struct {
|
|||||||
sendCh chan Message
|
sendCh chan Message
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
msgHandlers map[reflect.Type]func(Message)
|
msgHandlers map[reflect.Type]func(Message)
|
||||||
defaultHandler func(Message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDispatcher(rw ReadWriter) *Dispatcher {
|
func NewDispatcher(rw ReadWriter) *Dispatcher {
|
||||||
@@ -151,8 +150,6 @@ func (d *Dispatcher) readLoop() {
|
|||||||
|
|
||||||
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
|
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
|
||||||
handler(m)
|
handler(m)
|
||||||
} else if d.defaultHandler != nil {
|
|
||||||
d.defaultHandler(m)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,10 +167,6 @@ func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {
|
|||||||
d.msgHandlers[reflect.TypeOf(msg)] = handler
|
d.msgHandlers[reflect.TypeOf(msg)] = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) RegisterDefaultHandler(handler func(Message)) {
|
|
||||||
d.defaultHandler = handler
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Dispatcher) Done() chan struct{} {
|
func (d *Dispatcher) Done() chan struct{} {
|
||||||
return d.doneCh
|
return d.doneCh
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-23
@@ -21,6 +21,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
"k8s.io/utils/clock"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -144,19 +145,19 @@ func getBehaviorByModeAndIndex(mode int, index int) (RecommandBehavior, Recomman
|
|||||||
return behaviors[index].A, behaviors[index].B
|
return behaviors[index].A, behaviors[index].B
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBehaviorScoresByMode(mode int, defaultScore int) []*BehaviorScore {
|
func getBehaviorScoresByMode(mode int, defaultScore int) []*behaviorScore {
|
||||||
return getBehaviorScoresByMode2(mode, defaultScore, defaultScore)
|
return getBehaviorScoresByMode2(mode, defaultScore, defaultScore)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*BehaviorScore {
|
func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*behaviorScore {
|
||||||
behaviors := getBehaviorByMode(mode)
|
behaviors := getBehaviorByMode(mode)
|
||||||
scores := make([]*BehaviorScore, 0, len(behaviors))
|
scores := make([]*behaviorScore, 0, len(behaviors))
|
||||||
for i := range behaviors {
|
for i := range behaviors {
|
||||||
score := receiverScore
|
score := receiverScore
|
||||||
if behaviors[i].A.Role == DetectRoleSender {
|
if behaviors[i].A.Role == DetectRoleSender {
|
||||||
score = senderScore
|
score = senderScore
|
||||||
}
|
}
|
||||||
scores = append(scores, &BehaviorScore{Mode: mode, Index: i, Score: score})
|
scores = append(scores, &behaviorScore{Mode: mode, Index: i, Score: score})
|
||||||
}
|
}
|
||||||
return scores
|
return scores
|
||||||
}
|
}
|
||||||
@@ -170,14 +171,18 @@ type RecommandBehavior struct {
|
|||||||
ListenRandomPorts int
|
ListenRandomPorts int
|
||||||
}
|
}
|
||||||
|
|
||||||
type MakeHoleRecords struct {
|
type makeHoleRecords struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
scores []*BehaviorScore
|
scores []*behaviorScore
|
||||||
LastUpdateTime time.Time
|
clock clock.PassiveClock
|
||||||
|
lastUpdateTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {
|
func newMakeHoleRecordsWithClock(c, v *NatFeature, clk clock.PassiveClock) *makeHoleRecords {
|
||||||
scores := []*BehaviorScore{}
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
|
scores := []*behaviorScore{}
|
||||||
easyCount, hardCount, portsChangedRegularCount := ClassifyFeatureCount([]*NatFeature{c, v})
|
easyCount, hardCount, portsChangedRegularCount := ClassifyFeatureCount([]*NatFeature{c, v})
|
||||||
appendMode0 := func() {
|
appendMode0 := func() {
|
||||||
switch {
|
switch {
|
||||||
@@ -212,13 +217,17 @@ func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {
|
|||||||
scores = append(scores, getBehaviorScoresByMode(DetectMode1, 1)...)
|
scores = append(scores, getBehaviorScoresByMode(DetectMode1, 1)...)
|
||||||
scores = append(scores, getBehaviorScoresByMode(DetectMode3, 1)...)
|
scores = append(scores, getBehaviorScoresByMode(DetectMode3, 1)...)
|
||||||
}
|
}
|
||||||
return &MakeHoleRecords{scores: scores, LastUpdateTime: time.Now()}
|
return &makeHoleRecords{
|
||||||
|
scores: scores,
|
||||||
|
clock: clk,
|
||||||
|
lastUpdateTime: clk.Now(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mhr *MakeHoleRecords) ReportSuccess(mode int, index int) {
|
func (mhr *makeHoleRecords) reportSuccess(mode int, index int) {
|
||||||
mhr.mu.Lock()
|
mhr.mu.Lock()
|
||||||
defer mhr.mu.Unlock()
|
defer mhr.mu.Unlock()
|
||||||
mhr.LastUpdateTime = time.Now()
|
mhr.lastUpdateTime = mhr.clock.Now()
|
||||||
for i := range mhr.scores {
|
for i := range mhr.scores {
|
||||||
score := mhr.scores[i]
|
score := mhr.scores[i]
|
||||||
if score.Mode != mode || score.Index != index {
|
if score.Mode != mode || score.Index != index {
|
||||||
@@ -231,22 +240,22 @@ func (mhr *MakeHoleRecords) ReportSuccess(mode int, index int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mhr *MakeHoleRecords) Recommand() (mode, index int) {
|
func (mhr *makeHoleRecords) recommand() (mode, index int) {
|
||||||
mhr.mu.Lock()
|
mhr.mu.Lock()
|
||||||
defer mhr.mu.Unlock()
|
defer mhr.mu.Unlock()
|
||||||
|
|
||||||
if len(mhr.scores) == 0 {
|
if len(mhr.scores) == 0 {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
maxScore := slices.MaxFunc(mhr.scores, func(a, b *BehaviorScore) int {
|
maxScore := slices.MaxFunc(mhr.scores, func(a, b *behaviorScore) int {
|
||||||
return cmp.Compare(a.Score, b.Score)
|
return cmp.Compare(a.Score, b.Score)
|
||||||
})
|
})
|
||||||
maxScore.Score--
|
maxScore.Score--
|
||||||
mhr.LastUpdateTime = time.Now()
|
mhr.lastUpdateTime = mhr.clock.Now()
|
||||||
return maxScore.Mode, maxScore.Index
|
return maxScore.Mode, maxScore.Index
|
||||||
}
|
}
|
||||||
|
|
||||||
type BehaviorScore struct {
|
type behaviorScore struct {
|
||||||
Mode int
|
Mode int
|
||||||
Index int
|
Index int
|
||||||
// between -10 and 10
|
// between -10 and 10
|
||||||
@@ -255,16 +264,25 @@ type BehaviorScore struct {
|
|||||||
|
|
||||||
type Analyzer struct {
|
type Analyzer struct {
|
||||||
// key is client ip + visitor ip
|
// key is client ip + visitor ip
|
||||||
records map[string]*MakeHoleRecords
|
records map[string]*makeHoleRecords
|
||||||
dataReserveDuration time.Duration
|
dataReserveDuration time.Duration
|
||||||
|
clock clock.PassiveClock
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAnalyzer(dataReserveDuration time.Duration) *Analyzer {
|
func NewAnalyzer(dataReserveDuration time.Duration) *Analyzer {
|
||||||
|
return newAnalyzerWithClock(dataReserveDuration, clock.RealClock{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAnalyzerWithClock(dataReserveDuration time.Duration, clk clock.PassiveClock) *Analyzer {
|
||||||
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
return &Analyzer{
|
return &Analyzer{
|
||||||
records: make(map[string]*MakeHoleRecords),
|
records: make(map[string]*makeHoleRecords),
|
||||||
dataReserveDuration: dataReserveDuration,
|
dataReserveDuration: dataReserveDuration,
|
||||||
|
clock: clk,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,12 +290,12 @@ func (a *Analyzer) GetRecommandBehaviors(key string, c, v *NatFeature) (mode, in
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
records, ok := a.records[key]
|
records, ok := a.records[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
records = NewMakeHoleRecords(c, v)
|
records = newMakeHoleRecordsWithClock(c, v, a.clock)
|
||||||
a.records[key] = records
|
a.records[key] = records
|
||||||
}
|
}
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
mode, index = records.Recommand()
|
mode, index = records.recommand()
|
||||||
cBehavior, vBehavior := getBehaviorByModeAndIndex(mode, index)
|
cBehavior, vBehavior := getBehaviorByModeAndIndex(mode, index)
|
||||||
|
|
||||||
switch mode {
|
switch mode {
|
||||||
@@ -307,11 +325,11 @@ func (a *Analyzer) ReportSuccess(key string, mode, index int) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
records.ReportSuccess(mode, index)
|
records.reportSuccess(mode, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Analyzer) Clean() (int, int) {
|
func (a *Analyzer) Clean() (int, int) {
|
||||||
now := time.Now()
|
now := a.clock.Now()
|
||||||
total := 0
|
total := 0
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
@@ -321,7 +339,7 @@ func (a *Analyzer) Clean() (int, int) {
|
|||||||
total = len(a.records)
|
total = len(a.records)
|
||||||
// clean up records that have not been used for a period of time.
|
// clean up records that have not been used for a period of time.
|
||||||
for key, records := range a.records {
|
for key, records := range a.records {
|
||||||
if now.Sub(records.LastUpdateTime) > a.dataReserveDuration {
|
if now.Sub(records.lastUpdateTime) > a.dataReserveDuration {
|
||||||
delete(a.records, key)
|
delete(a.records, key)
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package nathole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
clocktesting "k8s.io/utils/clock/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnalyzerUsesClockForRecordTimestamps(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start)
|
||||||
|
analyzer := newAnalyzerWithClock(time.Hour, clk)
|
||||||
|
clientFeature := &NatFeature{NatType: EasyNAT, Behavior: BehaviorNoChange}
|
||||||
|
visitorFeature := &NatFeature{NatType: EasyNAT, Behavior: BehaviorNoChange}
|
||||||
|
|
||||||
|
mode, index, _, _ := analyzer.GetRecommandBehaviors("key", clientFeature, visitorFeature)
|
||||||
|
require.Equal(start, analyzer.records["key"].lastUpdateTime)
|
||||||
|
|
||||||
|
updatedAt := start.Add(time.Minute)
|
||||||
|
clk.SetTime(updatedAt)
|
||||||
|
analyzer.ReportSuccess("key", mode, index)
|
||||||
|
require.Equal(updatedAt, analyzer.records["key"].lastUpdateTime)
|
||||||
|
|
||||||
|
clk.SetTime(start.Add(2 * time.Hour))
|
||||||
|
count, total := analyzer.Clean()
|
||||||
|
require.Equal(1, count)
|
||||||
|
require.Equal(1, total)
|
||||||
|
require.Empty(analyzer.records)
|
||||||
|
}
|
||||||
+42
-34
@@ -326,40 +326,16 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol := vm.Protocol
|
protocol := vm.Protocol
|
||||||
vResp := &msg.NatHoleResp{
|
vResp := newNatHoleResponse(
|
||||||
TransactionID: vm.TransactionID,
|
vm.TransactionID, session.sid, protocol, mode,
|
||||||
Sid: session.sid,
|
cm.MappedAddrs, cm.AssistedAddrs, vBehavior,
|
||||||
Protocol: protocol,
|
timeoutMs-vBehavior.SendDelayMs, cNatFeature.PortsDifference,
|
||||||
CandidateAddrs: slices.Compact(cm.MappedAddrs),
|
)
|
||||||
AssistedAddrs: slices.Compact(cm.AssistedAddrs),
|
cResp := newNatHoleResponse(
|
||||||
DetectBehavior: msg.NatHoleDetectBehavior{
|
cm.TransactionID, session.sid, protocol, mode,
|
||||||
Mode: mode,
|
vm.MappedAddrs, vm.AssistedAddrs, cBehavior,
|
||||||
Role: vBehavior.Role,
|
timeoutMs-cBehavior.SendDelayMs, vNatFeature.PortsDifference,
|
||||||
TTL: vBehavior.TTL,
|
)
|
||||||
SendDelayMs: vBehavior.SendDelayMs,
|
|
||||||
ReadTimeoutMs: timeoutMs - vBehavior.SendDelayMs,
|
|
||||||
SendRandomPorts: vBehavior.PortsRandomNumber,
|
|
||||||
ListenRandomPorts: vBehavior.ListenRandomPorts,
|
|
||||||
CandidatePorts: getRangePorts(cm.MappedAddrs, cNatFeature.PortsDifference, vBehavior.PortsRangeNumber),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cResp := &msg.NatHoleResp{
|
|
||||||
TransactionID: cm.TransactionID,
|
|
||||||
Sid: session.sid,
|
|
||||||
Protocol: protocol,
|
|
||||||
CandidateAddrs: slices.Compact(vm.MappedAddrs),
|
|
||||||
AssistedAddrs: slices.Compact(vm.AssistedAddrs),
|
|
||||||
DetectBehavior: msg.NatHoleDetectBehavior{
|
|
||||||
Mode: mode,
|
|
||||||
Role: cBehavior.Role,
|
|
||||||
TTL: cBehavior.TTL,
|
|
||||||
SendDelayMs: cBehavior.SendDelayMs,
|
|
||||||
ReadTimeoutMs: timeoutMs - cBehavior.SendDelayMs,
|
|
||||||
SendRandomPorts: cBehavior.PortsRandomNumber,
|
|
||||||
ListenRandomPorts: cBehavior.ListenRandomPorts,
|
|
||||||
CandidatePorts: getRangePorts(vm.MappedAddrs, vNatFeature.PortsDifference, cBehavior.PortsRangeNumber),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("sid [%s] visitor nat: %+v, candidateAddrs: %v; client nat: %+v, candidateAddrs: %v, protocol: %s",
|
log.Debugf("sid [%s] visitor nat: %+v, candidateAddrs: %v; client nat: %+v, candidateAddrs: %v, protocol: %s",
|
||||||
session.sid, *vNatFeature, vm.MappedAddrs, *cNatFeature, cm.MappedAddrs, protocol)
|
session.sid, *vNatFeature, vm.MappedAddrs, *cNatFeature, cm.MappedAddrs, protocol)
|
||||||
@@ -368,6 +344,38 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
|
|||||||
return vResp, cResp, nil
|
return vResp, cResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newNatHoleResponse(
|
||||||
|
transactionID string,
|
||||||
|
sid string,
|
||||||
|
protocol string,
|
||||||
|
mode int,
|
||||||
|
candidateAddrs []string,
|
||||||
|
assistedAddrs []string,
|
||||||
|
behavior RecommandBehavior,
|
||||||
|
readTimeoutMs int,
|
||||||
|
portsDifference int,
|
||||||
|
) *msg.NatHoleResp {
|
||||||
|
compactCandidateAddrs := slices.Compact(candidateAddrs)
|
||||||
|
compactAssistedAddrs := slices.Compact(assistedAddrs)
|
||||||
|
return &msg.NatHoleResp{
|
||||||
|
TransactionID: transactionID,
|
||||||
|
Sid: sid,
|
||||||
|
Protocol: protocol,
|
||||||
|
CandidateAddrs: compactCandidateAddrs,
|
||||||
|
AssistedAddrs: compactAssistedAddrs,
|
||||||
|
DetectBehavior: msg.NatHoleDetectBehavior{
|
||||||
|
Mode: mode,
|
||||||
|
Role: behavior.Role,
|
||||||
|
TTL: behavior.TTL,
|
||||||
|
SendDelayMs: behavior.SendDelayMs,
|
||||||
|
ReadTimeoutMs: readTimeoutMs,
|
||||||
|
SendRandomPorts: behavior.PortsRandomNumber,
|
||||||
|
ListenRandomPorts: behavior.ListenRandomPorts,
|
||||||
|
CandidatePorts: getRangePorts(candidateAddrs, portsDifference, behavior.PortsRangeNumber),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
||||||
if maxNumber <= 0 {
|
if maxNumber <= 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -17,17 +17,9 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
stdlog "log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fatedier/golib/pool"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -37,57 +29,28 @@ func init() {
|
|||||||
type HTTP2HTTPPlugin struct {
|
type HTTP2HTTPPlugin struct {
|
||||||
opts *v1.HTTP2HTTPPluginOptions
|
opts *v1.HTTP2HTTPPluginOptions
|
||||||
|
|
||||||
l *Listener
|
*httpBridgePlugin
|
||||||
s *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTP2HTTPPluginOptions)
|
opts := options.(*v1.HTTP2HTTPPluginOptions)
|
||||||
|
|
||||||
listener := NewProxyListener()
|
|
||||||
|
|
||||||
p := &HTTP2HTTPPlugin{
|
p := &HTTP2HTTPPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
l: listener,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rp := &httputil.ReverseProxy{
|
rp := newHTTPBridgeReverseProxy(
|
||||||
Rewrite: func(r *httputil.ProxyRequest) {
|
func(r *httputil.ProxyRequest) {
|
||||||
req := r.Out
|
req := r.Out
|
||||||
req.URL.Scheme = "http"
|
rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
|
||||||
req.URL.Host = p.opts.LocalAddr
|
|
||||||
if p.opts.HostHeaderRewrite != "" {
|
|
||||||
req.Host = p.opts.HostHeaderRewrite
|
|
||||||
}
|
|
||||||
for k, v := range p.opts.RequestHeaders.Set {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
BufferPool: pool.NewBuffer(32 * 1024),
|
nil,
|
||||||
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
|
)
|
||||||
}
|
p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: rp,
|
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.Serve(listener)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTP2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPPlugin) Name() string {
|
func (p *HTTP2HTTPPlugin) Name() string {
|
||||||
return v1.PluginHTTP2HTTP
|
return v1.PluginHTTP2HTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTP2HTTPPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,18 +17,11 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
stdlog "log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fatedier/golib/pool"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -38,65 +31,35 @@ func init() {
|
|||||||
type HTTP2HTTPSPlugin struct {
|
type HTTP2HTTPSPlugin struct {
|
||||||
opts *v1.HTTP2HTTPSPluginOptions
|
opts *v1.HTTP2HTTPSPluginOptions
|
||||||
|
|
||||||
l *Listener
|
*httpBridgePlugin
|
||||||
s *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTP2HTTPSPluginOptions)
|
opts := options.(*v1.HTTP2HTTPSPluginOptions)
|
||||||
|
|
||||||
listener := NewProxyListener()
|
|
||||||
|
|
||||||
p := &HTTP2HTTPSPlugin{
|
p := &HTTP2HTTPSPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
l: listener,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
rp := &httputil.ReverseProxy{
|
rp := newHTTPBridgeReverseProxy(
|
||||||
Rewrite: func(r *httputil.ProxyRequest) {
|
func(r *httputil.ProxyRequest) {
|
||||||
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
||||||
r.Out.Header["X-Forwarded-Host"] = r.In.Header["X-Forwarded-Host"]
|
r.Out.Header["X-Forwarded-Host"] = r.In.Header["X-Forwarded-Host"]
|
||||||
r.Out.Header["X-Forwarded-Proto"] = r.In.Header["X-Forwarded-Proto"]
|
r.Out.Header["X-Forwarded-Proto"] = r.In.Header["X-Forwarded-Proto"]
|
||||||
req := r.Out
|
req := r.Out
|
||||||
req.URL.Scheme = "https"
|
rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
|
||||||
req.URL.Host = p.opts.LocalAddr
|
|
||||||
if p.opts.HostHeaderRewrite != "" {
|
|
||||||
req.Host = p.opts.HostHeaderRewrite
|
|
||||||
}
|
|
||||||
for k, v := range p.opts.RequestHeaders.Set {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Transport: tr,
|
tr,
|
||||||
BufferPool: pool.NewBuffer(32 * 1024),
|
)
|
||||||
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
|
p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
|
||||||
}
|
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: rp,
|
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.Serve(listener)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTP2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPSPlugin) Name() string {
|
func (p *HTTP2HTTPSPlugin) Name() string {
|
||||||
return v1.PluginHTTP2HTTPS
|
return v1.PluginHTTP2HTTPS
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTP2HTTPSPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// 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 (
|
||||||
|
"context"
|
||||||
|
stdlog "log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatedier/golib/pool"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/plugin/client/internal/httpsserver"
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
const httpBridgeReadHeaderTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
func rewriteHTTPPluginRequest(
|
||||||
|
req *http.Request,
|
||||||
|
scheme string,
|
||||||
|
localAddr string,
|
||||||
|
hostHeaderRewrite string,
|
||||||
|
requestHeaders v1.HeaderOperations,
|
||||||
|
) {
|
||||||
|
req.URL.Scheme = scheme
|
||||||
|
req.URL.Host = localAddr
|
||||||
|
if hostHeaderRewrite != "" {
|
||||||
|
req.Host = hostHeaderRewrite
|
||||||
|
}
|
||||||
|
for k, v := range requestHeaders.Set {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpBridgePlugin struct {
|
||||||
|
l *Listener
|
||||||
|
s *http.Server
|
||||||
|
|
||||||
|
useSourceRemoteAddr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPBridgePluginServer(handler http.Handler, useSourceRemoteAddr bool) *httpBridgePlugin {
|
||||||
|
listener := NewProxyListener()
|
||||||
|
p := &httpBridgePlugin{
|
||||||
|
l: listener,
|
||||||
|
useSourceRemoteAddr: useSourceRemoteAddr,
|
||||||
|
}
|
||||||
|
p.s = &http.Server{
|
||||||
|
Handler: handler,
|
||||||
|
ReadHeaderTimeout: httpBridgeReadHeaderTimeout,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
_ = p.s.Serve(listener)
|
||||||
|
}()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPSBridgePluginServer(
|
||||||
|
handler http.Handler,
|
||||||
|
crtPath string,
|
||||||
|
keyPath string,
|
||||||
|
enableHTTP2 *bool,
|
||||||
|
useSourceRemoteAddr bool,
|
||||||
|
) (*httpBridgePlugin, error) {
|
||||||
|
listener := NewProxyListener()
|
||||||
|
server, err := httpsserver.New(handler, crtPath, keyPath, enableHTTP2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p := &httpBridgePlugin{
|
||||||
|
l: listener,
|
||||||
|
s: server,
|
||||||
|
useSourceRemoteAddr: useSourceRemoteAddr,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
_ = p.s.ServeTLS(listener, "", "")
|
||||||
|
}()
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPBridgeReverseProxy(
|
||||||
|
rewrite func(*httputil.ProxyRequest),
|
||||||
|
transport http.RoundTripper,
|
||||||
|
) *httputil.ReverseProxy {
|
||||||
|
rp := &httputil.ReverseProxy{
|
||||||
|
Rewrite: rewrite,
|
||||||
|
BufferPool: pool.NewBuffer(32 * 1024),
|
||||||
|
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
|
||||||
|
}
|
||||||
|
if transport != nil {
|
||||||
|
rp.Transport = transport
|
||||||
|
}
|
||||||
|
return rp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *httpBridgePlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
||||||
|
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
||||||
|
if p.useSourceRemoteAddr && connInfo.SrcAddr != nil {
|
||||||
|
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
|
||||||
|
}
|
||||||
|
_ = p.l.PutConn(wrapConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *httpBridgePlugin) Close() error {
|
||||||
|
err := p.s.Close()
|
||||||
|
_ = p.l.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -17,22 +17,9 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
stdlog "log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fatedier/golib/pool"
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -42,80 +29,35 @@ func init() {
|
|||||||
type HTTPS2HTTPPlugin struct {
|
type HTTPS2HTTPPlugin struct {
|
||||||
opts *v1.HTTPS2HTTPPluginOptions
|
opts *v1.HTTPS2HTTPPluginOptions
|
||||||
|
|
||||||
l *Listener
|
*httpBridgePlugin
|
||||||
s *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
||||||
listener := NewProxyListener()
|
|
||||||
|
|
||||||
p := &HTTPS2HTTPPlugin{
|
p := &HTTPS2HTTPPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
l: listener,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rp := &httputil.ReverseProxy{
|
rp := newHTTPBridgeReverseProxy(
|
||||||
Rewrite: func(r *httputil.ProxyRequest) {
|
func(r *httputil.ProxyRequest) {
|
||||||
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
||||||
r.SetXForwarded()
|
r.SetXForwarded()
|
||||||
req := r.Out
|
req := r.Out
|
||||||
req.URL.Scheme = "http"
|
rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
|
||||||
req.URL.Host = p.opts.LocalAddr
|
|
||||||
if p.opts.HostHeaderRewrite != "" {
|
|
||||||
req.Host = p.opts.HostHeaderRewrite
|
|
||||||
}
|
|
||||||
for k, v := range p.opts.RequestHeaders.Set {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
BufferPool: pool.NewBuffer(32 * 1024),
|
nil,
|
||||||
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
|
)
|
||||||
}
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.TLS != nil {
|
|
||||||
tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
|
|
||||||
host, _ := httppkg.CanonicalHost(r.Host)
|
|
||||||
if tlsServerName != "" && tlsServerName != host {
|
|
||||||
w.WriteHeader(http.StatusMisdirectedRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rp.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.httpBridgePlugin = server
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: handler,
|
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
|
||||||
TLSConfig: tlsConfig,
|
|
||||||
}
|
|
||||||
if !lo.FromPtr(opts.EnableHTTP2) {
|
|
||||||
p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.ServeTLS(listener, "", "")
|
|
||||||
}()
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPS2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
if connInfo.SrcAddr != nil {
|
|
||||||
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
|
|
||||||
}
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPS2HTTPPlugin) Name() string {
|
func (p *HTTPS2HTTPPlugin) Name() string {
|
||||||
return v1.PluginHTTPS2HTTP
|
return v1.PluginHTTPS2HTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPS2HTTPPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,22 +17,11 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
|
||||||
stdlog "log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fatedier/golib/pool"
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -42,86 +31,39 @@ func init() {
|
|||||||
type HTTPS2HTTPSPlugin struct {
|
type HTTPS2HTTPSPlugin struct {
|
||||||
opts *v1.HTTPS2HTTPSPluginOptions
|
opts *v1.HTTPS2HTTPSPluginOptions
|
||||||
|
|
||||||
l *Listener
|
*httpBridgePlugin
|
||||||
s *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
||||||
|
|
||||||
listener := NewProxyListener()
|
|
||||||
|
|
||||||
p := &HTTPS2HTTPSPlugin{
|
p := &HTTPS2HTTPSPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
l: listener,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
rp := &httputil.ReverseProxy{
|
rp := newHTTPBridgeReverseProxy(
|
||||||
Rewrite: func(r *httputil.ProxyRequest) {
|
func(r *httputil.ProxyRequest) {
|
||||||
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
||||||
r.SetXForwarded()
|
r.SetXForwarded()
|
||||||
req := r.Out
|
req := r.Out
|
||||||
req.URL.Scheme = "https"
|
rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
|
||||||
req.URL.Host = p.opts.LocalAddr
|
|
||||||
if p.opts.HostHeaderRewrite != "" {
|
|
||||||
req.Host = p.opts.HostHeaderRewrite
|
|
||||||
}
|
|
||||||
for k, v := range p.opts.RequestHeaders.Set {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Transport: tr,
|
tr,
|
||||||
BufferPool: pool.NewBuffer(32 * 1024),
|
)
|
||||||
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
|
|
||||||
}
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.TLS != nil {
|
|
||||||
tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
|
|
||||||
host, _ := httppkg.CanonicalHost(r.Host)
|
|
||||||
if tlsServerName != "" && tlsServerName != host {
|
|
||||||
w.WriteHeader(http.StatusMisdirectedRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rp.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.httpBridgePlugin = server
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: handler,
|
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
|
||||||
TLSConfig: tlsConfig,
|
|
||||||
}
|
|
||||||
if !lo.FromPtr(opts.EnableHTTP2) {
|
|
||||||
p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.ServeTLS(listener, "", "")
|
|
||||||
}()
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPS2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
if connInfo.SrcAddr != nil {
|
|
||||||
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
|
|
||||||
}
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPS2HTTPSPlugin) Name() string {
|
func (p *HTTPS2HTTPSPlugin) Name() string {
|
||||||
return v1.PluginHTTPS2HTTPS
|
return v1.PluginHTTPS2HTTPS
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPS2HTTPSPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// 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/tls"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/samber/lo"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*http.Server, error) {
|
||||||
|
tlsConfig, err := transport.NewServerTLSConfig(crtPath, keyPath, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Handler: withMisdirectedRequestCheck(handler),
|
||||||
|
ReadHeaderTimeout: 60 * time.Second,
|
||||||
|
TLSConfig: tlsConfig,
|
||||||
|
}
|
||||||
|
if !lo.FromPtr(enableHTTP2) {
|
||||||
|
server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
||||||
|
}
|
||||||
|
return server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.TLS != nil {
|
||||||
|
tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
|
||||||
|
host, _ := httppkg.CanonicalHost(r.Host)
|
||||||
|
if tlsServerName != "" && tlsServerName != host {
|
||||||
|
w.WriteHeader(http.StatusMisdirectedRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
+68
-159
@@ -44,6 +44,67 @@ func NewManager() *Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newPluginRequestContext() (context.Context, *xlog.Logger) {
|
||||||
|
reqid, _ := util.RandID()
|
||||||
|
xl := xlog.New().AppendPrefix("reqid: " + reqid)
|
||||||
|
ctx := xlog.NewContext(context.Background(), xl)
|
||||||
|
return NewReqidContext(ctx, reqid), xl
|
||||||
|
}
|
||||||
|
|
||||||
|
type pluginErrorLogMode bool
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Warn is the zero value because it is the default for mutable plugin operations.
|
||||||
|
pluginErrorLogWarn pluginErrorLogMode = false
|
||||||
|
pluginErrorLogInfo pluginErrorLogMode = true
|
||||||
|
)
|
||||||
|
|
||||||
|
func logPluginError(xl *xlog.Logger, p Plugin, op string, err error, mode pluginErrorLogMode) {
|
||||||
|
if mode == pluginErrorLogInfo {
|
||||||
|
xl.Infof("send %s request to plugin [%s] error: %v", op, p.Name(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
xl.Warnf("send %s request to plugin [%s] error: %v", op, p.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMutableContent[T any](
|
||||||
|
plugins []Plugin,
|
||||||
|
op string,
|
||||||
|
content *T,
|
||||||
|
logMode pluginErrorLogMode,
|
||||||
|
) (*T, error) {
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
res = &Response{
|
||||||
|
Reject: false,
|
||||||
|
Unchange: true,
|
||||||
|
}
|
||||||
|
retContent any
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
ctx, xl := newPluginRequestContext()
|
||||||
|
|
||||||
|
for _, p := range plugins {
|
||||||
|
res, retContent, err = p.Handle(ctx, op, *content)
|
||||||
|
if err != nil {
|
||||||
|
logPluginError(xl, p, op, err, logMode)
|
||||||
|
return nil, errors.New("send " + op + " request to plugin error")
|
||||||
|
}
|
||||||
|
if res.Reject {
|
||||||
|
return nil, fmt.Errorf("%s", res.RejectReason)
|
||||||
|
}
|
||||||
|
if !res.Unchange {
|
||||||
|
// Preserve the existing Plugin contract: changed content must be *T.
|
||||||
|
// Buggy Plugin implementations still panic here, by design.
|
||||||
|
content = retContent.(*T)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) Register(p Plugin) {
|
func (m *Manager) Register(p Plugin) {
|
||||||
if p.IsSupport(OpLogin) {
|
if p.IsSupport(OpLogin) {
|
||||||
m.loginPlugins = append(m.loginPlugins, p)
|
m.loginPlugins = append(m.loginPlugins, p)
|
||||||
@@ -66,71 +127,11 @@ func (m *Manager) Register(p Plugin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Login(content *LoginContent) (*LoginContent, error) {
|
func (m *Manager) Login(content *LoginContent) (*LoginContent, error) {
|
||||||
if len(m.loginPlugins) == 0 {
|
return handleMutableContent(m.loginPlugins, OpLogin, content, pluginErrorLogWarn)
|
||||||
return content, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
res = &Response{
|
|
||||||
Reject: false,
|
|
||||||
Unchange: true,
|
|
||||||
}
|
|
||||||
retContent any
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
reqid, _ := util.RandID()
|
|
||||||
xl := xlog.New().AppendPrefix("reqid: " + reqid)
|
|
||||||
ctx := xlog.NewContext(context.Background(), xl)
|
|
||||||
ctx = NewReqidContext(ctx, reqid)
|
|
||||||
|
|
||||||
for _, p := range m.loginPlugins {
|
|
||||||
res, retContent, err = p.Handle(ctx, OpLogin, *content)
|
|
||||||
if err != nil {
|
|
||||||
xl.Warnf("send Login request to plugin [%s] error: %v", p.Name(), err)
|
|
||||||
return nil, errors.New("send Login request to plugin error")
|
|
||||||
}
|
|
||||||
if res.Reject {
|
|
||||||
return nil, fmt.Errorf("%s", res.RejectReason)
|
|
||||||
}
|
|
||||||
if !res.Unchange {
|
|
||||||
content = retContent.(*LoginContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) NewProxy(content *NewProxyContent) (*NewProxyContent, error) {
|
func (m *Manager) NewProxy(content *NewProxyContent) (*NewProxyContent, error) {
|
||||||
if len(m.newProxyPlugins) == 0 {
|
return handleMutableContent(m.newProxyPlugins, OpNewProxy, content, pluginErrorLogWarn)
|
||||||
return content, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
res = &Response{
|
|
||||||
Reject: false,
|
|
||||||
Unchange: true,
|
|
||||||
}
|
|
||||||
retContent any
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
reqid, _ := util.RandID()
|
|
||||||
xl := xlog.New().AppendPrefix("reqid: " + reqid)
|
|
||||||
ctx := xlog.NewContext(context.Background(), xl)
|
|
||||||
ctx = NewReqidContext(ctx, reqid)
|
|
||||||
|
|
||||||
for _, p := range m.newProxyPlugins {
|
|
||||||
res, retContent, err = p.Handle(ctx, OpNewProxy, *content)
|
|
||||||
if err != nil {
|
|
||||||
xl.Warnf("send NewProxy request to plugin [%s] error: %v", p.Name(), err)
|
|
||||||
return nil, errors.New("send NewProxy request to plugin error")
|
|
||||||
}
|
|
||||||
if res.Reject {
|
|
||||||
return nil, fmt.Errorf("%s", res.RejectReason)
|
|
||||||
}
|
|
||||||
if !res.Unchange {
|
|
||||||
content = retContent.(*NewProxyContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) CloseProxy(content *CloseProxyContent) error {
|
func (m *Manager) CloseProxy(content *CloseProxyContent) error {
|
||||||
@@ -139,10 +140,7 @@ func (m *Manager) CloseProxy(content *CloseProxyContent) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
errs := make([]string, 0)
|
errs := make([]string, 0)
|
||||||
reqid, _ := util.RandID()
|
ctx, xl := newPluginRequestContext()
|
||||||
xl := xlog.New().AppendPrefix("reqid: " + reqid)
|
|
||||||
ctx := xlog.NewContext(context.Background(), xl)
|
|
||||||
ctx = NewReqidContext(ctx, reqid)
|
|
||||||
|
|
||||||
for _, p := range m.closeProxyPlugins {
|
for _, p := range m.closeProxyPlugins {
|
||||||
_, _, err := p.Handle(ctx, OpCloseProxy, *content)
|
_, _, err := p.Handle(ctx, OpCloseProxy, *content)
|
||||||
@@ -159,103 +157,14 @@ func (m *Manager) CloseProxy(content *CloseProxyContent) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Ping(content *PingContent) (*PingContent, error) {
|
func (m *Manager) Ping(content *PingContent) (*PingContent, error) {
|
||||||
if len(m.pingPlugins) == 0 {
|
return handleMutableContent(m.pingPlugins, OpPing, content, pluginErrorLogWarn)
|
||||||
return content, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
res = &Response{
|
|
||||||
Reject: false,
|
|
||||||
Unchange: true,
|
|
||||||
}
|
|
||||||
retContent any
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
reqid, _ := util.RandID()
|
|
||||||
xl := xlog.New().AppendPrefix("reqid: " + reqid)
|
|
||||||
ctx := xlog.NewContext(context.Background(), xl)
|
|
||||||
ctx = NewReqidContext(ctx, reqid)
|
|
||||||
|
|
||||||
for _, p := range m.pingPlugins {
|
|
||||||
res, retContent, err = p.Handle(ctx, OpPing, *content)
|
|
||||||
if err != nil {
|
|
||||||
xl.Warnf("send Ping request to plugin [%s] error: %v", p.Name(), err)
|
|
||||||
return nil, errors.New("send Ping request to plugin error")
|
|
||||||
}
|
|
||||||
if res.Reject {
|
|
||||||
return nil, fmt.Errorf("%s", res.RejectReason)
|
|
||||||
}
|
|
||||||
if !res.Unchange {
|
|
||||||
content = retContent.(*PingContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) NewWorkConn(content *NewWorkConnContent) (*NewWorkConnContent, error) {
|
func (m *Manager) NewWorkConn(content *NewWorkConnContent) (*NewWorkConnContent, error) {
|
||||||
if len(m.newWorkConnPlugins) == 0 {
|
return handleMutableContent(m.newWorkConnPlugins, OpNewWorkConn, content, pluginErrorLogWarn)
|
||||||
return content, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
res = &Response{
|
|
||||||
Reject: false,
|
|
||||||
Unchange: true,
|
|
||||||
}
|
|
||||||
retContent any
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
reqid, _ := util.RandID()
|
|
||||||
xl := xlog.New().AppendPrefix("reqid: " + reqid)
|
|
||||||
ctx := xlog.NewContext(context.Background(), xl)
|
|
||||||
ctx = NewReqidContext(ctx, reqid)
|
|
||||||
|
|
||||||
for _, p := range m.newWorkConnPlugins {
|
|
||||||
res, retContent, err = p.Handle(ctx, OpNewWorkConn, *content)
|
|
||||||
if err != nil {
|
|
||||||
xl.Warnf("send NewWorkConn request to plugin [%s] error: %v", p.Name(), err)
|
|
||||||
return nil, errors.New("send NewWorkConn request to plugin error")
|
|
||||||
}
|
|
||||||
if res.Reject {
|
|
||||||
return nil, fmt.Errorf("%s", res.RejectReason)
|
|
||||||
}
|
|
||||||
if !res.Unchange {
|
|
||||||
content = retContent.(*NewWorkConnContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) NewUserConn(content *NewUserConnContent) (*NewUserConnContent, error) {
|
func (m *Manager) NewUserConn(content *NewUserConnContent) (*NewUserConnContent, error) {
|
||||||
if len(m.newUserConnPlugins) == 0 {
|
// Preserve the pre-refactor log level for NewUserConn plugin errors.
|
||||||
return content, nil
|
return handleMutableContent(m.newUserConnPlugins, OpNewUserConn, content, pluginErrorLogInfo)
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
res = &Response{
|
|
||||||
Reject: false,
|
|
||||||
Unchange: true,
|
|
||||||
}
|
|
||||||
retContent any
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
reqid, _ := util.RandID()
|
|
||||||
xl := xlog.New().AppendPrefix("reqid: " + reqid)
|
|
||||||
ctx := xlog.NewContext(context.Background(), xl)
|
|
||||||
ctx = NewReqidContext(ctx, reqid)
|
|
||||||
|
|
||||||
for _, p := range m.newUserConnPlugins {
|
|
||||||
res, retContent, err = p.Handle(ctx, OpNewUserConn, *content)
|
|
||||||
if err != nil {
|
|
||||||
xl.Infof("send NewUserConn request to plugin [%s] error: %v", p.Name(), err)
|
|
||||||
return nil, errors.New("send NewUserConn request to plugin error")
|
|
||||||
}
|
|
||||||
if res.Reject {
|
|
||||||
return nil, fmt.Errorf("%s", res.RejectReason)
|
|
||||||
}
|
|
||||||
if !res.Unchange {
|
|
||||||
content = retContent.(*NewUserConnContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
// Copyright 2019 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 (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
goliblog "github.com/fatedier/golib/log"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
frplog "github.com/fatedier/frp/pkg/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testPlugin struct {
|
||||||
|
name string
|
||||||
|
ops map[string]bool
|
||||||
|
handler func(context.Context, string, any) (*Response, any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log-capturing subtests serialize global logger swaps; do not use t.Parallel.
|
||||||
|
var logCaptureMu sync.Mutex
|
||||||
|
|
||||||
|
type logCapture struct {
|
||||||
|
bytes.Buffer
|
||||||
|
levels []goliblog.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p testPlugin) Name() string {
|
||||||
|
return p.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p testPlugin) IsSupport(op string) bool {
|
||||||
|
return p.ops[op]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p testPlugin) Handle(ctx context.Context, op string, content any) (*Response, any, error) {
|
||||||
|
return p.handler(ctx, op, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *logCapture) WriteLog(p []byte, level goliblog.Level, _ time.Time) (int, error) {
|
||||||
|
w.levels = append(w.levels, level)
|
||||||
|
return w.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureLogOutput(t *testing.T) *logCapture {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
logCaptureMu.Lock()
|
||||||
|
logOutput := &logCapture{}
|
||||||
|
oldLogger := frplog.Logger
|
||||||
|
frplog.Logger = goliblog.New(
|
||||||
|
goliblog.WithOutput(logOutput),
|
||||||
|
goliblog.WithLevel(goliblog.TraceLevel),
|
||||||
|
goliblog.WithCaller(false),
|
||||||
|
)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
frplog.Logger = oldLogger
|
||||||
|
logCaptureMu.Unlock()
|
||||||
|
})
|
||||||
|
return logOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
var mutablePluginOps = []struct {
|
||||||
|
name string
|
||||||
|
op string
|
||||||
|
}{
|
||||||
|
{name: "login", op: OpLogin},
|
||||||
|
{name: "new proxy", op: OpNewProxy},
|
||||||
|
{name: "ping", op: OpPing},
|
||||||
|
{name: "new work conn", op: OpNewWorkConn},
|
||||||
|
{name: "new user conn", op: OpNewUserConn},
|
||||||
|
}
|
||||||
|
|
||||||
|
func callMutableWithUser(m *Manager, op string, user string) (string, error) {
|
||||||
|
switch op {
|
||||||
|
case OpLogin:
|
||||||
|
got, err := m.Login(&LoginContent{Login: msg.Login{User: user}})
|
||||||
|
if got == nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return got.User, err
|
||||||
|
case OpNewProxy:
|
||||||
|
got, err := m.NewProxy(&NewProxyContent{User: UserInfo{User: user}})
|
||||||
|
if got == nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return got.User.User, err
|
||||||
|
case OpPing:
|
||||||
|
got, err := m.Ping(&PingContent{User: UserInfo{User: user}})
|
||||||
|
if got == nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return got.User.User, err
|
||||||
|
case OpNewWorkConn:
|
||||||
|
got, err := m.NewWorkConn(&NewWorkConnContent{User: UserInfo{User: user}})
|
||||||
|
if got == nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return got.User.User, err
|
||||||
|
case OpNewUserConn:
|
||||||
|
got, err := m.NewUserConn(&NewUserConnContent{User: UserInfo{User: user}})
|
||||||
|
if got == nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return got.User.User, err
|
||||||
|
default:
|
||||||
|
panic("unsupported mutable op: " + op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutableUser(t *testing.T, op string, content any) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case OpLogin:
|
||||||
|
return content.(LoginContent).User
|
||||||
|
case OpNewProxy:
|
||||||
|
return content.(NewProxyContent).User.User
|
||||||
|
case OpPing:
|
||||||
|
return content.(PingContent).User.User
|
||||||
|
case OpNewWorkConn:
|
||||||
|
return content.(NewWorkConnContent).User.User
|
||||||
|
case OpNewUserConn:
|
||||||
|
return content.(NewUserConnContent).User.User
|
||||||
|
default:
|
||||||
|
t.Fatalf("unsupported mutable op: %s", op)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutateMutableContent(t *testing.T, op string, content any, user string) any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case OpLogin:
|
||||||
|
got := content.(LoginContent)
|
||||||
|
got.User = user
|
||||||
|
return &got
|
||||||
|
case OpNewProxy:
|
||||||
|
got := content.(NewProxyContent)
|
||||||
|
got.User.User = user
|
||||||
|
return &got
|
||||||
|
case OpPing:
|
||||||
|
got := content.(PingContent)
|
||||||
|
got.User.User = user
|
||||||
|
return &got
|
||||||
|
case OpNewWorkConn:
|
||||||
|
got := content.(NewWorkConnContent)
|
||||||
|
got.User.User = user
|
||||||
|
return &got
|
||||||
|
case OpNewUserConn:
|
||||||
|
got := content.(NewUserConnContent)
|
||||||
|
got.User.User = user
|
||||||
|
return &got
|
||||||
|
default:
|
||||||
|
t.Fatalf("unsupported mutable op: %s", op)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerMutableContentAcrossOps(t *testing.T) {
|
||||||
|
for _, tt := range mutablePluginOps {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
m := NewManager()
|
||||||
|
m.Register(testPlugin{
|
||||||
|
name: "mutate",
|
||||||
|
ops: map[string]bool{tt.op: true},
|
||||||
|
handler: func(ctx context.Context, op string, content any) (*Response, any, error) {
|
||||||
|
if op != tt.op {
|
||||||
|
t.Fatalf("unexpected op: %s", op)
|
||||||
|
}
|
||||||
|
if GetReqidFromContext(ctx) == "" {
|
||||||
|
t.Fatal("expected request id in context")
|
||||||
|
}
|
||||||
|
if got := mutableUser(t, tt.op, content); got != "initial" {
|
||||||
|
t.Fatalf("expected initial user, got %q", got)
|
||||||
|
}
|
||||||
|
return &Response{Unchange: false}, mutateMutableContent(t, tt.op, content, "mutated"), nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
m.Register(testPlugin{
|
||||||
|
name: "observe",
|
||||||
|
ops: map[string]bool{tt.op: true},
|
||||||
|
handler: func(ctx context.Context, op string, content any) (*Response, any, error) {
|
||||||
|
if op != tt.op {
|
||||||
|
t.Fatalf("unexpected op: %s", op)
|
||||||
|
}
|
||||||
|
if GetReqidFromContext(ctx) == "" {
|
||||||
|
t.Fatal("expected request id in context")
|
||||||
|
}
|
||||||
|
if got := mutableUser(t, tt.op, content); got != "mutated" {
|
||||||
|
t.Fatalf("expected mutated user, got %q", got)
|
||||||
|
}
|
||||||
|
return &Response{Unchange: true}, mutateMutableContent(t, tt.op, content, "ignored"), nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := callMutableWithUser(m, tt.op, "initial")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mutable op failed: %v", err)
|
||||||
|
}
|
||||||
|
if got != "mutated" {
|
||||||
|
t.Fatalf("expected mutated user, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerMutableContentRejectStopsChain(t *testing.T) {
|
||||||
|
m := NewManager()
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
m.Register(testPlugin{
|
||||||
|
name: "reject",
|
||||||
|
ops: map[string]bool{OpPing: true},
|
||||||
|
handler: func(context.Context, string, any) (*Response, any, error) {
|
||||||
|
return &Response{Reject: true, RejectReason: "blocked"}, nil, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
m.Register(testPlugin{
|
||||||
|
name: "unused",
|
||||||
|
ops: map[string]bool{OpPing: true},
|
||||||
|
handler: func(context.Context, string, any) (*Response, any, error) {
|
||||||
|
called = true
|
||||||
|
return &Response{Unchange: true}, nil, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := m.Ping(&PingContent{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected reject error")
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("expected no returned content, got %#v", got)
|
||||||
|
}
|
||||||
|
if err.Error() != "blocked" {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("expected plugin chain to stop after reject")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerMutableContentPluginErrorLogLevel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
op string
|
||||||
|
level goliblog.Level
|
||||||
|
}{
|
||||||
|
{name: "default warning", op: OpLogin, level: goliblog.WarnLevel},
|
||||||
|
{name: "new user conn info", op: OpNewUserConn, level: goliblog.InfoLevel},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logOutput := captureLogOutput(t)
|
||||||
|
m := NewManager()
|
||||||
|
m.Register(testPlugin{
|
||||||
|
name: "error",
|
||||||
|
ops: map[string]bool{tt.op: true},
|
||||||
|
handler: func(context.Context, string, any) (*Response, any, error) {
|
||||||
|
return nil, nil, errors.New("boom")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := callMutableWithUser(m, tt.op, "initial")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected plugin error")
|
||||||
|
}
|
||||||
|
if want := "send " + tt.op + " request to plugin error"; err.Error() != want {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(logOutput.levels) != 1 || logOutput.levels[0] != tt.level {
|
||||||
|
t.Fatalf("expected log level %v, got %v in %q", tt.level, logOutput.levels, logOutput.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerCloseProxyAggregatesErrors(t *testing.T) {
|
||||||
|
logOutput := captureLogOutput(t)
|
||||||
|
m := NewManager()
|
||||||
|
|
||||||
|
for _, name := range []string{"first", "second"} {
|
||||||
|
m.Register(testPlugin{
|
||||||
|
name: name,
|
||||||
|
ops: map[string]bool{OpCloseProxy: true},
|
||||||
|
handler: func(ctx context.Context, op string, content any) (*Response, any, error) {
|
||||||
|
if GetReqidFromContext(ctx) == "" {
|
||||||
|
t.Fatal("expected request id in context")
|
||||||
|
}
|
||||||
|
if op != OpCloseProxy {
|
||||||
|
t.Fatalf("unexpected op: %s", op)
|
||||||
|
}
|
||||||
|
return nil, nil, errors.New(name + " error")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := m.CloseProxy(&CloseProxyContent{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected close proxy error")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(err.Error(), "send CloseProxy request to plugin errors: ") {
|
||||||
|
t.Fatalf("unexpected close proxy error prefix: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "[first]: first error") || !strings.Contains(err.Error(), "[second]: second error") {
|
||||||
|
t.Fatalf("missing aggregated errors: %v", err)
|
||||||
|
}
|
||||||
|
if len(logOutput.levels) != 2 {
|
||||||
|
t.Fatalf("expected two warning logs, got %v", logOutput.levels)
|
||||||
|
}
|
||||||
|
for _, level := range logOutput.levels {
|
||||||
|
if level != goliblog.WarnLevel {
|
||||||
|
t.Fatalf("expected warning log level, got %v", logOutput.levels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,8 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
|
|||||||
_, err = udpConn.Write(buf)
|
_, err = udpConn.Write(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
udpConn.Close()
|
udpConn.Close()
|
||||||
|
} else {
|
||||||
|
_ = udpConn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// Copyright 2026 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package wire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"golang.org/x/sys/cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AEADAlgorithmAES256GCM = "aes-256-gcm"
|
||||||
|
AEADAlgorithmXChaCha20Poly1305 = "xchacha20-poly1305"
|
||||||
|
|
||||||
|
CryptoRandomSize = 32
|
||||||
|
|
||||||
|
cryptoTranscriptLabel = "frp wire v2 crypto transcript"
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedAEADAlgorithms = []string{
|
||||||
|
AEADAlgorithmAES256GCM,
|
||||||
|
AEADAlgorithmXChaCha20Poly1305,
|
||||||
|
}
|
||||||
|
|
||||||
|
type CryptoContext struct {
|
||||||
|
Algorithm string
|
||||||
|
TranscriptHash []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientHello(bootstrap BootstrapInfo) (ClientHello, error) {
|
||||||
|
clientRandom, err := newCryptoRandom()
|
||||||
|
if err != nil {
|
||||||
|
return ClientHello{}, err
|
||||||
|
}
|
||||||
|
return clientHelloWithCryptoRandom(bootstrap, clientRandom), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServerHello(clientHello ClientHello) (ServerHello, error) {
|
||||||
|
if err := ValidateClientHello(clientHello); err != nil {
|
||||||
|
return ServerHello{}, err
|
||||||
|
}
|
||||||
|
algorithm, ok := SelectAEADAlgorithm(clientHello.Capabilities.Crypto.Algorithms)
|
||||||
|
if !ok {
|
||||||
|
return ServerHello{}, fmt.Errorf("no supported crypto algorithm")
|
||||||
|
}
|
||||||
|
serverRandom, err := newCryptoRandom()
|
||||||
|
if err != nil {
|
||||||
|
return ServerHello{}, err
|
||||||
|
}
|
||||||
|
return ServerHello{
|
||||||
|
Selected: ServerSelection{
|
||||||
|
Message: MessageSelection{
|
||||||
|
Codec: MessageCodecJSON,
|
||||||
|
},
|
||||||
|
Crypto: CryptoSelection{
|
||||||
|
Algorithm: algorithm,
|
||||||
|
ServerRandom: serverRandom,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateCryptoCapabilities(c CryptoCapabilities) error {
|
||||||
|
if len(c.ClientRandom) != CryptoRandomSize {
|
||||||
|
return fmt.Errorf("invalid crypto client random length %d, want %d", len(c.ClientRandom), CryptoRandomSize)
|
||||||
|
}
|
||||||
|
if _, ok := SelectAEADAlgorithm(c.Algorithms); !ok {
|
||||||
|
return fmt.Errorf("no supported crypto algorithm")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHello) error {
|
||||||
|
if serverHello.Selected.Message.Codec != MessageCodecJSON {
|
||||||
|
return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec)
|
||||||
|
}
|
||||||
|
cryptoSelection := serverHello.Selected.Crypto
|
||||||
|
if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) {
|
||||||
|
return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm)
|
||||||
|
}
|
||||||
|
if !Supports(clientHello.Capabilities.Crypto.Algorithms, cryptoSelection.Algorithm) {
|
||||||
|
return fmt.Errorf("selected crypto algorithm was not advertised by client: %s", cryptoSelection.Algorithm)
|
||||||
|
}
|
||||||
|
if len(cryptoSelection.ServerRandom) != CryptoRandomSize {
|
||||||
|
return fmt.Errorf("invalid crypto server random length %d, want %d", len(cryptoSelection.ServerRandom), CryptoRandomSize)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext {
|
||||||
|
return &CryptoContext{
|
||||||
|
Algorithm: algorithm,
|
||||||
|
TranscriptHash: HashCryptoTranscript(clientHelloPayload, serverHelloPayload),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientCryptoContext(clientHelloPayload, serverHelloPayload []byte) (*CryptoContext, error) {
|
||||||
|
var clientHello ClientHello
|
||||||
|
if err := json.Unmarshal(clientHelloPayload, &clientHello); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode ClientHello transcript: %w", err)
|
||||||
|
}
|
||||||
|
var serverHello ServerHello
|
||||||
|
if err := json.Unmarshal(serverHelloPayload, &serverHello); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode ServerHello transcript: %w", err)
|
||||||
|
}
|
||||||
|
if err := ValidateServerHelloForClient(clientHello, serverHello); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewCryptoContext(serverHello.Selected.Crypto.Algorithm, clientHelloPayload, serverHelloPayload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashCryptoTranscript(clientHelloPayload, serverHelloPayload []byte) []byte {
|
||||||
|
h := sha256.New()
|
||||||
|
_, _ = h.Write([]byte(cryptoTranscriptLabel))
|
||||||
|
writeCryptoTranscriptPart(h, "client hello", clientHelloPayload)
|
||||||
|
writeCryptoTranscriptPart(h, "server hello", serverHelloPayload)
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCryptoTranscriptPart(h hash.Hash, label string, payload []byte) {
|
||||||
|
var length [8]byte
|
||||||
|
binary.BigEndian.PutUint64(length[:], uint64(len(payload)))
|
||||||
|
_, _ = h.Write([]byte{0})
|
||||||
|
_, _ = h.Write([]byte(label))
|
||||||
|
_, _ = h.Write([]byte{0})
|
||||||
|
_, _ = h.Write(length[:])
|
||||||
|
_, _ = h.Write(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PreferredAEADAlgorithms() []string {
|
||||||
|
if hasFastAESGCM() {
|
||||||
|
return []string{AEADAlgorithmAES256GCM, AEADAlgorithmXChaCha20Poly1305}
|
||||||
|
}
|
||||||
|
return []string{AEADAlgorithmXChaCha20Poly1305, AEADAlgorithmAES256GCM}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelectAEADAlgorithm(clientAlgorithms []string) (string, bool) {
|
||||||
|
for _, algorithm := range clientAlgorithms {
|
||||||
|
if IsSupportedAEADAlgorithm(algorithm) {
|
||||||
|
return algorithm, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsSupportedAEADAlgorithm(algorithm string) bool {
|
||||||
|
return Supports(supportedAEADAlgorithms, algorithm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCryptoRandom() ([]byte, error) {
|
||||||
|
b := make([]byte, CryptoRandomSize)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate crypto random: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFastAESGCM() bool {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "amd64":
|
||||||
|
return cpu.X86.HasAES &&
|
||||||
|
cpu.X86.HasPCLMULQDQ &&
|
||||||
|
cpu.X86.HasSSE41 &&
|
||||||
|
cpu.X86.HasSSSE3
|
||||||
|
case "arm64":
|
||||||
|
return cpu.ARM64.HasAES && cpu.ARM64.HasPMULL
|
||||||
|
case "s390x":
|
||||||
|
return cpu.S390X.HasAES &&
|
||||||
|
cpu.S390X.HasAESCTR &&
|
||||||
|
cpu.S390X.HasGHASH
|
||||||
|
case "ppc64", "ppc64le":
|
||||||
|
// Go's ppc64/ppc64le port targets POWER8+, which has AES instructions;
|
||||||
|
// x/sys/cpu does not expose a PPC64 AES feature flag.
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-7
@@ -120,15 +120,23 @@ func (c *Conn) UnmarshalFrame(f *Frame, out any) error {
|
|||||||
return json.Unmarshal(f.Payload, out)
|
return json.Unmarshal(f.Payload, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn) WriteJSONFrame(frameType uint16, in any) error {
|
func NewJSONFrame(frameType uint16, in any) (*Frame, error) {
|
||||||
payload, err := json.Marshal(in)
|
payload, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Frame{
|
||||||
|
Type: frameType,
|
||||||
|
Payload: payload,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) WriteJSONFrame(frameType uint16, in any) error {
|
||||||
|
f, err := NewJSONFrame(frameType, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.WriteFrame(&Frame{
|
return c.WriteFrame(f)
|
||||||
Type: frameType,
|
|
||||||
Payload: payload,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func WriteMagic(w io.Writer) error {
|
func WriteMagic(w io.Writer) error {
|
||||||
@@ -170,12 +178,18 @@ type ClientHello struct {
|
|||||||
|
|
||||||
type ClientCapabilities struct {
|
type ClientCapabilities struct {
|
||||||
Message MessageCapabilities `json:"message,omitempty"`
|
Message MessageCapabilities `json:"message,omitempty"`
|
||||||
|
Crypto CryptoCapabilities `json:"crypto,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageCapabilities struct {
|
type MessageCapabilities struct {
|
||||||
Codecs []string `json:"codecs,omitempty"`
|
Codecs []string `json:"codecs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CryptoCapabilities struct {
|
||||||
|
Algorithms []string `json:"algorithms,omitempty"`
|
||||||
|
ClientRandom []byte `json:"clientRandom,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ServerHello struct {
|
type ServerHello struct {
|
||||||
Selected ServerSelection `json:"selected,omitempty"`
|
Selected ServerSelection `json:"selected,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@@ -183,19 +197,29 @@ type ServerHello struct {
|
|||||||
|
|
||||||
type ServerSelection struct {
|
type ServerSelection struct {
|
||||||
Message MessageSelection `json:"message,omitempty"`
|
Message MessageSelection `json:"message,omitempty"`
|
||||||
|
Crypto CryptoSelection `json:"crypto,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageSelection struct {
|
type MessageSelection struct {
|
||||||
Codec string `json:"codec,omitempty"`
|
Codec string `json:"codec,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultClientHello(bootstrap BootstrapInfo) ClientHello {
|
type CryptoSelection struct {
|
||||||
|
Algorithm string `json:"algorithm,omitempty"`
|
||||||
|
ServerRandom []byte `json:"serverRandom,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) ClientHello {
|
||||||
return ClientHello{
|
return ClientHello{
|
||||||
Bootstrap: bootstrap,
|
Bootstrap: bootstrap,
|
||||||
Capabilities: ClientCapabilities{
|
Capabilities: ClientCapabilities{
|
||||||
Message: MessageCapabilities{
|
Message: MessageCapabilities{
|
||||||
Codecs: []string{MessageCodecJSON},
|
Codecs: []string{MessageCodecJSON},
|
||||||
},
|
},
|
||||||
|
Crypto: CryptoCapabilities{
|
||||||
|
Algorithms: PreferredAEADAlgorithms(),
|
||||||
|
ClientRandom: clientRandom,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,5 +242,5 @@ func ValidateClientHello(h ClientHello) error {
|
|||||||
if !Supports(h.Capabilities.Message.Codecs, MessageCodecJSON) {
|
if !Supports(h.Capabilities.Message.Codecs, MessageCodecJSON) {
|
||||||
return fmt.Errorf("unsupported message codec")
|
return fmt.Errorf("unsupported message codec")
|
||||||
}
|
}
|
||||||
return nil
|
return ValidateCryptoCapabilities(h.Capabilities.Crypto)
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-3
@@ -28,7 +28,7 @@ func TestFrameRoundTrip(t *testing.T) {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
conn := NewConn(&buf)
|
conn := NewConn(&buf)
|
||||||
|
|
||||||
in := DefaultClientHello(BootstrapInfo{
|
in := mustClientHello(t, BootstrapInfo{
|
||||||
Transport: "tcp",
|
Transport: "tcp",
|
||||||
TLS: true,
|
TLS: true,
|
||||||
TCPMux: true,
|
TCPMux: true,
|
||||||
@@ -112,9 +112,122 @@ func TestCheckMagicV1PreservesReadBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateClientHello(t *testing.T) {
|
func TestValidateClientHello(t *testing.T) {
|
||||||
require.NoError(t, ValidateClientHello(DefaultClientHello(BootstrapInfo{})))
|
hello := mustClientHello(t, BootstrapInfo{})
|
||||||
|
require.NoError(t, ValidateClientHello(hello))
|
||||||
|
require.Len(t, hello.Capabilities.Crypto.ClientRandom, CryptoRandomSize)
|
||||||
|
require.ElementsMatch(t, []string{
|
||||||
|
AEADAlgorithmAES256GCM,
|
||||||
|
AEADAlgorithmXChaCha20Poly1305,
|
||||||
|
}, hello.Capabilities.Crypto.Algorithms)
|
||||||
|
|
||||||
hello := DefaultClientHello(BootstrapInfo{})
|
|
||||||
hello.Capabilities.Message.Codecs = []string{"unknown"}
|
hello.Capabilities.Message.Codecs = []string{"unknown"}
|
||||||
require.ErrorContains(t, ValidateClientHello(hello), "unsupported message codec")
|
require.ErrorContains(t, ValidateClientHello(hello), "unsupported message codec")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateClientHelloRejectsInvalidCrypto(t *testing.T) {
|
||||||
|
hello := mustClientHello(t, BootstrapInfo{})
|
||||||
|
hello.Capabilities.Crypto.ClientRandom = hello.Capabilities.Crypto.ClientRandom[:CryptoRandomSize-1]
|
||||||
|
require.ErrorContains(t, ValidateClientHello(hello), "invalid crypto client random length")
|
||||||
|
|
||||||
|
hello = mustClientHello(t, BootstrapInfo{})
|
||||||
|
hello.Capabilities.Crypto.Algorithms = []string{"unknown"}
|
||||||
|
require.ErrorContains(t, ValidateClientHello(hello), "no supported crypto algorithm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreferredAEADAlgorithms(t *testing.T) {
|
||||||
|
require.ElementsMatch(t, []string{
|
||||||
|
AEADAlgorithmAES256GCM,
|
||||||
|
AEADAlgorithmXChaCha20Poly1305,
|
||||||
|
}, PreferredAEADAlgorithms())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServerHelloSelectsFirstSupportedAEADAlgorithm(t *testing.T) {
|
||||||
|
hello := mustClientHello(t, BootstrapInfo{})
|
||||||
|
hello.Capabilities.Crypto.Algorithms = []string{"future-aead", AEADAlgorithmXChaCha20Poly1305, AEADAlgorithmAES256GCM}
|
||||||
|
|
||||||
|
serverHello, err := NewServerHello(hello)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec)
|
||||||
|
require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm)
|
||||||
|
require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientCryptoContextValidatesServerHello(t *testing.T) {
|
||||||
|
hello := mustClientHello(t, BootstrapInfo{})
|
||||||
|
serverHello, err := NewServerHello(hello)
|
||||||
|
require.NoError(t, err)
|
||||||
|
clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
|
||||||
|
|
||||||
|
ctx, err := NewClientCryptoContext(clientHelloPayload, serverHelloPayload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, serverHello.Selected.Crypto.Algorithm, ctx.Algorithm)
|
||||||
|
require.Len(t, ctx.TranscriptHash, 32)
|
||||||
|
|
||||||
|
tampered := serverHello
|
||||||
|
tampered.Selected.Crypto.ServerRandom = append([]byte(nil), serverHello.Selected.Crypto.ServerRandom...)
|
||||||
|
tampered.Selected.Crypto.ServerRandom[0] ^= 0xff
|
||||||
|
_, tamperedServerHelloPayload := mustCryptoTranscriptPayloads(t, hello, tampered)
|
||||||
|
tamperedCtx, err := NewClientCryptoContext(clientHelloPayload, tamperedServerHelloPayload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, ctx.TranscriptHash, tamperedCtx.TranscriptHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCryptoContextBindsFullClientHelloPayload(t *testing.T) {
|
||||||
|
hello := mustClientHello(t, BootstrapInfo{
|
||||||
|
Transport: "tcp",
|
||||||
|
TLS: true,
|
||||||
|
TCPMux: true,
|
||||||
|
})
|
||||||
|
serverHello, err := NewServerHello(hello)
|
||||||
|
require.NoError(t, err)
|
||||||
|
clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
|
||||||
|
|
||||||
|
ctx := NewCryptoContext(serverHello.Selected.Crypto.Algorithm, clientHelloPayload, serverHelloPayload)
|
||||||
|
|
||||||
|
tamperedHello := hello
|
||||||
|
tamperedHello.Bootstrap.TLS = false
|
||||||
|
tamperedClientHelloPayload, _ := mustCryptoTranscriptPayloads(t, tamperedHello, serverHello)
|
||||||
|
tamperedCtx := NewCryptoContext(serverHello.Selected.Crypto.Algorithm, tamperedClientHelloPayload, serverHelloPayload)
|
||||||
|
require.NotEqual(t, ctx.TranscriptHash, tamperedCtx.TranscriptHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientCryptoContextRejectsUnknownServerSelection(t *testing.T) {
|
||||||
|
hello := mustClientHello(t, BootstrapInfo{})
|
||||||
|
serverHello, err := NewServerHello(hello)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
serverHello.Selected.Crypto.Algorithm = "unknown"
|
||||||
|
clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
|
||||||
|
_, err = NewClientCryptoContext(clientHelloPayload, serverHelloPayload)
|
||||||
|
require.ErrorContains(t, err, "unknown selected crypto algorithm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientCryptoContextRejectsUnadvertisedServerSelection(t *testing.T) {
|
||||||
|
hello := mustClientHello(t, BootstrapInfo{})
|
||||||
|
hello.Capabilities.Crypto.Algorithms = []string{AEADAlgorithmAES256GCM}
|
||||||
|
serverHello, err := NewServerHello(hello)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
serverHello.Selected.Crypto.Algorithm = AEADAlgorithmXChaCha20Poly1305
|
||||||
|
clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
|
||||||
|
_, err = NewClientCryptoContext(clientHelloPayload, serverHelloPayload)
|
||||||
|
require.ErrorContains(t, err, "selected crypto algorithm was not advertised by client")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustClientHello(t *testing.T, bootstrap BootstrapInfo) ClientHello {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hello, err := NewClientHello(bootstrap)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return hello
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCryptoTranscriptPayloads(t *testing.T, hello ClientHello, serverHello ServerHello) ([]byte, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
clientHelloFrame, err := NewJSONFrame(FrameTypeClientHello, hello)
|
||||||
|
require.NoError(t, err)
|
||||||
|
serverHelloFrame, err := NewJSONFrame(FrameTypeServerHello, serverHello)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return clientHelloFrame.Payload, serverHelloFrame.Payload
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ package metric
|
|||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/utils/clock"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DateCounter interface {
|
type DateCounter interface {
|
||||||
@@ -38,27 +40,33 @@ func NewDateCounter(reserveDays int64) DateCounter {
|
|||||||
type StandardDateCounter struct {
|
type StandardDateCounter struct {
|
||||||
reserveDays int64
|
reserveDays int64
|
||||||
counts []int64
|
counts []int64
|
||||||
|
clock clock.PassiveClock
|
||||||
|
|
||||||
lastUpdateDate time.Time
|
lastUpdateDate time.Time
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStandardDateCounter(reserveDays int64) *StandardDateCounter {
|
func newStandardDateCounter(reserveDays int64) *StandardDateCounter {
|
||||||
now := time.Now()
|
return newStandardDateCounterWithClock(reserveDays, clock.RealClock{})
|
||||||
now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
}
|
||||||
s := &StandardDateCounter{
|
|
||||||
|
func newStandardDateCounterWithClock(reserveDays int64, clk clock.PassiveClock) *StandardDateCounter {
|
||||||
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
|
return &StandardDateCounter{
|
||||||
reserveDays: reserveDays,
|
reserveDays: reserveDays,
|
||||||
counts: make([]int64, reserveDays),
|
counts: make([]int64, reserveDays),
|
||||||
lastUpdateDate: now,
|
clock: clk,
|
||||||
|
lastUpdateDate: startOfDay(clk.Now()),
|
||||||
}
|
}
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *StandardDateCounter) TodayCount() int64 {
|
func (c *StandardDateCounter) TodayCount() int64 {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
c.rotate(time.Now())
|
c.rotate(c.clock.Now())
|
||||||
return c.counts[0]
|
return c.counts[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,65 +78,61 @@ func (c *StandardDateCounter) GetLastDaysCount(lastdays int64) []int64 {
|
|||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.rotate(time.Now())
|
c.rotate(c.clock.Now())
|
||||||
for i := 0; i < int(lastdays); i++ {
|
copy(counts, c.counts)
|
||||||
counts[i] = c.counts[i]
|
|
||||||
}
|
|
||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *StandardDateCounter) Inc(count int64) {
|
func (c *StandardDateCounter) Inc(count int64) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.rotate(time.Now())
|
c.rotate(c.clock.Now())
|
||||||
c.counts[0] += count
|
c.counts[0] += count
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *StandardDateCounter) Dec(count int64) {
|
func (c *StandardDateCounter) Dec(count int64) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.rotate(time.Now())
|
c.rotate(c.clock.Now())
|
||||||
c.counts[0] -= count
|
c.counts[0] -= count
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *StandardDateCounter) Snapshot() DateCounter {
|
func (c *StandardDateCounter) Snapshot() DateCounter {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
tmp := newStandardDateCounter(c.reserveDays)
|
tmp := newStandardDateCounterWithClock(c.reserveDays, c.clock)
|
||||||
for i := 0; i < int(c.reserveDays); i++ {
|
copy(tmp.counts, c.counts)
|
||||||
tmp.counts[i] = c.counts[i]
|
|
||||||
}
|
|
||||||
return tmp
|
return tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *StandardDateCounter) Clear() {
|
func (c *StandardDateCounter) Clear() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
for i := 0; i < int(c.reserveDays); i++ {
|
clear(c.counts)
|
||||||
c.counts[i] = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotate
|
// rotate
|
||||||
// Must hold the lock before calling this function.
|
// Must hold the lock before calling this function.
|
||||||
func (c *StandardDateCounter) rotate(now time.Time) {
|
func (c *StandardDateCounter) rotate(now time.Time) {
|
||||||
now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
now = startOfDay(now)
|
||||||
days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
|
days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
|
||||||
|
reserveDays := int(c.reserveDays)
|
||||||
defer func() {
|
|
||||||
c.lastUpdateDate = now
|
|
||||||
}()
|
|
||||||
|
|
||||||
if days <= 0 {
|
if days <= 0 {
|
||||||
return
|
return
|
||||||
} else if days >= int(c.reserveDays) {
|
} else if days >= reserveDays {
|
||||||
c.counts = make([]int64, c.reserveDays)
|
c.counts = make([]int64, c.reserveDays)
|
||||||
|
c.lastUpdateDate = now
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newCounts := make([]int64, c.reserveDays)
|
newCounts := make([]int64, c.reserveDays)
|
||||||
|
|
||||||
for i := days; i < int(c.reserveDays); i++ {
|
copy(newCounts[days:], c.counts[:reserveDays-days])
|
||||||
newCounts[i] = c.counts[i-days]
|
|
||||||
}
|
|
||||||
c.counts = newCounts
|
c.counts = newCounts
|
||||||
|
c.lastUpdateDate = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// startOfDay returns midnight in t's location.
|
||||||
|
func startOfDay(t time.Time) time.Time {
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package metric
|
package metric
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
clocktesting "k8s.io/utils/clock/testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDateCounter(t *testing.T) {
|
func TestDateCounter(t *testing.T) {
|
||||||
@@ -25,3 +28,107 @@ func TestDateCounter(t *testing.T) {
|
|||||||
dcTmp := dc.Snapshot()
|
dcTmp := dc.Snapshot()
|
||||||
require.EqualValues(5, dcTmp.TodayCount())
|
require.EqualValues(5, dcTmp.TodayCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDateCounterRotate(t *testing.T) {
|
||||||
|
loc := time.FixedZone("test", 8*60*60)
|
||||||
|
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
now time.Time
|
||||||
|
want []int64
|
||||||
|
wantLastUpdateDate time.Time
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "same day",
|
||||||
|
now: time.Date(2026, time.May, 8, 12, 30, 0, 0, loc),
|
||||||
|
want: []int64{10, 7, 3},
|
||||||
|
wantLastUpdateDate: lastUpdateDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clock skew",
|
||||||
|
now: time.Date(2026, time.May, 7, 12, 30, 0, 0, loc),
|
||||||
|
want: []int64{10, 7, 3},
|
||||||
|
wantLastUpdateDate: lastUpdateDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one day",
|
||||||
|
now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc),
|
||||||
|
want: []int64{0, 10, 7},
|
||||||
|
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, loc),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two days",
|
||||||
|
now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc),
|
||||||
|
want: []int64{0, 0, 10},
|
||||||
|
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, loc),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all reserved days elapsed",
|
||||||
|
now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc),
|
||||||
|
want: []int64{0, 0, 0},
|
||||||
|
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, loc),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
dc := newStandardDateCounter(3)
|
||||||
|
dc.counts = []int64{10, 7, 3}
|
||||||
|
dc.lastUpdateDate = lastUpdateDate
|
||||||
|
|
||||||
|
dc.mu.Lock()
|
||||||
|
dc.rotate(tt.now)
|
||||||
|
dc.mu.Unlock()
|
||||||
|
|
||||||
|
require.Equal(tt.want, dc.counts)
|
||||||
|
require.Equal(tt.wantLastUpdateDate, dc.lastUpdateDate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDateCounterGetLastDaysCountReturnsCopy(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
clk := clocktesting.NewFakeClock(time.Date(2026, time.May, 8, 12, 30, 0, 0, time.Local))
|
||||||
|
dc := newStandardDateCounterWithClock(3, clk)
|
||||||
|
dc.counts = []int64{10, 7, 3}
|
||||||
|
|
||||||
|
counts := dc.GetLastDaysCount(2)
|
||||||
|
require.Equal([]int64{10, 7}, counts)
|
||||||
|
|
||||||
|
counts[0] = 100
|
||||||
|
require.Equal([]int64{10, 7}, dc.GetLastDaysCount(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDateCounterClear(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
dc := newStandardDateCounter(3)
|
||||||
|
dc.counts = []int64{10, 7, 3}
|
||||||
|
|
||||||
|
dc.Clear()
|
||||||
|
|
||||||
|
require.Equal([]int64{0, 0, 0}, dc.counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDateCounterConcurrentAccess(t *testing.T) {
|
||||||
|
clk := clocktesting.NewFakeClock(time.Date(2026, time.May, 8, 12, 30, 0, 0, time.Local))
|
||||||
|
dc := newStandardDateCounterWithClock(3, clk)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 8 {
|
||||||
|
wg.Go(func() {
|
||||||
|
for range 100 {
|
||||||
|
dc.Inc(1)
|
||||||
|
dc.Dec(1)
|
||||||
|
_ = dc.TodayCount()
|
||||||
|
_ = dc.GetLastDaysCount(3)
|
||||||
|
_ = dc.Snapshot()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|||||||
+92
-3
@@ -16,14 +16,16 @@ package net
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/crypto"
|
libcrypto "github.com/fatedier/golib/crypto"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
)
|
)
|
||||||
@@ -241,8 +243,8 @@ func (conn *wrapQuicStream) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {
|
func NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {
|
||||||
encReader := crypto.NewReader(rw, key)
|
encReader := libcrypto.NewReader(rw, key)
|
||||||
encWriter, err := crypto.NewWriter(rw, key)
|
encWriter, err := libcrypto.NewWriter(rw, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -254,3 +256,90 @@ func NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {
|
|||||||
Writer: encWriter,
|
Writer: encWriter,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AEADCryptoRole int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AEADCryptoRoleClient AEADCryptoRole = iota + 1
|
||||||
|
AEADCryptoRoleServer
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
aeadControlHKDFInfoPrefix = "frp wire v2 control aead"
|
||||||
|
aeadDirectionClientToServer = "client-to-server"
|
||||||
|
aeadDirectionServerToClient = "server-to-client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewAEADCryptoReadWriter wraps rw with framed AEAD encryption for the v2
|
||||||
|
// control channel. Frames and their order are authenticated, but end-of-stream
|
||||||
|
// is not: a clean EOF at a frame boundary is returned as normal EOF by the
|
||||||
|
// underlying AEAD stream. Protocols that need truncation detection for finite
|
||||||
|
// objects must add their own authenticated final message.
|
||||||
|
func NewAEADCryptoReadWriter(
|
||||||
|
rw io.ReadWriter,
|
||||||
|
key []byte,
|
||||||
|
role AEADCryptoRole,
|
||||||
|
algorithm string,
|
||||||
|
transcriptHash []byte,
|
||||||
|
) (io.ReadWriter, error) {
|
||||||
|
clientToServerKey, serverToClientKey, err := deriveAEADControlKeys(key, algorithm, transcriptHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var readKey, writeKey []byte
|
||||||
|
switch role {
|
||||||
|
case AEADCryptoRoleClient:
|
||||||
|
readKey = serverToClientKey
|
||||||
|
writeKey = clientToServerKey
|
||||||
|
case AEADCryptoRoleServer:
|
||||||
|
readKey = clientToServerKey
|
||||||
|
writeKey = serverToClientKey
|
||||||
|
default:
|
||||||
|
return nil, errors.New("invalid aead crypto role")
|
||||||
|
}
|
||||||
|
|
||||||
|
encReader, err := libcrypto.NewAEADStreamReader(rw, libcrypto.AEADStreamOptions{
|
||||||
|
Algorithm: libcrypto.AEADAlgorithm(algorithm),
|
||||||
|
Key: readKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
encWriter, err := libcrypto.NewAEADStreamWriter(rw, libcrypto.AEADStreamOptions{
|
||||||
|
Algorithm: libcrypto.AEADAlgorithm(algorithm),
|
||||||
|
Key: writeKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return struct {
|
||||||
|
io.Reader
|
||||||
|
io.Writer
|
||||||
|
}{
|
||||||
|
Reader: encReader,
|
||||||
|
Writer: encWriter,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveAEADControlKeys(key []byte, algorithm string, transcriptHash []byte) (clientToServerKey, serverToClientKey []byte, err error) {
|
||||||
|
clientToServerKey, err = deriveAEADControlKey(key, algorithm, transcriptHash, aeadDirectionClientToServer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
serverToClientKey, err = deriveAEADControlKey(key, algorithm, transcriptHash, aeadDirectionServerToClient)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return clientToServerKey, serverToClientKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveAEADControlKey(key []byte, algorithm string, transcriptHash []byte, direction string) ([]byte, error) {
|
||||||
|
info := []byte(aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction)
|
||||||
|
reader := hkdf.New(sha256.New, key, transcriptHash, info)
|
||||||
|
out := make([]byte, libcrypto.AEADKeySize)
|
||||||
|
if _, err := io.ReadFull(reader, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// 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"
|
||||||
|
"io"
|
||||||
|
stdnet "net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAEADCryptoReadWriterRoundTrip(t *testing.T) {
|
||||||
|
clientConn, serverConn := stdnet.Pipe()
|
||||||
|
defer clientConn.Close()
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
key := []byte("token")
|
||||||
|
transcriptHash := bytes.Repeat([]byte{0x11}, 32)
|
||||||
|
clientRW, err := NewAEADCryptoReadWriter(
|
||||||
|
clientConn,
|
||||||
|
key,
|
||||||
|
AEADCryptoRoleClient,
|
||||||
|
wire.AEADAlgorithmXChaCha20Poly1305,
|
||||||
|
transcriptHash,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
serverRW, err := NewAEADCryptoReadWriter(
|
||||||
|
serverConn,
|
||||||
|
key,
|
||||||
|
AEADCryptoRoleServer,
|
||||||
|
wire.AEADAlgorithmXChaCha20Poly1305,
|
||||||
|
transcriptHash,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
clientErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
if _, err := clientRW.Write([]byte("ping")); err != nil {
|
||||||
|
clientErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, len("pong"))
|
||||||
|
_, err := io.ReadFull(clientRW, buf)
|
||||||
|
clientErrCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
buf := make([]byte, len("ping"))
|
||||||
|
_, err = io.ReadFull(serverRW, buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "ping", string(buf))
|
||||||
|
_, err = serverRW.Write([]byte("pong"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, <-clientErrCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAEADCryptoReadWriterRejectsDifferentTranscript(t *testing.T) {
|
||||||
|
clientConn, serverConn := stdnet.Pipe()
|
||||||
|
defer clientConn.Close()
|
||||||
|
defer serverConn.Close()
|
||||||
|
require.NoError(t, clientConn.SetDeadline(time.Now().Add(time.Second)))
|
||||||
|
require.NoError(t, serverConn.SetDeadline(time.Now().Add(time.Second)))
|
||||||
|
|
||||||
|
key := []byte("token")
|
||||||
|
clientRW, err := NewAEADCryptoReadWriter(
|
||||||
|
clientConn,
|
||||||
|
key,
|
||||||
|
AEADCryptoRoleClient,
|
||||||
|
wire.AEADAlgorithmAES256GCM,
|
||||||
|
bytes.Repeat([]byte{0x22}, 32),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
serverRW, err := NewAEADCryptoReadWriter(
|
||||||
|
serverConn,
|
||||||
|
key,
|
||||||
|
AEADCryptoRoleServer,
|
||||||
|
wire.AEADAlgorithmAES256GCM,
|
||||||
|
bytes.Repeat([]byte{0x33}, 32),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
writeErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := clientRW.Write([]byte("ping"))
|
||||||
|
writeErrCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
buf := make([]byte, len("ping"))
|
||||||
|
_, err = io.ReadFull(serverRW, buf)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NoError(t, <-writeErrCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAEADControlKeysUsesDistinctDirections(t *testing.T) {
|
||||||
|
clientToServerKey, serverToClientKey, err := deriveAEADControlKeys(
|
||||||
|
[]byte("token"),
|
||||||
|
wire.AEADAlgorithmXChaCha20Poly1305,
|
||||||
|
bytes.Repeat([]byte{0x44}, 32),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, clientToServerKey, serverToClientKey)
|
||||||
|
}
|
||||||
@@ -39,11 +39,14 @@ type HTTPConnectTCPMuxer struct {
|
|||||||
func NewHTTPConnectTCPMuxer(listener net.Listener, passthrough bool, timeout time.Duration) (*HTTPConnectTCPMuxer, error) {
|
func NewHTTPConnectTCPMuxer(listener net.Listener, passthrough bool, timeout time.Duration) (*HTTPConnectTCPMuxer, error) {
|
||||||
ret := &HTTPConnectTCPMuxer{passthrough: passthrough}
|
ret := &HTTPConnectTCPMuxer{passthrough: passthrough}
|
||||||
mux, err := vhost.NewMuxer(listener, ret.getHostFromHTTPConnect, timeout)
|
mux, err := vhost.NewMuxer(listener, ret.getHostFromHTTPConnect, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
mux.SetCheckAuthFunc(ret.auth).
|
mux.SetCheckAuthFunc(ret.auth).
|
||||||
SetSuccessHookFunc(ret.sendConnectResponse).
|
SetSuccessHookFunc(ret.sendConnectResponse).
|
||||||
SetFailHookFunc(vhostFailed)
|
SetFailHookFunc(vhostFailed)
|
||||||
ret.Muxer = mux
|
ret.Muxer = mux
|
||||||
return ret, err
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (muxer *HTTPConnectTCPMuxer) readHTTPConnectRequest(rd io.Reader) (host, httpUser, httpPwd string, err error) {
|
func (muxer *HTTPConnectTCPMuxer) readHTTPConnectRequest(rd io.Reader) (host, httpUser, httpPwd string, err error) {
|
||||||
|
|||||||
+2
-47
@@ -24,7 +24,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
libio "github.com/fatedier/golib/io"
|
libio "github.com/fatedier/golib/io"
|
||||||
@@ -160,7 +159,7 @@ func (rp *HTTPReverseProxy) UnRegister(routeCfg RouteConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser string) *RouteConfig {
|
func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser string) *RouteConfig {
|
||||||
vr, ok := rp.getVhost(domain, location, routeByHTTPUser)
|
vr, ok := rp.vhostRouter.getByRoute(domain, location, routeByHTTPUser)
|
||||||
if ok {
|
if ok {
|
||||||
log.Debugf("get new http request host [%s] path [%s] httpuser [%s]", domain, location, routeByHTTPUser)
|
log.Debugf("get new http request host [%s] path [%s] httpuser [%s]", domain, location, routeByHTTPUser)
|
||||||
return vr.payload.(*RouteConfig)
|
return vr.payload.(*RouteConfig)
|
||||||
@@ -171,7 +170,7 @@ func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser str
|
|||||||
// CreateConnection create a new connection by route config
|
// CreateConnection create a new connection by route config
|
||||||
func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byEndpoint bool) (net.Conn, error) {
|
func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byEndpoint bool) (net.Conn, error) {
|
||||||
host, _ := httppkg.CanonicalHost(reqRouteInfo.Host)
|
host, _ := httppkg.CanonicalHost(reqRouteInfo.Host)
|
||||||
vr, ok := rp.getVhost(host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
|
vr, ok := rp.vhostRouter.getByRoute(host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
|
||||||
if ok {
|
if ok {
|
||||||
if byEndpoint {
|
if byEndpoint {
|
||||||
fn := vr.payload.(*RouteConfig).CreateConnByEndpointFn
|
fn := vr.payload.(*RouteConfig).CreateConnByEndpointFn
|
||||||
@@ -208,50 +207,6 @@ func checkRouteAuthByRequest(req *http.Request, rc *RouteConfig) bool {
|
|||||||
return ok && user == rc.Username && passwd == rc.Password
|
return ok && user == rc.Username && passwd == rc.Password
|
||||||
}
|
}
|
||||||
|
|
||||||
// getVhost tries to get vhost router by route policy.
|
|
||||||
func (rp *HTTPReverseProxy) getVhost(domain, location, routeByHTTPUser string) (*Router, bool) {
|
|
||||||
findRouter := func(inDomain, inLocation, inRouteByHTTPUser string) (*Router, bool) {
|
|
||||||
vr, ok := rp.vhostRouter.Get(inDomain, inLocation, inRouteByHTTPUser)
|
|
||||||
if ok {
|
|
||||||
return vr, ok
|
|
||||||
}
|
|
||||||
// Try to check if there is one proxy that doesn't specify routerByHTTPUser, it means match all.
|
|
||||||
vr, ok = rp.vhostRouter.Get(inDomain, inLocation, "")
|
|
||||||
if ok {
|
|
||||||
return vr, ok
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// First we check the full hostname
|
|
||||||
// if not exist, then check the wildcard_domain such as *.example.com
|
|
||||||
vr, ok := findRouter(domain, location, routeByHTTPUser)
|
|
||||||
if ok {
|
|
||||||
return vr, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// e.g. domain = test.example.com, try to match wildcard domains.
|
|
||||||
// *.example.com
|
|
||||||
// *.com
|
|
||||||
domainSplit := strings.Split(domain, ".")
|
|
||||||
for len(domainSplit) >= 3 {
|
|
||||||
domainSplit[0] = "*"
|
|
||||||
domain = strings.Join(domainSplit, ".")
|
|
||||||
vr, ok = findRouter(domain, location, routeByHTTPUser)
|
|
||||||
if ok {
|
|
||||||
return vr, true
|
|
||||||
}
|
|
||||||
domainSplit = domainSplit[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally, try to check if there is one proxy that domain is "*" means match all domains.
|
|
||||||
vr, ok = findRouter("*", location, routeByHTTPUser)
|
|
||||||
if ok {
|
|
||||||
return vr, true
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Request) {
|
func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Request) {
|
||||||
hj, ok := rw.(http.Hijacker)
|
hj, ok := rw.(http.Hijacker)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ type HTTPSMuxer struct {
|
|||||||
|
|
||||||
func NewHTTPSMuxer(listener net.Listener, timeout time.Duration) (*HTTPSMuxer, error) {
|
func NewHTTPSMuxer(listener net.Listener, timeout time.Duration) (*HTTPSMuxer, error) {
|
||||||
mux, err := NewMuxer(listener, GetHTTPSHostname, timeout)
|
mux, err := NewMuxer(listener, GetHTTPSHostname, timeout)
|
||||||
mux.SetFailHookFunc(vhostFailed)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &HTTPSMuxer{mux}, err
|
mux.SetFailHookFunc(vhostFailed)
|
||||||
|
return &HTTPSMuxer{mux}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHTTPSHostname(c net.Conn) (_ net.Conn, _ map[string]string, err error) {
|
func GetHTTPSHostname(c net.Conn) (_ net.Conn, _ map[string]string, err error) {
|
||||||
|
|||||||
@@ -16,23 +16,53 @@ func TestGetHTTPSHostname(t *testing.T) {
|
|||||||
require.NoError(err)
|
require.NoError(err)
|
||||||
defer l.Close()
|
defer l.Close()
|
||||||
|
|
||||||
var conn net.Conn
|
connCh := make(chan net.Conn, 1)
|
||||||
|
acceptErrCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
conn, _ = l.Accept()
|
conn, err := l.Accept()
|
||||||
require.NotNil(conn)
|
if err != nil {
|
||||||
|
acceptErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connCh <- conn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
clientErrCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
tls.Dial("tcp", l.Addr().String(), &tls.Config{
|
conn, err := tls.Dial("tcp", l.Addr().String(), &tls.Config{
|
||||||
InsecureSkipVerify: true,
|
InsecureSkipVerify: true,
|
||||||
ServerName: "example.com",
|
ServerName: "example.com",
|
||||||
})
|
})
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
clientErrCh <- err
|
||||||
}()
|
}()
|
||||||
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
var conn net.Conn
|
||||||
_, infos, err := GetHTTPSHostname(conn)
|
select {
|
||||||
|
case conn = <-connCh:
|
||||||
|
case err := <-acceptErrCh:
|
||||||
|
require.NoError(err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for accepted connection")
|
||||||
|
}
|
||||||
|
require.NotNil(conn)
|
||||||
|
|
||||||
|
serverConn, infos, err := GetHTTPSHostname(conn)
|
||||||
|
if serverConn != nil {
|
||||||
|
_ = serverConn.Close()
|
||||||
|
} else {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
require.NoError(err)
|
require.NoError(err)
|
||||||
require.Equal("example.com", infos["Host"])
|
require.Equal("example.com", infos["Host"])
|
||||||
require.Equal("https", infos["Scheme"])
|
require.Equal("https", infos["Scheme"])
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-clientErrCh:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for TLS client")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,12 +93,19 @@ func (r *Routers) Del(domain, location, httpUser string) {
|
|||||||
routersByHTTPUser[httpUser] = newVrs
|
routersByHTTPUser[httpUser] = newVrs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get returns the best location match for an exact host and exact HTTP user.
|
||||||
|
// It does not apply all-users, wildcard-domain, or catch-all-domain fallback.
|
||||||
func (r *Routers) Get(host, path, httpUser string) (vr *Router, exist bool) {
|
func (r *Routers) Get(host, path, httpUser string) (vr *Router, exist bool) {
|
||||||
host = strings.ToLower(host)
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
r.mutex.RLock()
|
r.mutex.RLock()
|
||||||
defer r.mutex.RUnlock()
|
defer r.mutex.RUnlock()
|
||||||
|
|
||||||
|
return r.getLocked(host, path, httpUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLocked performs an exact-host lookup; host must already be lower-cased.
|
||||||
|
func (r *Routers) getLocked(host, path, httpUser string) (vr *Router, exist bool) {
|
||||||
routersByHTTPUser, found := r.indexByDomain[host]
|
routersByHTTPUser, found := r.indexByDomain[host]
|
||||||
if !found {
|
if !found {
|
||||||
return
|
return
|
||||||
@@ -117,6 +124,49 @@ func (r *Routers) Get(host, path, httpUser string) (vr *Router, exist bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Routers) getByRoute(host, path, httpUser string) (*Router, bool) {
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
|
r.mutex.RLock()
|
||||||
|
defer r.mutex.RUnlock()
|
||||||
|
|
||||||
|
// First we check the full hostname; if it doesn't exist, then check wildcard domains.
|
||||||
|
// For example, test.example.com checks *.example.com before falling back to "*".
|
||||||
|
vr, ok := r.getExactOrAllUsersLocked(host, path, httpUser)
|
||||||
|
if ok {
|
||||||
|
return vr, true
|
||||||
|
}
|
||||||
|
|
||||||
|
hostSplit := strings.Split(host, ".")
|
||||||
|
// Keep two-label hosts out of the wildcard walk, so example.com does not match *.com.
|
||||||
|
for len(hostSplit) >= 3 {
|
||||||
|
// Replace the leftmost remaining label with the wildcard marker.
|
||||||
|
hostSplit[0] = "*"
|
||||||
|
host = strings.Join(hostSplit, ".")
|
||||||
|
vr, ok = r.getExactOrAllUsersLocked(host, path, httpUser)
|
||||||
|
if ok {
|
||||||
|
return vr, true
|
||||||
|
}
|
||||||
|
hostSplit = hostSplit[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, try to check if there is one proxy whose domain is "*", which means match all domains.
|
||||||
|
return r.getExactOrAllUsersLocked("*", path, httpUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Routers) getExactOrAllUsersLocked(host, path, httpUser string) (*Router, bool) {
|
||||||
|
vr, ok := r.getLocked(host, path, httpUser)
|
||||||
|
if ok {
|
||||||
|
return vr, true
|
||||||
|
}
|
||||||
|
// Try to check if there is one proxy that doesn't specify routeByHTTPUser, it means match all.
|
||||||
|
vr, ok = r.getLocked(host, path, "")
|
||||||
|
if ok {
|
||||||
|
return vr, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Routers) exist(host, path, httpUser string) (route *Router, exist bool) {
|
func (r *Routers) exist(host, path, httpUser string) (route *Router, exist bool) {
|
||||||
routersByHTTPUser, found := r.indexByDomain[host]
|
routersByHTTPUser, found := r.indexByDomain[host]
|
||||||
if !found {
|
if !found {
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Copyright 2017 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 vhost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRoutersGet(t *testing.T) {
|
||||||
|
routers := NewRouters()
|
||||||
|
require.NoError(t, routers.Add("example.com", "/api", "alice", "exact-user"))
|
||||||
|
require.NoError(t, routers.Add("example.com", "/public", "", "exact-all-users"))
|
||||||
|
require.NoError(t, routers.Add("*.example.com", "/api", "", "wildcard-all-users"))
|
||||||
|
require.NoError(t, routers.Add("*", "/api", "", "all-domains"))
|
||||||
|
|
||||||
|
t.Run("exact host and user match with normalized domain", func(t *testing.T) {
|
||||||
|
router, ok := routers.Get("EXAMPLE.COM", "/api/users", "alice")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "exact-user", router.payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exact host and empty user match", func(t *testing.T) {
|
||||||
|
router, ok := routers.Get("EXAMPLE.COM", "/public/docs", "")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "exact-all-users", router.payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not fall back from named user to empty user", func(t *testing.T) {
|
||||||
|
// Get intentionally requires an exact HTTP user; route-level fallbacks live in getByRoute.
|
||||||
|
_, ok := routers.Get("EXAMPLE.COM", "/public/docs", "alice")
|
||||||
|
require.False(t, ok)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not fall back to wildcard domains", func(t *testing.T) {
|
||||||
|
_, ok := routers.Get("foo.example.com", "/api/users", "")
|
||||||
|
require.False(t, ok)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not fall back to catch-all domain", func(t *testing.T) {
|
||||||
|
_, ok := routers.Get("missing.test", "/api/users", "")
|
||||||
|
require.False(t, ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutersGetByRoute(t *testing.T) {
|
||||||
|
routers := NewRouters()
|
||||||
|
require.NoError(t, routers.Add("example.com", "/api", "alice", "exact-user"))
|
||||||
|
require.NoError(t, routers.Add("example.com", "/api", "", "exact-all-users"))
|
||||||
|
require.NoError(t, routers.Add("exact.example.com", "/api", "", "exact-subdomain"))
|
||||||
|
require.NoError(t, routers.Add("*.example.com", "/api", "", "wildcard-all-users"))
|
||||||
|
require.NoError(t, routers.Add("*.foo.example.com", "/api", "", "specific-wildcard"))
|
||||||
|
require.NoError(t, routers.Add("*.bar.com", "/api", "", "wildcard-parent-domain"))
|
||||||
|
require.NoError(t, routers.Add("*", "/admin", "root", "all-domains-user"))
|
||||||
|
require.NoError(t, routers.Add("*", "/", "", "all-domains"))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
location string
|
||||||
|
httpUser string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact domain and http user",
|
||||||
|
domain: "example.com",
|
||||||
|
location: "/api/users",
|
||||||
|
httpUser: "alice",
|
||||||
|
want: "exact-user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact domain falls back to all users",
|
||||||
|
domain: "example.com",
|
||||||
|
location: "/api/users",
|
||||||
|
httpUser: "bob",
|
||||||
|
want: "exact-all-users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard domain uses all users fallback",
|
||||||
|
domain: "foo.example.com",
|
||||||
|
location: "/api/users",
|
||||||
|
httpUser: "bob",
|
||||||
|
want: "wildcard-all-users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed-case domain is normalized",
|
||||||
|
domain: "Foo.Example.Com",
|
||||||
|
location: "/api/users",
|
||||||
|
httpUser: "bob",
|
||||||
|
want: "wildcard-all-users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact domain wins over wildcard domain",
|
||||||
|
domain: "exact.example.com",
|
||||||
|
location: "/api/users",
|
||||||
|
httpUser: "bob",
|
||||||
|
want: "exact-subdomain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "more specific wildcard wins over broader wildcard",
|
||||||
|
domain: "bar.foo.example.com",
|
||||||
|
location: "/api/users",
|
||||||
|
httpUser: "bob",
|
||||||
|
want: "specific-wildcard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard walk checks parent domains",
|
||||||
|
domain: "a.b.bar.com",
|
||||||
|
location: "/api/users",
|
||||||
|
httpUser: "bob",
|
||||||
|
want: "wildcard-parent-domain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "catch-all domain fallback",
|
||||||
|
domain: "foo.test.com",
|
||||||
|
location: "/other",
|
||||||
|
httpUser: "bob",
|
||||||
|
want: "all-domains",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "catch-all domain honors http user",
|
||||||
|
domain: "foo.test.com",
|
||||||
|
location: "/admin/panel",
|
||||||
|
httpUser: "root",
|
||||||
|
want: "all-domains-user",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
router, ok := routers.getByRoute(tt.domain, tt.location, tt.httpUser)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, tt.want, router.payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutersGetByRouteNoMatch(t *testing.T) {
|
||||||
|
routers := NewRouters()
|
||||||
|
require.NoError(t, routers.Add("*.example.com", "/api", "", "wildcard-all-users"))
|
||||||
|
require.NoError(t, routers.Add("*.com", "/api", "", "top-level-wildcard"))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
location string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "two-label domain does not enter wildcard walk",
|
||||||
|
domain: "example.com",
|
||||||
|
location: "/api/users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing catch-all remains no match",
|
||||||
|
domain: "foo.test.com",
|
||||||
|
location: "/api/users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong path remains no match",
|
||||||
|
domain: "foo.example.com",
|
||||||
|
location: "/other",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
router, ok := routers.getByRoute(tt.domain, tt.location, "bob")
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Nil(t, router)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutersConcurrentGetByRouteAndAdd(t *testing.T) {
|
||||||
|
routers := NewRouters()
|
||||||
|
require.NoError(t, routers.Add("*.example.com", "/api", "", "wildcard"))
|
||||||
|
|
||||||
|
const readers = 8
|
||||||
|
const iterations = 200
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
start := make(chan struct{})
|
||||||
|
errCh := make(chan error, readers+1)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for id := range readers {
|
||||||
|
wg.Go(func() {
|
||||||
|
<-start
|
||||||
|
for j := range iterations {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
router, ok := routers.getByRoute("foo.example.com", "/api/users", "")
|
||||||
|
if !ok || router == nil || router.payload != "wildcard" {
|
||||||
|
errCh <- fmt.Errorf("reader %d iteration %d got router=%v ok=%v", id, j, router, ok)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Go(func() {
|
||||||
|
<-start
|
||||||
|
for i := range iterations {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
err := routers.Add(fmt.Sprintf("host-%d.example.com", i), "/api", "", i)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
cancel()
|
||||||
|
t.Fatal("concurrent route lookup and add timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(errCh)
|
||||||
|
for err := range errCh {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-33
@@ -148,42 +148,10 @@ func (v *Muxer) Listen(ctx context.Context, cfg *RouteConfig) (l *Listener, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Muxer) getListener(name, path, httpUser string) (*Listener, bool) {
|
func (v *Muxer) getListener(name, path, httpUser string) (*Listener, bool) {
|
||||||
findRouter := func(inName, inPath, inHTTPUser string) (*Listener, bool) {
|
vr, ok := v.registryRouter.getByRoute(name, path, httpUser)
|
||||||
vr, ok := v.registryRouter.Get(inName, inPath, inHTTPUser)
|
|
||||||
if ok {
|
if ok {
|
||||||
return vr.payload.(*Listener), true
|
return vr.payload.(*Listener), true
|
||||||
}
|
}
|
||||||
// Try to check if there is one proxy that doesn't specify routerByHTTPUser, it means match all.
|
|
||||||
vr, ok = v.registryRouter.Get(inName, inPath, "")
|
|
||||||
if ok {
|
|
||||||
return vr.payload.(*Listener), true
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// first we check the full hostname
|
|
||||||
// if not exist, then check the wildcard_domain such as *.example.com
|
|
||||||
l, ok := findRouter(name, path, httpUser)
|
|
||||||
if ok {
|
|
||||||
return l, true
|
|
||||||
}
|
|
||||||
|
|
||||||
domainSplit := strings.Split(name, ".")
|
|
||||||
for len(domainSplit) >= 3 {
|
|
||||||
domainSplit[0] = "*"
|
|
||||||
name = strings.Join(domainSplit, ".")
|
|
||||||
|
|
||||||
l, ok = findRouter(name, path, httpUser)
|
|
||||||
if ok {
|
|
||||||
return l, true
|
|
||||||
}
|
|
||||||
domainSplit = domainSplit[1:]
|
|
||||||
}
|
|
||||||
// Finally, try to check if there is one proxy that domain is "*" means match all domains.
|
|
||||||
l, ok = findRouter("*", path, httpUser)
|
|
||||||
if ok {
|
|
||||||
return l, true
|
|
||||||
}
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/utils/clock"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ type FastBackoffOptions struct {
|
|||||||
|
|
||||||
type fastBackoffImpl struct {
|
type fastBackoffImpl struct {
|
||||||
options FastBackoffOptions
|
options FastBackoffOptions
|
||||||
|
clock clock.PassiveClock
|
||||||
|
|
||||||
lastCalledTime time.Time
|
lastCalledTime time.Time
|
||||||
consecutiveErrCount int
|
consecutiveErrCount int
|
||||||
@@ -57,18 +60,26 @@ type fastBackoffImpl struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewFastBackoffManager(options FastBackoffOptions) BackoffManager {
|
func NewFastBackoffManager(options FastBackoffOptions) BackoffManager {
|
||||||
|
return newFastBackoffManagerWithClock(options, clock.RealClock{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFastBackoffManagerWithClock(options FastBackoffOptions, clk clock.PassiveClock) BackoffManager {
|
||||||
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
return &fastBackoffImpl{
|
return &fastBackoffImpl{
|
||||||
options: options,
|
options: options,
|
||||||
|
clock: clk,
|
||||||
countsInFastRetryWindow: 1,
|
countsInFastRetryWindow: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fastBackoffImpl) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration {
|
func (f *fastBackoffImpl) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration {
|
||||||
if f.lastCalledTime.IsZero() {
|
if f.lastCalledTime.IsZero() {
|
||||||
f.lastCalledTime = time.Now()
|
f.lastCalledTime = f.clock.Now()
|
||||||
return f.options.Duration
|
return f.options.Duration
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := f.clock.Now()
|
||||||
f.lastCalledTime = now
|
f.lastCalledTime = now
|
||||||
|
|
||||||
if previousConditionError {
|
if previousConditionError {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package wait
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
clocktesting "k8s.io/utils/clock/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFastBackoffManagerUsesClock(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start)
|
||||||
|
backoff := newFastBackoffManagerWithClock(FastBackoffOptions{
|
||||||
|
Duration: time.Second,
|
||||||
|
}, clk).(*fastBackoffImpl)
|
||||||
|
|
||||||
|
require.Equal(time.Second, backoff.Backoff(0, false))
|
||||||
|
require.Equal(start, backoff.lastCalledTime)
|
||||||
|
|
||||||
|
next := start.Add(time.Minute)
|
||||||
|
clk.SetTime(next)
|
||||||
|
require.Equal(time.Second, backoff.Backoff(time.Second, false))
|
||||||
|
require.Equal(next, backoff.lastCalledTime)
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ import "strings"
|
|||||||
// LogWriter forwards writes to frp's logger at configurable level.
|
// LogWriter forwards writes to frp's logger at configurable level.
|
||||||
// It is safe for concurrent use as long as the underlying Logger is thread-safe.
|
// It is safe for concurrent use as long as the underlying Logger is thread-safe.
|
||||||
type LogWriter struct {
|
type LogWriter struct {
|
||||||
xl *Logger
|
|
||||||
logFunc func(string)
|
logFunc func(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,35 +30,30 @@ func (w LogWriter) Write(p []byte) (n int, err error) {
|
|||||||
|
|
||||||
func NewTraceWriter(xl *Logger) LogWriter {
|
func NewTraceWriter(xl *Logger) LogWriter {
|
||||||
return LogWriter{
|
return LogWriter{
|
||||||
xl: xl,
|
|
||||||
logFunc: func(msg string) { xl.Tracef("%s", msg) },
|
logFunc: func(msg string) { xl.Tracef("%s", msg) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDebugWriter(xl *Logger) LogWriter {
|
func NewDebugWriter(xl *Logger) LogWriter {
|
||||||
return LogWriter{
|
return LogWriter{
|
||||||
xl: xl,
|
|
||||||
logFunc: func(msg string) { xl.Debugf("%s", msg) },
|
logFunc: func(msg string) { xl.Debugf("%s", msg) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInfoWriter(xl *Logger) LogWriter {
|
func NewInfoWriter(xl *Logger) LogWriter {
|
||||||
return LogWriter{
|
return LogWriter{
|
||||||
xl: xl,
|
|
||||||
logFunc: func(msg string) { xl.Infof("%s", msg) },
|
logFunc: func(msg string) { xl.Infof("%s", msg) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWarnWriter(xl *Logger) LogWriter {
|
func NewWarnWriter(xl *Logger) LogWriter {
|
||||||
return LogWriter{
|
return LogWriter{
|
||||||
xl: xl,
|
|
||||||
logFunc: func(msg string) { xl.Warnf("%s", msg) },
|
logFunc: func(msg string) { xl.Warnf("%s", msg) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewErrorWriter(xl *Logger) LogWriter {
|
func NewErrorWriter(xl *Logger) LogWriter {
|
||||||
return LogWriter{
|
return LogWriter{
|
||||||
xl: xl,
|
|
||||||
logFunc: func(msg string) { xl.Errorf("%s", msg) },
|
logFunc: func(msg string) { xl.Errorf("%s", msg) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,7 +286,6 @@ func (r *clientRouter) addRoute(name string, routes []net.IPNet, conn io.ReadWri
|
|||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
r.routes[name] = &routeElement{
|
r.routes[name] = &routeElement{
|
||||||
name: name,
|
|
||||||
routes: routes,
|
routes: routes,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
}
|
}
|
||||||
@@ -383,7 +382,6 @@ func (r *serverRouter) cleanupConnIPs(conn io.Writer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type routeElement struct {
|
type routeElement struct {
|
||||||
name string
|
|
||||||
routes []net.IPNet
|
routes []net.IPNet
|
||||||
conn io.ReadWriteCloser
|
conn io.ReadWriteCloser
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-15
@@ -7,6 +7,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/utils/clock"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/types"
|
"github.com/fatedier/frp/pkg/config/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,16 +40,25 @@ type Manager struct {
|
|||||||
|
|
||||||
bindAddr string
|
bindAddr string
|
||||||
netType string
|
netType string
|
||||||
|
clock clock.WithTicker
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(netType string, bindAddr string, allowPorts []types.PortsRange) *Manager {
|
func NewManager(netType string, bindAddr string, allowPorts []types.PortsRange) *Manager {
|
||||||
|
return newManagerWithClock(netType, bindAddr, allowPorts, clock.RealClock{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newManagerWithClock(netType string, bindAddr string, allowPorts []types.PortsRange, clk clock.WithTicker) *Manager {
|
||||||
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
pm := &Manager{
|
pm := &Manager{
|
||||||
reservedPorts: make(map[string]*PortCtx),
|
reservedPorts: make(map[string]*PortCtx),
|
||||||
usedPorts: make(map[int]*PortCtx),
|
usedPorts: make(map[int]*PortCtx),
|
||||||
freePorts: make(map[int]struct{}),
|
freePorts: make(map[int]struct{}),
|
||||||
bindAddr: bindAddr,
|
bindAddr: bindAddr,
|
||||||
netType: netType,
|
netType: netType,
|
||||||
|
clock: clk,
|
||||||
}
|
}
|
||||||
if len(allowPorts) > 0 {
|
if len(allowPorts) > 0 {
|
||||||
for _, pair := range allowPorts {
|
for _, pair := range allowPorts {
|
||||||
@@ -72,7 +83,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
|
|||||||
portCtx := &PortCtx{
|
portCtx := &PortCtx{
|
||||||
ProxyName: name,
|
ProxyName: name,
|
||||||
Closed: false,
|
Closed: false,
|
||||||
UpdateTime: time.Now(),
|
UpdateTime: pm.clock.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
var ok bool
|
var ok bool
|
||||||
@@ -90,9 +101,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
|
|||||||
if ctx, ok := pm.reservedPorts[name]; ok {
|
if ctx, ok := pm.reservedPorts[name]; ok {
|
||||||
if pm.isPortAvailable(ctx.Port) {
|
if pm.isPortAvailable(ctx.Port) {
|
||||||
realPort = ctx.Port
|
realPort = ctx.Port
|
||||||
pm.usedPorts[realPort] = portCtx
|
pm.markPortAcquiredLocked(name, realPort, portCtx)
|
||||||
pm.reservedPorts[name] = portCtx
|
|
||||||
delete(pm.freePorts, realPort)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,9 +118,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
|
|||||||
}
|
}
|
||||||
if pm.isPortAvailable(k) {
|
if pm.isPortAvailable(k) {
|
||||||
realPort = k
|
realPort = k
|
||||||
pm.usedPorts[realPort] = portCtx
|
pm.markPortAcquiredLocked(name, realPort, portCtx)
|
||||||
pm.reservedPorts[name] = portCtx
|
|
||||||
delete(pm.freePorts, realPort)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,9 +130,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
|
|||||||
if _, ok = pm.freePorts[port]; ok {
|
if _, ok = pm.freePorts[port]; ok {
|
||||||
if pm.isPortAvailable(port) {
|
if pm.isPortAvailable(port) {
|
||||||
realPort = port
|
realPort = port
|
||||||
pm.usedPorts[realPort] = portCtx
|
pm.markPortAcquiredLocked(name, realPort, portCtx)
|
||||||
pm.reservedPorts[name] = portCtx
|
|
||||||
delete(pm.freePorts, realPort)
|
|
||||||
} else {
|
} else {
|
||||||
err = ErrPortUnAvailable
|
err = ErrPortUnAvailable
|
||||||
}
|
}
|
||||||
@@ -140,6 +145,13 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markPortAcquiredLocked records a successful acquisition. pm.mu must be held.
|
||||||
|
func (pm *Manager) markPortAcquiredLocked(name string, port int, portCtx *PortCtx) {
|
||||||
|
pm.usedPorts[port] = portCtx
|
||||||
|
pm.reservedPorts[name] = portCtx
|
||||||
|
delete(pm.freePorts, port)
|
||||||
|
}
|
||||||
|
|
||||||
func (pm *Manager) isPortAvailable(port int) bool {
|
func (pm *Manager) isPortAvailable(port int) bool {
|
||||||
if pm.netType == "udp" {
|
if pm.netType == "udp" {
|
||||||
addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(pm.bindAddr, strconv.Itoa(port)))
|
addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(pm.bindAddr, strconv.Itoa(port)))
|
||||||
@@ -169,20 +181,36 @@ func (pm *Manager) Release(port int) {
|
|||||||
pm.freePorts[port] = struct{}{}
|
pm.freePorts[port] = struct{}{}
|
||||||
delete(pm.usedPorts, port)
|
delete(pm.usedPorts, port)
|
||||||
ctx.Closed = true
|
ctx.Closed = true
|
||||||
ctx.UpdateTime = time.Now()
|
ctx.UpdateTime = pm.clock.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release reserved port if it isn't used in last 24 hours.
|
// Release reserved port if it isn't used in last 24 hours.
|
||||||
func (pm *Manager) cleanReservedPortsWorker() {
|
func (pm *Manager) cleanReservedPortsWorker() {
|
||||||
|
pm.cleanReservedPortsWorkerUntil(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *Manager) cleanReservedPortsWorkerUntil(stopCh <-chan struct{}) {
|
||||||
|
ticker := pm.clock.NewTicker(CleanReservedPortsInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
time.Sleep(CleanReservedPortsInterval)
|
select {
|
||||||
|
case <-ticker.C():
|
||||||
|
pm.cleanReservedPortsOnce()
|
||||||
|
case <-stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *Manager) cleanReservedPortsOnce() {
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
for name, ctx := range pm.reservedPorts {
|
for name, ctx := range pm.reservedPorts {
|
||||||
if ctx.Closed && time.Since(ctx.UpdateTime) > MaxPortReservedDuration {
|
if ctx.Closed && pm.clock.Since(ctx.UpdateTime) > MaxPortReservedDuration {
|
||||||
delete(pm.reservedPorts, name)
|
delete(pm.reservedPorts, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pm.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
clocktesting "k8s.io/utils/clock/testing"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/config/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagerUsesClockForPortTimestamps(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
port := freeTCPPort(t)
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start)
|
||||||
|
pm := newManagerWithClock("tcp", "127.0.0.1", []types.PortsRange{{Single: port}}, clk)
|
||||||
|
|
||||||
|
realPort, err := pm.Acquire("proxy", port)
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(port, realPort)
|
||||||
|
require.Equal(start, pm.usedPorts[port].UpdateTime)
|
||||||
|
|
||||||
|
releasedAt := start.Add(time.Minute)
|
||||||
|
clk.SetTime(releasedAt)
|
||||||
|
pm.Release(port)
|
||||||
|
|
||||||
|
require.Equal(releasedAt, pm.reservedPorts["proxy"].UpdateTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerCleanReservedPortsWorkerUsesClockTicker(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
port := freeTCPPort(t)
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start)
|
||||||
|
pm := newManagerWithClock("tcp", "127.0.0.1", []types.PortsRange{{Single: port}}, clk)
|
||||||
|
|
||||||
|
realPort, err := pm.Acquire("proxy", port)
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(port, realPort)
|
||||||
|
pm.Release(port)
|
||||||
|
require.True(pm.hasReservedPort("proxy"))
|
||||||
|
|
||||||
|
require.Eventually(clk.HasWaiters, time.Second, time.Millisecond)
|
||||||
|
clk.Step(MaxPortReservedDuration + CleanReservedPortsInterval + time.Minute)
|
||||||
|
|
||||||
|
require.Eventually(func() bool {
|
||||||
|
return !pm.hasReservedPort("proxy")
|
||||||
|
}, time.Second, time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *Manager) hasReservedPort(name string) bool {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
_, ok := pm.reservedPorts[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func freeTCPPort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
return listener.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/utils/clock"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClientInfo captures metadata about a connected frpc instance.
|
// ClientInfo captures metadata about a connected frpc instance.
|
||||||
@@ -42,12 +44,21 @@ type ClientRegistry struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
clients map[string]*ClientInfo
|
clients map[string]*ClientInfo
|
||||||
runIndex map[string]string
|
runIndex map[string]string
|
||||||
|
clock clock.PassiveClock
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClientRegistry() *ClientRegistry {
|
func NewClientRegistry() *ClientRegistry {
|
||||||
|
return newClientRegistryWithClock(clock.RealClock{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClientRegistryWithClock(clk clock.PassiveClock) *ClientRegistry {
|
||||||
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
return &ClientRegistry{
|
return &ClientRegistry{
|
||||||
clients: make(map[string]*ClientInfo),
|
clients: make(map[string]*ClientInfo),
|
||||||
runIndex: make(map[string]string),
|
runIndex: make(map[string]string),
|
||||||
|
clock: clk,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +75,7 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version,
|
|||||||
key = cr.composeClientKey(user, effectiveID)
|
key = cr.composeClientKey(user, effectiveID)
|
||||||
enforceUnique := rawClientID != ""
|
enforceUnique := rawClientID != ""
|
||||||
|
|
||||||
now := time.Now()
|
now := cr.clock.Now()
|
||||||
cr.mu.Lock()
|
cr.mu.Lock()
|
||||||
defer cr.mu.Unlock()
|
defer cr.mu.Unlock()
|
||||||
|
|
||||||
@@ -116,7 +127,7 @@ func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
|||||||
} else {
|
} else {
|
||||||
info.RunID = ""
|
info.RunID = ""
|
||||||
info.Online = false
|
info.Online = false
|
||||||
now := time.Now()
|
now := cr.clock.Now()
|
||||||
info.DisconnectedAt = now
|
info.DisconnectedAt = now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ package registry
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
clocktesting "k8s.io/utils/clock/testing"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/proto/wire"
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
)
|
)
|
||||||
@@ -35,3 +38,37 @@ func TestClientRegistryRegisterStoresWireProtocol(t *testing.T) {
|
|||||||
t.Fatalf("wire protocol mismatch, want %q got %q", wire.ProtocolV2, info.WireProtocol)
|
t.Fatalf("wire protocol mismatch, want %q got %q", wire.ProtocolV2, info.WireProtocol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientRegistryUsesClockForTimestamps(t *testing.T) {
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start)
|
||||||
|
registry := newClientRegistryWithClock(clk)
|
||||||
|
|
||||||
|
key, conflict := registry.Register("user", "client-id", "run-id", "host", "1.0.0", "127.0.0.1", wire.ProtocolV2)
|
||||||
|
if conflict {
|
||||||
|
t.Fatal("unexpected client conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := registry.GetByKey(key)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("client %q not found", key)
|
||||||
|
}
|
||||||
|
if !info.FirstConnectedAt.Equal(start) {
|
||||||
|
t.Fatalf("first connected time mismatch, want %s got %s", start, info.FirstConnectedAt)
|
||||||
|
}
|
||||||
|
if !info.LastConnectedAt.Equal(start) {
|
||||||
|
t.Fatalf("last connected time mismatch, want %s got %s", start, info.LastConnectedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedAt := start.Add(time.Minute)
|
||||||
|
clk.SetTime(disconnectedAt)
|
||||||
|
registry.MarkOfflineByRunID("run-id")
|
||||||
|
|
||||||
|
info, ok = registry.GetByKey(key)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("client %q not found after disconnect", key)
|
||||||
|
}
|
||||||
|
if !info.DisconnectedAt.Equal(disconnectedAt) {
|
||||||
|
t.Fatalf("disconnected time mismatch, want %s got %s", disconnectedAt, info.DisconnectedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+60
-11
@@ -60,6 +60,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
connReadTimeout time.Duration = 10 * time.Second
|
connReadTimeout time.Duration = 10 * time.Second
|
||||||
|
connWriteTimeout time.Duration = 5 * time.Second
|
||||||
vhostReadWriteTimeout time.Duration = 30 * time.Second
|
vhostReadWriteTimeout time.Duration = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -456,7 +457,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
controlConn := acceptedConn.conn
|
controlConn := acceptedConn.conn
|
||||||
if !internal {
|
if !internal {
|
||||||
var controlRW io.ReadWriter
|
var controlRW io.ReadWriter
|
||||||
controlRW, err = netpkg.NewCryptoReadWriter(conn, svr.auth.EncryptionKey())
|
controlRW, err = acceptedConn.newControlReadWriter(conn, svr.auth.EncryptionKey())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
controlConn = acceptedConn.messageConnFor(controlRW)
|
controlConn = acceptedConn.messageConnFor(controlRW)
|
||||||
}
|
}
|
||||||
@@ -468,17 +469,23 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("register control error: %v", err)
|
xl.Warnf("register control error: %v", err)
|
||||||
_ = acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
if writeErr := writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||||
|
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
Error: util.GenerateResponseErrorString("register control error", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
|
Error: util.GenerateResponseErrorString("register control error", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
|
||||||
})
|
})
|
||||||
|
}); writeErr != nil {
|
||||||
|
xl.Warnf("write login error response error: %v", writeErr)
|
||||||
|
}
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err = acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
if err = writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||||
|
return acceptedConn.conn.WriteMsg(&msg.LoginResp{
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
RunID: ctl.runID,
|
RunID: ctl.runID,
|
||||||
Error: "",
|
Error: "",
|
||||||
|
})
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
xl.Warnf("write login response error: %v", err)
|
xl.Warnf("write login response error: %v", err)
|
||||||
svr.ctlManager.Del(m.RunID, ctl)
|
svr.ctlManager.Del(m.RunID, ctl)
|
||||||
@@ -523,6 +530,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
|
|||||||
type acceptedConnection struct {
|
type acceptedConnection struct {
|
||||||
conn *msg.Conn
|
conn *msg.Conn
|
||||||
wireProtocol string
|
wireProtocol string
|
||||||
|
cryptoContext *wire.CryptoContext
|
||||||
firstMsg msg.Message
|
firstMsg msg.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +552,7 @@ func (svr *Service) acceptConnection(ctx context.Context, conn net.Conn) (*accep
|
|||||||
wireConn := wire.NewConn(conn)
|
wireConn := wire.NewConn(conn)
|
||||||
rw := msg.NewV2ReadWriterWithConn(wireConn)
|
rw := msg.NewV2ReadWriterWithConn(wireConn)
|
||||||
acceptedConn.conn = msg.NewConn(conn, rw)
|
acceptedConn.conn = msg.NewConn(conn, rw)
|
||||||
acceptedConn.firstMsg, err = acceptedConn.readFirstV2Msg(wireConn)
|
acceptedConn.firstMsg, err = acceptedConn.readFirstV2Msg(conn, wireConn)
|
||||||
} else {
|
} else {
|
||||||
rw := msg.NewV1ReadWriter(conn)
|
rw := msg.NewV1ReadWriter(conn)
|
||||||
acceptedConn.conn = msg.NewConn(conn, rw)
|
acceptedConn.conn = msg.NewConn(conn, rw)
|
||||||
@@ -557,17 +565,41 @@ func (svr *Service) acceptConnection(ctx context.Context, conn net.Conn) (*accep
|
|||||||
return acceptedConn, nil
|
return acceptedConn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeWithDeadline(conn net.Conn, timeout time.Duration, writeFn func() error) error {
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
|
||||||
|
defer func() {
|
||||||
|
_ = conn.SetWriteDeadline(time.Time{})
|
||||||
|
}()
|
||||||
|
return writeFn()
|
||||||
|
}
|
||||||
|
|
||||||
func (ac *acceptedConnection) messageConnFor(rw io.ReadWriter) *msg.Conn {
|
func (ac *acceptedConnection) messageConnFor(rw io.ReadWriter) *msg.Conn {
|
||||||
return msg.NewConn(ac.conn, msg.NewReadWriter(rw, ac.wireProtocol))
|
return msg.NewConn(ac.conn, msg.NewReadWriter(rw, ac.wireProtocol))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ac *acceptedConnection) readFirstV2Msg(wireConn *wire.Conn) (msg.Message, error) {
|
func (ac *acceptedConnection) newControlReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {
|
||||||
|
if ac.wireProtocol == wire.ProtocolV2 {
|
||||||
|
if ac.cryptoContext == nil {
|
||||||
|
return nil, fmt.Errorf("missing v2 crypto negotiation")
|
||||||
|
}
|
||||||
|
return netpkg.NewAEADCryptoReadWriter(
|
||||||
|
rw,
|
||||||
|
key,
|
||||||
|
netpkg.AEADCryptoRoleServer,
|
||||||
|
ac.cryptoContext.Algorithm,
|
||||||
|
ac.cryptoContext.TranscriptHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return netpkg.NewCryptoReadWriter(rw, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ac *acceptedConnection) readFirstV2Msg(conn net.Conn, wireConn *wire.Conn) (msg.Message, error) {
|
||||||
frame, err := wireConn.ReadFrame()
|
frame, err := wireConn.ReadFrame()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read v2 frame: %w", err)
|
return nil, fmt.Errorf("read v2 frame: %w", err)
|
||||||
}
|
}
|
||||||
if frame.Type == wire.FrameTypeClientHello {
|
if frame.Type == wire.FrameTypeClientHello {
|
||||||
if err := ac.handleClientHello(wireConn, frame); err != nil {
|
if err := ac.handleClientHello(conn, wireConn, frame); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
frame, err = wireConn.ReadFrame()
|
frame, err = wireConn.ReadFrame()
|
||||||
@@ -583,21 +615,38 @@ func (ac *acceptedConnection) readFirstV2Msg(wireConn *wire.Conn) (msg.Message,
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ac *acceptedConnection) handleClientHello(wireConn *wire.Conn, frame *wire.Frame) error {
|
func (ac *acceptedConnection) handleClientHello(conn net.Conn, wireConn *wire.Conn, frame *wire.Frame) error {
|
||||||
var hello wire.ClientHello
|
var hello wire.ClientHello
|
||||||
if err := wireConn.UnmarshalFrame(frame, &hello); err != nil {
|
if err := wireConn.UnmarshalFrame(frame, &hello); err != nil {
|
||||||
return fmt.Errorf("decode ClientHello: %w", err)
|
return fmt.Errorf("decode ClientHello: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
serverHello := wire.DefaultServerHello()
|
serverHello, err := wire.NewServerHello(hello)
|
||||||
if err := wire.ValidateClientHello(hello); err != nil {
|
if err != nil {
|
||||||
|
serverHello = wire.DefaultServerHello()
|
||||||
serverHello.Error = err.Error()
|
serverHello.Error = err.Error()
|
||||||
_ = wireConn.WriteJSONFrame(wire.FrameTypeServerHello, serverHello)
|
if writeErr := writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||||
|
return wireConn.WriteJSONFrame(wire.FrameTypeServerHello, serverHello)
|
||||||
|
}); writeErr != nil {
|
||||||
|
return fmt.Errorf("%w; write ServerHello error: %v", err, writeErr)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := wireConn.WriteJSONFrame(wire.FrameTypeServerHello, serverHello); err != nil {
|
serverHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeServerHello, serverHello)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode ServerHello: %w", err)
|
||||||
|
}
|
||||||
|
cryptoContext := wire.NewCryptoContext(
|
||||||
|
serverHello.Selected.Crypto.Algorithm,
|
||||||
|
frame.Payload,
|
||||||
|
serverHelloFrame.Payload,
|
||||||
|
)
|
||||||
|
if err := writeWithDeadline(conn, connWriteTimeout, func() error {
|
||||||
|
return wireConn.WriteFrame(serverHelloFrame)
|
||||||
|
}); err != nil {
|
||||||
return fmt.Errorf("write ServerHello: %w", err)
|
return fmt.Errorf("write ServerHello: %w", err)
|
||||||
}
|
}
|
||||||
|
ac.cryptoContext = cryptoContext
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
|
||||||
|
serverConn, clientConn := net.Pipe()
|
||||||
|
defer serverConn.Close()
|
||||||
|
defer clientConn.Close()
|
||||||
|
|
||||||
|
err := writeWithDeadline(serverConn, 50*time.Millisecond, func() error {
|
||||||
|
_, writeErr := serverConn.Write([]byte("x"))
|
||||||
|
return writeErr
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
var netErr net.Error
|
||||||
|
require.True(t, errors.As(err, &netErr))
|
||||||
|
require.True(t, netErr.Timeout())
|
||||||
|
|
||||||
|
readCh := make(chan byte, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
if _, readErr := clientConn.Read(buf); readErr != nil {
|
||||||
|
errCh <- readErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readCh <- buf[0]
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = serverConn.Write([]byte("y"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case b := <-readCh:
|
||||||
|
require.Equal(t, byte('y'), b)
|
||||||
|
case err := <-errCh:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for write after deadline reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package framework
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CleanupActionHandle is an integer pointer type for handling cleanup action
|
|
||||||
type CleanupActionHandle *int
|
|
||||||
|
|
||||||
type cleanupFuncHandle struct {
|
|
||||||
actionHandle CleanupActionHandle
|
|
||||||
actionHook func()
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
cleanupActionsLock sync.Mutex
|
|
||||||
cleanupHookList = []cleanupFuncHandle{}
|
|
||||||
)
|
|
||||||
|
|
||||||
// AddCleanupAction installs a function that will be called in the event of the
|
|
||||||
// whole test being terminated. This allows arbitrary pieces of the overall
|
|
||||||
// test to hook into SynchronizedAfterSuite().
|
|
||||||
// The hooks are called in last-in-first-out order.
|
|
||||||
func AddCleanupAction(fn func()) CleanupActionHandle {
|
|
||||||
p := CleanupActionHandle(new(int))
|
|
||||||
cleanupActionsLock.Lock()
|
|
||||||
defer cleanupActionsLock.Unlock()
|
|
||||||
c := cleanupFuncHandle{actionHandle: p, actionHook: fn}
|
|
||||||
cleanupHookList = append([]cleanupFuncHandle{c}, cleanupHookList...)
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveCleanupAction removes a function that was installed by
|
|
||||||
// AddCleanupAction.
|
|
||||||
func RemoveCleanupAction(p CleanupActionHandle) {
|
|
||||||
cleanupActionsLock.Lock()
|
|
||||||
defer cleanupActionsLock.Unlock()
|
|
||||||
for i, item := range cleanupHookList {
|
|
||||||
if item.actionHandle == p {
|
|
||||||
cleanupHookList = append(cleanupHookList[:i], cleanupHookList[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunCleanupActions runs all functions installed by AddCleanupAction. It does
|
|
||||||
// not remove them (see RemoveCleanupAction) but it does run unlocked, so they
|
|
||||||
// may remove themselves.
|
|
||||||
func RunCleanupActions() {
|
|
||||||
list := []func(){}
|
|
||||||
func() {
|
|
||||||
cleanupActionsLock.Lock()
|
|
||||||
defer cleanupActionsLock.Unlock()
|
|
||||||
for _, p := range cleanupHookList {
|
|
||||||
list = append(list, p.actionHook)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
// Run unlocked.
|
|
||||||
for _, fn := range list {
|
|
||||||
fn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,11 +23,6 @@ func ExpectNotEqual(actual any, extra any, explain ...any) {
|
|||||||
gomega.ExpectWithOffset(1, actual).NotTo(gomega.Equal(extra), explain...)
|
gomega.ExpectWithOffset(1, actual).NotTo(gomega.Equal(extra), explain...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpectError expects an error happens, otherwise an exception raises
|
|
||||||
func ExpectError(err error, explain ...any) {
|
|
||||||
gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred(), explain...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExpectErrorWithOffset(offset int, err error, explain ...any) {
|
func ExpectErrorWithOffset(offset int, err error, explain ...any) {
|
||||||
gomega.ExpectWithOffset(1+offset, err).To(gomega.HaveOccurred(), explain...)
|
gomega.ExpectWithOffset(1+offset, err).To(gomega.HaveOccurred(), explain...)
|
||||||
}
|
}
|
||||||
@@ -47,11 +42,6 @@ func ExpectContainSubstring(actual, substr string, explain ...any) {
|
|||||||
gomega.ExpectWithOffset(1, actual).To(gomega.ContainSubstring(substr), explain...)
|
gomega.ExpectWithOffset(1, actual).To(gomega.ContainSubstring(substr), explain...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpectConsistOf expects actual contains precisely the extra elements. The ordering of the elements does not matter.
|
|
||||||
func ExpectConsistOf(actual any, extra any, explain ...any) {
|
|
||||||
gomega.ExpectWithOffset(1, actual).To(gomega.ConsistOf(extra), explain...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExpectContainElements(actual any, extra any, explain ...any) {
|
func ExpectContainElements(actual any, extra any, explain ...any) {
|
||||||
gomega.ExpectWithOffset(1, actual).To(gomega.ContainElements(extra), explain...)
|
gomega.ExpectWithOffset(1, actual).To(gomega.ContainElements(extra), explain...)
|
||||||
}
|
}
|
||||||
@@ -60,16 +50,6 @@ func ExpectNotContainElements(actual any, extra any, explain ...any) {
|
|||||||
gomega.ExpectWithOffset(1, actual).NotTo(gomega.ContainElements(extra), explain...)
|
gomega.ExpectWithOffset(1, actual).NotTo(gomega.ContainElements(extra), explain...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpectHaveKey expects the actual map has the key in the keyset
|
|
||||||
func ExpectHaveKey(actual any, key any, explain ...any) {
|
|
||||||
gomega.ExpectWithOffset(1, actual).To(gomega.HaveKey(key), explain...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExpectEmpty expects actual is empty
|
|
||||||
func ExpectEmpty(actual any, explain ...any) {
|
|
||||||
gomega.ExpectWithOffset(1, actual).To(gomega.BeEmpty(), explain...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExpectTrue(actual any, explain ...any) {
|
func ExpectTrue(actual any, explain ...any) {
|
||||||
gomega.ExpectWithOffset(1, actual).Should(gomega.BeTrue(), explain...)
|
gomega.ExpectWithOffset(1, actual).Should(gomega.BeTrue(), explain...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,6 @@ type Framework struct {
|
|||||||
// Multiple default mock servers used for e2e testing.
|
// Multiple default mock servers used for e2e testing.
|
||||||
mockServers *MockServers
|
mockServers *MockServers
|
||||||
|
|
||||||
// To make sure that this framework cleans up after itself, no matter what,
|
|
||||||
// we install a Cleanup action before each test and clear it after. If we
|
|
||||||
// should abort, the AfterSuite hook should run all Cleanup actions.
|
|
||||||
cleanupHandle CleanupActionHandle
|
|
||||||
|
|
||||||
// beforeEachStarted indicates that BeforeEach has started
|
// beforeEachStarted indicates that BeforeEach has started
|
||||||
beforeEachStarted bool
|
beforeEachStarted bool
|
||||||
|
|
||||||
@@ -87,8 +82,6 @@ func NewFramework(opt Options) *Framework {
|
|||||||
func (f *Framework) BeforeEach() {
|
func (f *Framework) BeforeEach() {
|
||||||
f.beforeEachStarted = true
|
f.beforeEachStarted = true
|
||||||
|
|
||||||
f.cleanupHandle = AddCleanupAction(f.AfterEach)
|
|
||||||
|
|
||||||
dir, err := os.MkdirTemp(os.TempDir(), "frp-e2e-test-*")
|
dir, err := os.MkdirTemp(os.TempDir(), "frp-e2e-test-*")
|
||||||
ExpectNoError(err)
|
ExpectNoError(err)
|
||||||
f.TempDirectory = dir
|
f.TempDirectory = dir
|
||||||
@@ -113,8 +106,6 @@ func (f *Framework) AfterEach() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
RemoveCleanupAction(f.cleanupHandle)
|
|
||||||
|
|
||||||
// stop processor
|
// stop processor
|
||||||
for _, p := range f.serverProcesses {
|
for _, p := range f.serverProcesses {
|
||||||
_ = p.Stop()
|
_ = p.Stop()
|
||||||
@@ -266,10 +257,6 @@ func (f *Framework) AllocPortExcludingRanges(ranges ...[2]int) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Framework) ReleasePort(port int) {
|
|
||||||
f.portAllocator.Release(port)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Framework) RunServer(portName string, s server.Server) {
|
func (f *Framework) RunServer(portName string, s server.Server) {
|
||||||
f.servers = append(f.servers, s)
|
f.servers = append(f.servers, s)
|
||||||
if s.BindPort() > 0 && portName != "" {
|
if s.BindPort() > 0 && portName != "" {
|
||||||
|
|||||||
@@ -75,11 +75,3 @@ func (m *MockServers) GetTemplateParams() map[string]any {
|
|||||||
ret[HTTPSimpleServerPort] = m.httpSimpleServer.BindPort()
|
ret[HTTPSimpleServerPort] = m.httpSimpleServer.BindPort()
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockServers) GetParam(key string) any {
|
|
||||||
params := m.GetTemplateParams()
|
|
||||||
if v, ok := params[key]; ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -124,7 +124,3 @@ func (e *RequestExpect) Ensure(fns ...EnsureFunc) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *RequestExpect) Do() (*request.Response, error) {
|
|
||||||
return e.req.Do()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,13 +31,6 @@ func New(options ...Option) *Server {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithBindAddr(addr string) Option {
|
|
||||||
return func(s *Server) *Server {
|
|
||||||
s.bindAddr = addr
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithBindPort(port int) Option {
|
func WithBindPort(port int) Option {
|
||||||
return func(s *Server) *Server {
|
return func(s *Server) *Server {
|
||||||
s.bindPort = port
|
s.bindPort = port
|
||||||
|
|||||||
@@ -61,21 +61,6 @@ func WithBindPort(port int) Option {
|
|||||||
return func(s *Server) { s.bindPort = port }
|
return func(s *Server) { s.bindPort = port }
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithClientCredentials(id, secret string) Option {
|
|
||||||
return func(s *Server) {
|
|
||||||
s.clientID = id
|
|
||||||
s.clientSecret = secret
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithAudience(aud string) Option {
|
|
||||||
return func(s *Server) { s.audience = aud }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithSubject(sub string) Option {
|
|
||||||
return func(s *Server) { s.subject = sub }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithExpiresIn(seconds int) Option {
|
func WithExpiresIn(seconds int) Option {
|
||||||
return func(s *Server) { s.expiresIn = seconds }
|
return func(s *Server) { s.expiresIn = seconds }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,14 +41,9 @@ type Process struct {
|
|||||||
waitErr error
|
waitErr error
|
||||||
|
|
||||||
started bool
|
started bool
|
||||||
beforeStopHandler func()
|
|
||||||
stopped bool
|
stopped bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(path string, params []string) *Process {
|
|
||||||
return NewWithEnvs(path, params, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWithEnvs(path string, params []string, envs []string) *Process {
|
func NewWithEnvs(path string, params []string, envs []string) *Process {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cmd := exec.CommandContext(ctx, path, params...)
|
cmd := exec.CommandContext(ctx, path, params...)
|
||||||
@@ -100,9 +95,6 @@ func (p *Process) Stop() error {
|
|||||||
defer func() {
|
defer func() {
|
||||||
p.stopped = true
|
p.stopped = true
|
||||||
}()
|
}()
|
||||||
if p.beforeStopHandler != nil {
|
|
||||||
p.beforeStopHandler()
|
|
||||||
}
|
|
||||||
p.cancel()
|
p.cancel()
|
||||||
<-p.done
|
<-p.done
|
||||||
return p.waitErr
|
return p.waitErr
|
||||||
@@ -125,10 +117,6 @@ func (p *Process) CountOutput(pattern string) int {
|
|||||||
return strings.Count(p.Output(), pattern)
|
return strings.Count(p.Output(), pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Process) SetBeforeStopHandler(fn func()) {
|
|
||||||
p.beforeStopHandler = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// WaitForOutput polls the combined process output until the pattern is found
|
// WaitForOutput polls the combined process output until the pattern is found
|
||||||
// count time(s) or the timeout is reached. It also returns early if the process exits.
|
// count time(s) or the timeout is reached. It also returns early if the process exits.
|
||||||
func (p *Process) WaitForOutput(pattern string, count int, timeout time.Duration) error {
|
func (p *Process) WaitForOutput(pattern string, count int, timeout time.Duration) error {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ type Request struct {
|
|||||||
port int
|
port int
|
||||||
body []byte
|
body []byte
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
resolver *net.Resolver
|
|
||||||
|
|
||||||
// for http or https
|
// for http or https
|
||||||
method string
|
method string
|
||||||
@@ -134,11 +133,6 @@ func (r *Request) Body(content []byte) *Request {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Request) Resolver(resolver *net.Resolver) *Request {
|
|
||||||
r.resolver = resolver
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Request) Do() (*Response, error) {
|
func (r *Request) Do() (*Response, error) {
|
||||||
var (
|
var (
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
@@ -169,7 +163,7 @@ func (r *Request) Do() (*Response, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dialer := &net.Dialer{Resolver: r.resolver}
|
dialer := &net.Dialer{}
|
||||||
switch r.protocol {
|
switch r.protocol {
|
||||||
case "tcp":
|
case "tcp":
|
||||||
conn, err = dialer.Dial("tcp", addr)
|
conn, err = dialer.Dial("tcp", addr)
|
||||||
@@ -225,7 +219,6 @@ func (r *Request) sendHTTPRequest(method, urlstr string, host string, headers ma
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
KeepAlive: 30 * time.Second,
|
KeepAlive: 30 * time.Second,
|
||||||
DualStack: true,
|
DualStack: true,
|
||||||
Resolver: r.resolver,
|
|
||||||
}).DialContext,
|
}).DialContext,
|
||||||
MaxIdleConns: 100,
|
MaxIdleConns: 100,
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
|||||||
+113
-34
@@ -77,8 +77,9 @@
|
|||||||
<div v-if="filteredProxies.length > 0" class="proxies-list">
|
<div v-if="filteredProxies.length > 0" class="proxies-list">
|
||||||
<ProxyCard
|
<ProxyCard
|
||||||
v-for="proxy in filteredProxies"
|
v-for="proxy in filteredProxies"
|
||||||
:key="proxy.name"
|
:key="`${proxy.type}:${proxy.name}`"
|
||||||
:proxy="proxy"
|
:proxy="proxy"
|
||||||
|
:show-type="activeType === 'all'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loading" class="empty-state">
|
<div v-else-if="!loading" class="empty-state">
|
||||||
@@ -129,12 +130,14 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const proxyTypes = [
|
const proxyTypes = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
{ label: 'TCP', value: 'tcp' },
|
{ label: 'TCP', value: 'tcp' },
|
||||||
{ label: 'UDP', value: 'udp' },
|
{ label: 'UDP', value: 'udp' },
|
||||||
{ label: 'HTTP', value: 'http' },
|
{ label: 'HTTP', value: 'http' },
|
||||||
{ label: 'HTTPS', value: 'https' },
|
{ label: 'HTTPS', value: 'https' },
|
||||||
{ label: 'TCPMUX', value: 'tcpmux' },
|
{ label: 'TCPMUX', value: 'tcpmux' },
|
||||||
{ label: 'STCP', value: 'stcp' },
|
{ label: 'STCP', value: 'stcp' },
|
||||||
|
{ label: 'XTCP', value: 'xtcp' },
|
||||||
{ label: 'SUDP', value: 'sudp' },
|
{ label: 'SUDP', value: 'sudp' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -200,15 +203,48 @@ const filteredProxies = computed(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search text
|
// Filter by search text across multiple fields
|
||||||
if (searchText.value) {
|
if (searchText.value) {
|
||||||
const search = searchText.value.toLowerCase()
|
const search = searchText.value.toLowerCase()
|
||||||
result = result.filter((p) => p.name.toLowerCase().includes(search))
|
result = result.filter((p) => {
|
||||||
|
const fields: unknown[] = [
|
||||||
|
p.name,
|
||||||
|
p.type,
|
||||||
|
p.clientID,
|
||||||
|
p.user,
|
||||||
|
p.addr,
|
||||||
|
p.port,
|
||||||
|
p.customDomains,
|
||||||
|
p.subdomain,
|
||||||
|
]
|
||||||
|
return fields.some((v) => matchesSearch(v, search))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Normalize a field of unknown shape (string / number / array / null) to a
|
||||||
|
// lowercase string for case-insensitive substring matching. Arrays are joined
|
||||||
|
// so e.g. customDomains: ["A.com","B.com"] is searchable as one blob.
|
||||||
|
const matchesSearch = (value: unknown, needle: string): boolean => {
|
||||||
|
if (value === null || value === undefined) return false
|
||||||
|
let str: string
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
str = value
|
||||||
|
.filter((v) => v !== null && v !== undefined)
|
||||||
|
.map((v) => String(v))
|
||||||
|
.join(' ')
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
if (value === 0) return false
|
||||||
|
str = String(value)
|
||||||
|
} else {
|
||||||
|
str = String(value)
|
||||||
|
}
|
||||||
|
if (!str) return false
|
||||||
|
return str.toLowerCase().includes(needle)
|
||||||
|
}
|
||||||
|
|
||||||
const onClientFilterChange = (key: string) => {
|
const onClientFilterChange = (key: string) => {
|
||||||
if (key) {
|
if (key) {
|
||||||
const client = clientOptions.value.find((c) => c.key === key)
|
const client = clientOptions.value.find((c) => c.key === key)
|
||||||
@@ -249,45 +285,88 @@ const fetchServerInfo = async () => {
|
|||||||
return serverInfo
|
return serverInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const convertProxies = async (
|
||||||
|
type: string,
|
||||||
|
json: any,
|
||||||
|
): Promise<BaseProxy[]> => {
|
||||||
|
if (type === 'tcp') {
|
||||||
|
return json.proxies.map((p: any) => new TCPProxy(p))
|
||||||
|
}
|
||||||
|
if (type === 'udp') {
|
||||||
|
return json.proxies.map((p: any) => new UDPProxy(p))
|
||||||
|
}
|
||||||
|
if (type === 'http') {
|
||||||
|
const info = await fetchServerInfo()
|
||||||
|
if (info && info.vhostHTTPPort) {
|
||||||
|
return json.proxies.map(
|
||||||
|
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (type === 'https') {
|
||||||
|
const info = await fetchServerInfo()
|
||||||
|
if (info && info.vhostHTTPSPort) {
|
||||||
|
return json.proxies.map(
|
||||||
|
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (type === 'tcpmux') {
|
||||||
|
const info = await fetchServerInfo()
|
||||||
|
if (info && info.tcpmuxHTTPConnectPort) {
|
||||||
|
return json.proxies.map(
|
||||||
|
(p: any) =>
|
||||||
|
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (type === 'stcp') {
|
||||||
|
return json.proxies.map((p: any) => new STCPProxy(p))
|
||||||
|
}
|
||||||
|
if (type === 'sudp') {
|
||||||
|
return json.proxies.map((p: any) => new SUDPProxy(p))
|
||||||
|
}
|
||||||
|
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
|
||||||
|
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
|
||||||
|
return json.proxies.map((p: any) => {
|
||||||
|
const bp = new BaseProxy(p)
|
||||||
|
bp.type = type
|
||||||
|
return bp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const allProxyTypes = [
|
||||||
|
'tcp',
|
||||||
|
'udp',
|
||||||
|
'http',
|
||||||
|
'https',
|
||||||
|
'tcpmux',
|
||||||
|
'stcp',
|
||||||
|
'xtcp',
|
||||||
|
'sudp',
|
||||||
|
]
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
proxies.value = []
|
proxies.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const type = activeType.value
|
const type = activeType.value
|
||||||
const json = await getProxiesByType(type)
|
|
||||||
|
|
||||||
if (type === 'tcp') {
|
if (type === 'all') {
|
||||||
proxies.value = json.proxies.map((p: any) => new TCPProxy(p))
|
const results = await Promise.all(
|
||||||
} else if (type === 'udp') {
|
allProxyTypes.map(async (t) => {
|
||||||
proxies.value = json.proxies.map((p: any) => new UDPProxy(p))
|
const json = await getProxiesByType(t)
|
||||||
} else if (type === 'http') {
|
return convertProxies(t, json)
|
||||||
const info = await fetchServerInfo()
|
}),
|
||||||
if (info && info.vhostHTTPPort) {
|
|
||||||
proxies.value = json.proxies.map(
|
|
||||||
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
|
||||||
)
|
)
|
||||||
}
|
proxies.value = results.flat()
|
||||||
} else if (type === 'https') {
|
} else {
|
||||||
const info = await fetchServerInfo()
|
const json = await getProxiesByType(type)
|
||||||
if (info && info.vhostHTTPSPort) {
|
proxies.value = await convertProxies(type, json)
|
||||||
proxies.value = json.proxies.map(
|
|
||||||
(p: any) =>
|
|
||||||
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (type === 'tcpmux') {
|
|
||||||
const info = await fetchServerInfo()
|
|
||||||
if (info && info.tcpmuxHTTPConnectPort) {
|
|
||||||
proxies.value = json.proxies.map(
|
|
||||||
(p: any) =>
|
|
||||||
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (type === 'stcp') {
|
|
||||||
proxies.value = json.proxies.map((p: any) => new STCPProxy(p))
|
|
||||||
} else if (type === 'sudp') {
|
|
||||||
proxies.value = json.proxies.map((p: any) => new SUDPProxy(p))
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
|
|||||||
Reference in New Issue
Block a user