Compare commits

..
44 Commits
Author SHA1 Message Date
fatedierandGitHub 7b6e01f04f Merge pull request #5411 from fatedier/dev
Release v0.70.0
2026-07-11 18:50:35 +08:00
fatedierandGitHub 5396947c7c remove retired Go Report Card badge (#5409) 2026-07-11 15:59:51 +08:00
fatedierandGitHub fe61093bc2 bump version to 0.70.0 (#5406) 2026-07-11 14:28:19 +08:00
fatedierandGitHub 54aeb2a7b0 feat(server): add typed frps v2 proxy specs (#5405) 2026-07-11 10:34:04 +08:00
fatedierandGitHub 84be1938e4 api: expose v2 proxy timestamps as unix seconds (#5402) 2026-07-09 14:47:19 +08:00
fatedierandGitHub becee40715 docs: update API v2 release notes (#5400) 2026-07-08 15:54:16 +08:00
fatedierandGitHub 17e788d43b refactor: clean up frps v2 frontend models (#5399) 2026-07-08 13:10:55 +08:00
fatedierandGitHub 68509f5d44 Add frps proxy traffic API v2 (#5398) 2026-07-08 02:05:28 +08:00
fatedierandGitHub 5cd722b177 feat: add system prune API v2 (#5395) 2026-07-07 13:00:53 +08:00
fatedierandGitHub 5876beceac feat: add system info API v2 (#5394) 2026-07-07 02:00:44 +08:00
fatedierandGitHub 7fe152e3aa web/frps: use API v2 for client and proxy details (#5386) 2026-06-30 01:33:57 +08:00
fatedierandGitHub 7c343fc6e7 web: bump esbuild to 0.28.1 and remove unused ElPopover type (#5385) 2026-06-29 23:21:22 +08:00
fatedierandGitHub a3b3b35b69 feat(ui): default proxies view to all tab (#5384) 2026-06-29 22:49:08 +08:00
fatedierandGitHub ae1c0504ec feat(dashboard): add v2 client detail status (#5381) 2026-06-26 21:15:30 +08:00
fatedierandGitHub 393a533744 docs: add release note for duplicate config names (#5379) 2026-06-24 15:03:22 +08:00
MAAZIZ Adel AyoubandGitHub 035889c360 fix(client): reject duplicate proxy and visitor names (#5378) 2026-06-24 13:37:29 +08:00
fatedierandGitHub 940bde5c46 docs: update release notes (#5377) 2026-06-23 17:06:16 +08:00
fatedierandGitHub 14628df63c test: cover tls2raw proxy protocol header (#5376) 2026-06-23 00:04:38 +08:00
Shani PathakandGitHub ba7adcab8f fix(websocket): send tunnel payload as binary frames (#5363)
The ws/wss transport carries a raw byte stream (yamux), but the
golang.org/x/net/websocket Conn defaults to text frames (PayloadType
TextFrame). Per RFC 6455 §5.6 a text frame must contain valid UTF-8, so
RFC-compliant intermediaries (API gateways / reverse proxies) validate
the payload and close the connection when the binary tunnel data is not
valid UTF-8.

This goes unnoticed peer-to-peer because x/net/websocket does not
validate UTF-8 on read, but it breaks the connection through a compliant
validating proxy. Set PayloadType to BinaryFrame on both the server
listener and the client dialer so the tunnel is framed as binary.
2026-06-22 23:34:02 +08:00
4cc826e236 fix(client): write proxy protocol header in tls2raw plugin (#5362)
Co-authored-by: futrobo <futrobo@163.com>
2026-06-22 23:08:50 +08:00
fatedierandGitHub 54c6ccdfec feat: remove proxies client filter (#5375) 2026-06-22 22:53:57 +08:00
fatedierandGitHub 9bde0b07de feat: paginate dashboard clients and proxies via API v2 (#5354)
Move the frps dashboard Clients and Proxies views to the paginated
/api/v2/clients and /api/v2/proxies endpoints instead of fetching all
data at once, and extend server-side proxy search so the search box
keeps working under pagination.

Frontend:
- Add V2Envelope/V2Page types and getV2 HTTP helper to api/http.ts
- Add v2 paginated fetch functions to api/client.ts and api/proxy.ts
- Add ClientV2Info and ProxyV2Info types for v2 API responses
- Rewrite Clients.vue with server-side pagination, status/user search
  filtering, and ElPagination component
- Rewrite Proxies.vue with server-side pagination, type tabs, client
  dropdown filter, and a search box that passes q to the API
- Default page size 10, selectable sizes [10, 20, 50, 100]

Backend:
- Extend /api/v2/proxies q matching to also cover online proxy spec
  fields: TCP/UDP remotePort and HTTP/HTTPS/TCPMux customDomains and
  subdomain, so dashboard search no longer needs to scan every page
- Add controller_v2 tests for the new spec-field matching
2026-06-03 14:08:45 +08:00
fatedierandGitHub c6c545289c fix: normalize web package lockfile (#5353) 2026-06-02 13:39:37 +08:00
fatedier 503afe78b7 feat: add dashboard API v2 pagination endpoints (#5351) 2026-06-01 20:09:25 +08:00
fatedierandGitHub 8dd26c6961 Merge pull request #5350 from fatedier/dev
Release v0.69.1
2026-06-01 18:02:22 +08:00
fatedierandGitHub 9ea1d86f03 test: handle wire v2 compatibility baselines (#5349) 2026-06-01 17:52:57 +08:00
fatedier ac3e82db4e Release v0.69.1 (#5348) 2026-06-01 16:22:34 +08:00
fatedier 0773938d70 feat: bridge mixed wire protocol SUDP payloads (#5347)
SUDP payload codec follows transport wireProtocol; same-protocol v1/v1 and v2/v2 keep raw join; only mixed proxy/visitor protocols use message-aware bridge; no new capability/selection field.
2026-06-01 16:22:34 +08:00
fatedier 9bacce22a2 feat: use wire v2 framing for XTCP NatHoleSid (#5343) 2026-06-01 16:22:34 +08:00
fatedier 7f8d68b666 feat: use wire v2 framing for UDP workConn payload (#5340) 2026-06-01 16:22:34 +08:00
fatedierandGitHub c8c1e5116c Merge pull request #5323 from fatedier/dev
Release v0.69.0
2026-05-22 00:55:23 +08:00
fatedierandGitHub 3e19ef9bfd feat(ui): add xtcp tab to frps dashboard (#5322) 2026-05-21 22:18:00 +08:00
fatedierandGitHub e20f974d61 feat(ui): add all proxies tab to frps dashboard (#5321) 2026-05-20 16:20:58 +08:00
fatedierandGitHub a88e0e9a49 refactor: clean up code (#5308) 2026-05-12 11:13:50 +08:00
霜冷长河andGitHub ad07d27914 fix: reset UDP read deadline on write activity to prevent idle timeout 2026-05-11 20:23:39 +08:00
fatedierandGitHub 8666e3643f protocol: add AEAD encryption negotiation to v2 wire control channel (#5304) 2026-05-06 10:43:47 +08:00
fatedierandGitHub 4ec8de973f Merge pull request #5287 from fatedier/dev
bump version to v0.68.1
2026-04-14 01:28:33 +08:00
fatedierandGitHub 5bfcea3d0c merge dev to master (#5254)
* ci: bump github actions to latest major versions (#5251)

* docker: copy shared web directory for npm workspace builds
2026-03-20 15:54:26 +08:00
fatedierandGitHub 0a1b4ab21f Merge pull request #5249 from fatedier/dev
bump version
2026-03-20 13:56:28 +08:00
fatedierandGitHub 5f575b8442 Merge pull request #5147 from fatedier/dev
bump version
2026-01-31 14:01:40 +08:00
fatedierandGitHub a1348cdf00 bump version (#5112) 2026-01-04 14:54:13 +08:00
fatedierandGitHub 2f5e1f7945 Merge pull request #4999 from fatedier/dev
bump version
2025-09-25 20:23:42 +08:00
fatedierandGitHub 22ae8166d3 Merge pull request #4925 from fatedier/dev
bump version
2025-08-10 23:26:32 +08:00
fatedierandGitHub af6bc6369d Merge pull request #4849 from fatedier/dev
bump version
2025-06-25 11:51:19 +08:00
103 changed files with 6442 additions and 1750 deletions
+10 -11
View File
@@ -2,7 +2,6 @@
[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp)
[![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp)
[![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
[README](README.md) | [中文文档](README_zh.md)
@@ -13,6 +12,16 @@ frp is an open source project with its ongoing development made possible entirel
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<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">
@@ -30,16 +39,6 @@ frp is an open source project with its ongoing development made possible entirel
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end-->
## What is frp?
+10 -10
View File
@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<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">
@@ -32,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end-->
## 为什么使用 frp ?
+7 -19
View File
@@ -1,21 +1,9 @@
## Compatibility Policy
Starting with v0.69.0, each minor release is supported until there are nine newer minor releases. For example, v0.69.0 will be supported until v0.78.0 is released. Within this window, frpc v0.69.0 is guaranteed to work with any frps from v0.61.0 to v0.77.0, and vice versa. Patch releases within the same minor are always compatible. Versions outside the support window may continue to work on a best-effort basis, but compatibility is no longer guaranteed.
For mixed-version deployments, upgrade frps first, then upgrade frpc. This keeps the server side ready for newer client-side protocol behavior before clients start using it.
## Notes
This release introduces wire protocol v2 as a transition path for future frpc/frps protocol changes. The existing wire protocol is difficult to extend without compatibility risk, and upcoming changes, including replacing deprecated stream encryption methods, require a versioned protocol.
**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.
## Features
* 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`.
* 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.
* Expanded the frps dashboard API v2 migration across Clients, Proxies, Server Overview, Client Detail, and Proxy Detail, covering paginated users/clients/proxies, detail data, proxy traffic history, server system info, offline proxy statistics pruning, server-side pagination, search, and proxy type filtering.
## Fixes
* WebSocket and WSS tunnel payloads are now sent as binary frames, avoiding disconnects through RFC-compliant intermediaries that validate text frames as UTF-8.
* The `tls2raw` client plugin now writes the proxy protocol header to the local raw connection when proxy protocol is enabled.
* frpc now rejects duplicate proxy and visitor names in config files instead of silently overwriting earlier entries.
+8 -7
View File
@@ -87,6 +87,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
readCh := make(chan *msg.UDPPacket, 1024)
sendCh := make(chan msg.Message, 1024)
isClose := false
@@ -109,7 +110,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
// udp service <- frpc <- frps <- frpc visitor <- user
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
defer closeFn()
for {
@@ -122,7 +123,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
var udpMsg msg.UDPPacket
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
xl.Warnf("read from workConn for sudp error: %v", errRet)
return
}
@@ -137,7 +138,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
// udp service -> frpc -> frps -> frpc visitor -> user
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
defer func() {
closeFn()
xl.Infof("writer goroutine for sudp work connection closed")
@@ -148,12 +149,12 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
switch m := rawMsg.(type) {
case *msg.UDPPacket:
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())
m.LocalAddr.String(), m.RemoteAddr.String(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
case *msg.Ping:
xl.Tracef("frpc send ping message to frpc visitor")
}
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
xl.Errorf("sudp work write error: %v", errRet)
return
}
@@ -184,8 +185,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
go workConnSenderFn(workConn, sendCh)
go workConnReaderFn(workConn, readCh)
go workConnSenderFn(payloadConn, sendCh)
go workConnReaderFn(payloadConn, readCh)
go heartbeatFn(sendCh)
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
+8 -6
View File
@@ -99,15 +99,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
pxy.mu.Lock()
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
// Plain UDP payload follows the configured wire protocol for message framing.
payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol)
pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024)
pxy.closed = false
pxy.mu.Unlock()
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
for {
var udpMsg msg.UDPPacket
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
xl.Warnf("read from workConn for udp error: %v", errRet)
return
}
@@ -120,7 +122,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
}
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
workConnSenderFn := func(rw msg.ReadWriter, sendCh chan msg.Message) {
defer func() {
xl.Infof("writer goroutine for udp work connection closed")
}()
@@ -132,7 +134,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
case *msg.Ping:
xl.Tracef("send ping message to udp workConn")
}
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
if errRet = rw.WriteMsg(rawMsg); errRet != nil {
xl.Errorf("udp work write error: %v", errRet)
return
}
@@ -151,8 +153,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
go workConnSenderFn(pxy.workConn, pxy.sendCh)
go workConnReaderFn(pxy.workConn, pxy.readCh)
go workConnSenderFn(payloadRW, pxy.sendCh)
go workConnReaderFn(payloadRW, pxy.readCh)
go heartbeatFn(pxy.sendCh)
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
+10 -2
View File
@@ -57,8 +57,7 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
xl := pxy.xl
defer conn.Close()
var natHoleSidMsg msg.NatHoleSid
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
if err != nil {
xl.Errorf("xtcp read from workConn error: %v", err)
return
@@ -131,6 +130,15 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
}
func readNatHoleSid(conn net.Conn, wireProtocol string) (*msg.NatHoleSid, error) {
workMsgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wireProtocol))
var natHoleSidMsg msg.NatHoleSid
if err := workMsgConn.ReadMsgInto(&natHoleSidMsg); err != nil {
return nil, err
}
return &natHoleSidMsg, nil
}
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
xl := pxy.xl
listenConn.Close()
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package proxy
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestReadNatHoleSidUsesSelectedWireProtocol(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
}{
{name: "v2", wireProtocol: wire.ProtocolV2},
{name: "v1", wireProtocol: wire.ProtocolV1},
{name: "default", wireProtocol: ""},
} {
t.Run(tc.name, func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setPipeDeadline(t, client, server)
errCh := make(chan error, 1)
go func() {
writer := msg.NewConn(server, msg.NewReadWriter(server, tc.wireProtocol))
errCh <- writer.WriteMsg(&msg.NatHoleSid{Sid: "sid"})
}()
out, err := readNatHoleSid(client, tc.wireProtocol)
require.NoError(t, err)
require.Equal(t, "sid", out.Sid)
require.NoError(t, <-errCh)
})
}
}
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
t.Helper()
deadline := time.Now().Add(time.Second)
for _, conn := range conns {
require.NoError(t, conn.SetDeadline(deadline))
}
}
+12 -11
View File
@@ -113,15 +113,16 @@ func (sv *SUDPVisitor) dispatcher() {
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl := xlog.FromContextSafe(sv.ctx)
xl.Debugf("starting sudp proxy worker")
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
wg := &sync.WaitGroup{}
wg.Add(2)
closeCh := make(chan struct{})
// udp service -> frpc -> frps -> frpc visitor -> user
workConnReaderFn := func(conn net.Conn) {
workConnReaderFn := func(payloadConn *msg.Conn) {
defer func() {
conn.Close()
payloadConn.Close()
close(closeCh)
wg.Done()
}()
@@ -133,13 +134,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
)
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
return
}
_ = conn.SetReadDeadline(time.Time{})
_ = payloadConn.SetReadDeadline(time.Time{})
switch m := rawMsg.(type) {
case *msg.Ping:
xl.Debugf("frpc visitor get ping message from frpc")
@@ -157,15 +158,15 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
}
// udp service <- frpc <- frps <- frpc visitor <- user
workConnSenderFn := func(conn net.Conn) {
workConnSenderFn := func(payloadConn *msg.Conn) {
defer func() {
conn.Close()
payloadConn.Close()
wg.Done()
}()
var errRet error
if firstPacket != nil {
if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
@@ -180,7 +181,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
return
}
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
@@ -191,8 +192,8 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
}
}
go workConnReaderFn(workConn)
go workConnSenderFn(workConn)
go workConnReaderFn(payloadConn)
go workConnSenderFn(payloadConn)
wg.Wait()
xl.Infof("sudp worker is closed")
+1 -1
View File
@@ -36,6 +36,7 @@ require (
gopkg.in/ini.v1 v1.67.0
k8s.io/apimachinery v0.28.8
k8s.io/client-go v0.28.8
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2
)
require (
@@ -75,7 +76,6 @@ require (
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v2 v2.4.0 // 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/yaml v1.3.0 // indirect
)
+29
View File
@@ -394,6 +394,10 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
}
}
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
return nil, err
}
return result, nil
}
@@ -417,6 +421,31 @@ func LoadClientConfig(path string, strict bool) (
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
}
// validateNoDuplicateNames rejects proxies or visitors that share a name. They are
// keyed by name in the config sources, so a duplicate would otherwise be silently
// overwritten and never started, with no error or log.
func validateNoDuplicateNames(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
proxyNames := make(map[string]struct{}, len(proxies))
for _, p := range proxies {
name := p.GetBaseConfig().Name
if _, ok := proxyNames[name]; ok {
return fmt.Errorf("proxy name [%s] is duplicated", name)
}
proxyNames[name] = struct{}{}
}
visitorNames := make(map[string]struct{}, len(visitors))
for _, v := range visitors {
name := v.GetBaseConfig().Name
if _, ok := visitorNames[name]; ok {
return fmt.Errorf("visitor name [%s] is duplicated", name)
}
visitorNames[name] = struct{}{}
}
return nil
}
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
proxyCfgs := proxies
for _, c := range proxyCfgs {
+107
View File
@@ -17,6 +17,8 @@ package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -462,6 +464,111 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
require.Equal("keep", proxies[0].GetBaseConfig().Name)
}
func TestLoadClientConfigResult_DuplicateNames(t *testing.T) {
tests := []struct {
name string
content string
errSubstr string
}{
{
name: "duplicate proxy names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "dup"
type = "tcp"
localPort = 22
remotePort = 6000
[[proxies]]
name = "dup"
type = "tcp"
localPort = 3306
remotePort = 6001
`,
errSubstr: "proxy name [dup] is duplicated",
},
{
name: "duplicate visitor names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[visitors]]
name = "dup"
type = "stcp"
serverName = "a"
secretKey = "secret"
bindPort = 9001
[[visitors]]
name = "dup"
type = "stcp"
serverName = "b"
secretKey = "secret"
bindPort = 9002
`,
errSubstr: "visitor name [dup] is duplicated",
},
{
name: "unique names",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "p1"
type = "tcp"
localPort = 22
remotePort = 6000
[[proxies]]
name = "p2"
type = "tcp"
localPort = 3306
remotePort = 6001
`,
},
{
name: "same name across proxy and visitor",
content: `
serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "same"
type = "tcp"
localPort = 22
remotePort = 6000
[[visitors]]
name = "same"
type = "stcp"
serverName = "a"
secretKey = "secret"
bindPort = 9001
`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "frpc.toml")
require.NoError(os.WriteFile(path, []byte(tc.content), 0o600))
_, err := LoadClientConfigResult(path, false)
if tc.errSubstr == "" {
require.NoError(err)
} else {
require.ErrorContains(err, tc.errSubstr)
}
})
}
}
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
func TestYAMLEdgeCases(t *testing.T) {
require := require.New(t)
+7 -17
View File
@@ -14,11 +14,7 @@
package source
import (
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
import v1 "github.com/fatedier/frp/pkg/config/v1"
// ConfigSource implements Source for in-memory configuration.
// 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))
for _, p := range proxies {
if p == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := p.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
name, err := validateProxyName(p)
if err != nil {
return err
}
nextProxies[name] = p
}
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
for _, v := range visitors {
if v == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := v.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
name, err := validateVisitorName(v)
if err != nil {
return err
}
nextVisitors[name] = v
}
+101 -119
View File
@@ -43,6 +43,11 @@ var (
ErrNotFound = errors.New("not found")
)
const (
storeKindProxy = "proxy"
storeKindVisitor = "visitor"
)
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
if cfg.Path == "" {
return nil, fmt.Errorf("path is required")
@@ -172,79 +177,111 @@ func (s *StoreSource) saveToFileUnlocked() error {
return nil
}
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
func (s *StoreSource) persistOrRollbackUnlocked(rollback func()) error {
if err := s.saveToFileUnlocked(); err != 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 == "" {
return fmt.Errorf("proxy name cannot be empty")
return fmt.Errorf("%s name cannot be empty", kind)
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.proxies[name]; exists {
return fmt.Errorf("%w: proxy %q", ErrAlreadyExists, name)
entries := entriesFn(s)
old, exists := entries[name]
if !exists {
return fmt.Errorf("%w: %s %q", ErrNotFound, kind, name)
}
s.proxies[name] = proxy
if err := s.saveToFileUnlocked(); err != nil {
delete(s.proxies, name)
return fmt.Errorf("failed to persist: %w", err)
delete(entries, name)
return s.persistOrRollbackUnlocked(func() {
entries[name] = old
})
}
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 {
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
name, err := validateProxyName(proxy)
if err != nil {
return err
}
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
return updateStoreEntry(s, proxyStoreEntries, storeKindProxy, name, proxy)
}
func (s *StoreSource) RemoveProxy(name string) error {
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)
}
delete(s.proxies, name)
if err := s.saveToFileUnlocked(); err != nil {
s.proxies[name] = oldProxy
return fmt.Errorf("failed to persist: %w", err)
}
return nil
return removeStoreEntry(s, proxyStoreEntries, storeKindProxy, name)
}
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 {
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
name, err := validateVisitorName(visitor)
if err != nil {
return err
}
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
return addStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
}
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
name, err := validateVisitorName(visitor)
if err != nil {
return err
}
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
return updateStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
}
func (s *StoreSource) RemoveVisitor(name string) error {
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)
}
delete(s.visitors, name)
if err := s.saveToFileUnlocked(); err != nil {
s.visitors[name] = oldVisitor
return fmt.Errorf("failed to persist: %w", err)
}
return nil
return removeStoreEntry(s, visitorStoreEntries, storeKindVisitor, name)
}
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
+96
View File
@@ -17,6 +17,7 @@ package source
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
@@ -59,6 +60,101 @@ func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T
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) {
require := require.New(t)
+43
View File
@@ -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
}
+43
View File
@@ -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
}
+228
View File
@@ -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",
},
}
}
+1 -16
View File
@@ -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))
}
// Validate token/tokenSource mutual exclusivity
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))
}
}
errs = AppendError(errs, v.validateAuthTokenSource(c.Token, c.TokenSource))
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
errs = AppendError(errs, err)
+1 -17
View File
@@ -21,7 +21,6 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/security"
)
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))
}
// Validate token/tokenSource mutual exclusivity
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))
}
}
errs = AppendError(errs, v.validateAuthTokenSource(c.Auth.Token, c.Auth.TokenSource))
if err := validateLogConfig(&c.Log); err != nil {
errs = AppendError(errs, err)
+42 -12
View File
@@ -18,6 +18,8 @@ import (
"sync"
"time"
"k8s.io/utils/clock"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/metric"
server "github.com/fatedier/frp/server/metrics"
@@ -38,11 +40,20 @@ func init() {
type serverMetrics struct {
info *ServerStatistics
clock clock.WithTicker
mu sync.Mutex
}
func newServerMetrics() *serverMetrics {
return newServerMetricsWithClock(clock.RealClock{})
}
func newServerMetricsWithClock(clk clock.WithTicker) *serverMetrics {
if clk == nil {
clk = clock.RealClock{}
}
return &serverMetrics{
clock: clk,
info: &ServerStatistics{
TotalTrafficIn: metric.NewDateCounter(ReserveDays),
TotalTrafficOut: metric.NewDateCounter(ReserveDays),
@@ -57,14 +68,23 @@ func newServerMetrics() *serverMetrics {
}
func (m *serverMetrics) run() {
go func() {
for {
time.Sleep(12 * time.Hour)
start := time.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, time.Since(start))
go m.runUntil(nil)
}
func (m *serverMetrics) runUntil(stopCh <-chan struct{}) {
ticker := m.clock.NewTicker(12 * time.Hour)
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) {
@@ -75,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
defer m.mu.Unlock()
total = len(m.info.ProxyStatistics)
for name, data := range m.info.ProxyStatistics {
if !data.LastCloseTime.IsZero() &&
data.LastStartTime.Before(data.LastCloseTime) &&
time.Since(data.LastCloseTime) > continuousOfflineDuration {
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
delete(m.info.ProxyStatistics, name)
count++
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
@@ -86,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
return count, total
}
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
return !data.LastCloseTime.IsZero() &&
data.LastStartTime.Before(data.LastCloseTime) &&
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
}
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
return m.clearUselessInfo(0)
}
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
return m.clearUselessInfo(0)
}
func (m *serverMetrics) NewClient() {
m.info.ClientCounts.Inc(1)
}
@@ -121,7 +149,7 @@ func (m *serverMetrics) NewProxy(name string, proxyType string, user string, cli
}
proxyStats.User = user
proxyStats.ClientID = clientID
proxyStats.LastStartTime = time.Now()
proxyStats.LastStartTime = m.clock.Now()
}
func (m *serverMetrics) CloseProxy(name string, proxyType string) {
@@ -131,7 +159,7 @@ func (m *serverMetrics) CloseProxy(name string, proxyType string) {
counter.Dec(1)
}
if proxyStats, ok := m.info.ProxyStatistics[name]; ok {
proxyStats.LastCloseTime = time.Now()
proxyStats.LastCloseTime = m.clock.Now()
}
}
@@ -211,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
}
if !proxyStats.LastStartTime.IsZero() {
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
ps.LastStartAt = proxyStats.LastStartTime.Unix()
}
if !proxyStats.LastCloseTime.IsZero() {
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
}
return ps
}
+153
View File
@@ -0,0 +1,153 @@
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)
stats := metrics.GetProxyByName("proxy")
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
require.Equal(start.Unix(), stats.LastStartAt)
require.Equal(closedAt.Unix(), stats.LastCloseAt)
}
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 TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(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(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
cleared, total := metrics.ClearOfflineProxies()
require.Equal(1, cleared)
require.Equal(2, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
}
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(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(time.Minute))
metrics := newServerMetricsWithClock(clk)
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
Name: "offline",
LastStartTime: start.Add(-time.Hour),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
Name: "online",
LastStartTime: start,
}
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
Name: "restarted",
LastStartTime: start.Add(30 * time.Second),
LastCloseTime: start,
}
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
Name: "same-time",
LastStartTime: start,
LastCloseTime: start,
}
cleared, total := metrics.PruneOfflineProxies()
require.Equal(1, cleared)
require.Equal(4, total)
require.False(metrics.hasProxyStatistics("offline"))
require.True(metrics.hasProxyStatistics("online"))
require.True(metrics.hasProxyStatistics("restarted"))
require.True(metrics.hasProxyStatistics("same-time"))
cleared, total = metrics.PruneOfflineProxies()
require.Equal(0, cleared)
require.Equal(3, total)
}
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
}
+3
View File
@@ -41,6 +41,8 @@ type ProxyStats struct {
TodayTrafficOut int64
LastStartTime string
LastCloseTime string
LastStartAt int64
LastCloseAt int64
CurConns int64
}
@@ -85,4 +87,5 @@ type Collector interface {
GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int)
PruneOfflineProxies() (int, int)
}
-7
View File
@@ -112,7 +112,6 @@ type Dispatcher struct {
sendCh chan Message
doneCh chan struct{}
msgHandlers map[reflect.Type]func(Message)
defaultHandler func(Message)
}
func NewDispatcher(rw ReadWriter) *Dispatcher {
@@ -151,8 +150,6 @@ func (d *Dispatcher) readLoop() {
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
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
}
func (d *Dispatcher) RegisterDefaultHandler(handler func(Message)) {
d.defaultHandler = handler
}
func (d *Dispatcher) Done() chan struct{} {
return d.doneCh
}
+19
View File
@@ -43,9 +43,28 @@ func TestV2ReadWriterRoundTrip(t *testing.T) {
func TestNewReadWriter(t *testing.T) {
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV1))
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, "unknown"))
require.IsType(t, &V2ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV2))
}
func TestNewReadWriterEncoding(t *testing.T) {
for _, wireProtocol := range []string{"", wire.ProtocolV1} {
var legacy bytes.Buffer
legacyRW := NewReadWriter(&legacy, wireProtocol)
require.NoError(t, legacyRW.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
require.NotEmpty(t, legacy.Bytes())
require.Equal(t, TypeUDPPacket, legacy.Bytes()[0])
}
var v2 bytes.Buffer
v2RW := NewReadWriter(&v2, wire.ProtocolV2)
require.NoError(t, v2RW.WriteMsg(&UDPPacket{Content: []byte("v2")}))
frame, err := wire.NewConn(&v2).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestV2MessageTypeIDsAreStable(t *testing.T) {
require.Equal(t, uint16(1), V2TypeLogin)
require.Equal(t, uint16(2), V2TypeLoginResp)
+41 -23
View File
@@ -21,6 +21,7 @@ import (
"time"
"github.com/samber/lo"
"k8s.io/utils/clock"
)
var (
@@ -144,19 +145,19 @@ func getBehaviorByModeAndIndex(mode int, index int) (RecommandBehavior, Recomman
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)
}
func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*BehaviorScore {
func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*behaviorScore {
behaviors := getBehaviorByMode(mode)
scores := make([]*BehaviorScore, 0, len(behaviors))
scores := make([]*behaviorScore, 0, len(behaviors))
for i := range behaviors {
score := receiverScore
if behaviors[i].A.Role == DetectRoleSender {
score = senderScore
}
scores = append(scores, &BehaviorScore{Mode: mode, Index: i, Score: score})
scores = append(scores, &behaviorScore{Mode: mode, Index: i, Score: score})
}
return scores
}
@@ -170,14 +171,18 @@ type RecommandBehavior struct {
ListenRandomPorts int
}
type MakeHoleRecords struct {
type makeHoleRecords struct {
mu sync.Mutex
scores []*BehaviorScore
LastUpdateTime time.Time
scores []*behaviorScore
clock clock.PassiveClock
lastUpdateTime time.Time
}
func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {
scores := []*BehaviorScore{}
func newMakeHoleRecordsWithClock(c, v *NatFeature, clk clock.PassiveClock) *makeHoleRecords {
if clk == nil {
clk = clock.RealClock{}
}
scores := []*behaviorScore{}
easyCount, hardCount, portsChangedRegularCount := ClassifyFeatureCount([]*NatFeature{c, v})
appendMode0 := func() {
switch {
@@ -212,13 +217,17 @@ func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {
scores = append(scores, getBehaviorScoresByMode(DetectMode1, 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()
defer mhr.mu.Unlock()
mhr.LastUpdateTime = time.Now()
mhr.lastUpdateTime = mhr.clock.Now()
for i := range mhr.scores {
score := mhr.scores[i]
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()
defer mhr.mu.Unlock()
if len(mhr.scores) == 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)
})
maxScore.Score--
mhr.LastUpdateTime = time.Now()
mhr.lastUpdateTime = mhr.clock.Now()
return maxScore.Mode, maxScore.Index
}
type BehaviorScore struct {
type behaviorScore struct {
Mode int
Index int
// between -10 and 10
@@ -255,16 +264,25 @@ type BehaviorScore struct {
type Analyzer struct {
// key is client ip + visitor ip
records map[string]*MakeHoleRecords
records map[string]*makeHoleRecords
dataReserveDuration time.Duration
clock clock.PassiveClock
mu sync.Mutex
}
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{
records: make(map[string]*MakeHoleRecords),
records: make(map[string]*makeHoleRecords),
dataReserveDuration: dataReserveDuration,
clock: clk,
}
}
@@ -272,12 +290,12 @@ func (a *Analyzer) GetRecommandBehaviors(key string, c, v *NatFeature) (mode, in
a.mu.Lock()
records, ok := a.records[key]
if !ok {
records = NewMakeHoleRecords(c, v)
records = newMakeHoleRecordsWithClock(c, v, a.clock)
a.records[key] = records
}
a.mu.Unlock()
mode, index = records.Recommand()
mode, index = records.recommand()
cBehavior, vBehavior := getBehaviorByModeAndIndex(mode, index)
switch mode {
@@ -307,11 +325,11 @@ func (a *Analyzer) ReportSuccess(key string, mode, index int) {
if !ok {
return
}
records.ReportSuccess(mode, index)
records.reportSuccess(mode, index)
}
func (a *Analyzer) Clean() (int, int) {
now := time.Now()
now := a.clock.Now()
total := 0
count := 0
@@ -321,7 +339,7 @@ func (a *Analyzer) Clean() (int, int) {
total = len(a.records)
// clean up records that have not been used for a period of time.
for key, records := range a.records {
if now.Sub(records.LastUpdateTime) > a.dataReserveDuration {
if now.Sub(records.lastUpdateTime) > a.dataReserveDuration {
delete(a.records, key)
count++
}
+33
View File
@@ -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
View File
@@ -326,40 +326,16 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
}
protocol := vm.Protocol
vResp := &msg.NatHoleResp{
TransactionID: vm.TransactionID,
Sid: session.sid,
Protocol: protocol,
CandidateAddrs: slices.Compact(cm.MappedAddrs),
AssistedAddrs: slices.Compact(cm.AssistedAddrs),
DetectBehavior: msg.NatHoleDetectBehavior{
Mode: mode,
Role: vBehavior.Role,
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),
},
}
vResp := newNatHoleResponse(
vm.TransactionID, session.sid, protocol, mode,
cm.MappedAddrs, cm.AssistedAddrs, vBehavior,
timeoutMs-vBehavior.SendDelayMs, cNatFeature.PortsDifference,
)
cResp := newNatHoleResponse(
cm.TransactionID, session.sid, protocol, mode,
vm.MappedAddrs, vm.AssistedAddrs, cBehavior,
timeoutMs-cBehavior.SendDelayMs, vNatFeature.PortsDifference,
)
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)
@@ -368,6 +344,38 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
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 {
if maxNumber <= 0 {
return nil
+7 -44
View File
@@ -17,17 +17,9 @@
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/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -37,57 +29,28 @@ func init() {
type HTTP2HTTPPlugin struct {
opts *v1.HTTP2HTTPPluginOptions
l *Listener
s *http.Server
*httpBridgePlugin
}
func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPPluginOptions)
listener := NewProxyListener()
p := &HTTP2HTTPPlugin{
opts: opts,
l: listener,
}
rp := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
rp := newHTTPBridgeReverseProxy(
func(r *httputil.ProxyRequest) {
req := r.Out
req.URL.Scheme = "http"
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)
}
rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
},
BufferPool: pool.NewBuffer(32 * 1024),
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
}
p.s = &http.Server{
Handler: rp,
ReadHeaderTimeout: 60 * time.Second,
}
go func() {
_ = p.s.Serve(listener)
}()
nil,
)
p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
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 {
return v1.PluginHTTP2HTTP
}
func (p *HTTP2HTTPPlugin) Close() error {
return p.s.Close()
}
+7 -44
View File
@@ -17,18 +17,11 @@
package client
import (
"context"
"crypto/tls"
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/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
@@ -38,65 +31,35 @@ func init() {
type HTTP2HTTPSPlugin struct {
opts *v1.HTTP2HTTPSPluginOptions
l *Listener
s *http.Server
*httpBridgePlugin
}
func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTP2HTTPSPluginOptions)
listener := NewProxyListener()
p := &HTTP2HTTPSPlugin{
opts: opts,
l: listener,
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
rp := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
rp := newHTTPBridgeReverseProxy(
func(r *httputil.ProxyRequest) {
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-Proto"] = r.In.Header["X-Forwarded-Proto"]
req := r.Out
req.URL.Scheme = "https"
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)
}
rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
},
Transport: tr,
BufferPool: pool.NewBuffer(32 * 1024),
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
}
p.s = &http.Server{
Handler: rp,
ReadHeaderTimeout: 60 * time.Second,
}
go func() {
_ = p.s.Serve(listener)
}()
tr,
)
p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
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 {
return v1.PluginHTTP2HTTPS
}
func (p *HTTP2HTTPSPlugin) Close() error {
return p.s.Close()
}
+126
View File
@@ -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
}
+9 -67
View File
@@ -17,22 +17,9 @@
package client
import (
"context"
"crypto/tls"
"fmt"
stdlog "log"
"net/http"
"net/http/httputil"
"time"
"github.com/fatedier/golib/pool"
"github.com/samber/lo"
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() {
@@ -42,80 +29,35 @@ func init() {
type HTTPS2HTTPPlugin struct {
opts *v1.HTTPS2HTTPPluginOptions
l *Listener
s *http.Server
*httpBridgePlugin
}
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPPluginOptions)
listener := NewProxyListener()
p := &HTTPS2HTTPPlugin{
opts: opts,
l: listener,
}
rp := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
rp := newHTTPBridgeReverseProxy(
func(r *httputil.ProxyRequest) {
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
r.SetXForwarded()
req := r.Out
req.URL.Scheme = "http"
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)
}
rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
},
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)
})
nil,
)
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 {
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
}
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 {
return v1.PluginHTTPS2HTTP
}
func (p *HTTPS2HTTPPlugin) Close() error {
return p.s.Close()
}
+9 -67
View File
@@ -17,22 +17,11 @@
package client
import (
"context"
"crypto/tls"
"fmt"
stdlog "log"
"net/http"
"net/http/httputil"
"time"
"github.com/fatedier/golib/pool"
"github.com/samber/lo"
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() {
@@ -42,86 +31,39 @@ func init() {
type HTTPS2HTTPSPlugin struct {
opts *v1.HTTPS2HTTPSPluginOptions
l *Listener
s *http.Server
*httpBridgePlugin
}
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
listener := NewProxyListener()
p := &HTTPS2HTTPSPlugin{
opts: opts,
l: listener,
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
rp := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
rp := newHTTPBridgeReverseProxy(
func(r *httputil.ProxyRequest) {
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
r.SetXForwarded()
req := r.Out
req.URL.Scheme = "https"
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)
}
rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
},
Transport: 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)
})
tr,
)
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 {
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
}
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 {
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)
})
}
+9
View File
@@ -72,6 +72,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
return
}
if connInfo.ProxyProtocolHeader != nil {
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
rawConn.Close()
tlsConn.Close()
return
}
}
libio.Join(tlsConn, rawConn)
}
+68 -159
View File
@@ -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) {
if p.IsSupport(OpLogin) {
m.loginPlugins = append(m.loginPlugins, p)
@@ -66,71 +127,11 @@ func (m *Manager) Register(p Plugin) {
}
func (m *Manager) Login(content *LoginContent) (*LoginContent, error) {
if len(m.loginPlugins) == 0 {
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
return handleMutableContent(m.loginPlugins, OpLogin, content, pluginErrorLogWarn)
}
func (m *Manager) NewProxy(content *NewProxyContent) (*NewProxyContent, error) {
if len(m.newProxyPlugins) == 0 {
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
return handleMutableContent(m.newProxyPlugins, OpNewProxy, content, pluginErrorLogWarn)
}
func (m *Manager) CloseProxy(content *CloseProxyContent) error {
@@ -139,10 +140,7 @@ func (m *Manager) CloseProxy(content *CloseProxyContent) error {
}
errs := make([]string, 0)
reqid, _ := util.RandID()
xl := xlog.New().AppendPrefix("reqid: " + reqid)
ctx := xlog.NewContext(context.Background(), xl)
ctx = NewReqidContext(ctx, reqid)
ctx, xl := newPluginRequestContext()
for _, p := range m.closeProxyPlugins {
_, _, 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) {
if len(m.pingPlugins) == 0 {
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
return handleMutableContent(m.pingPlugins, OpPing, content, pluginErrorLogWarn)
}
func (m *Manager) NewWorkConn(content *NewWorkConnContent) (*NewWorkConnContent, error) {
if len(m.newWorkConnPlugins) == 0 {
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
return handleMutableContent(m.newWorkConnPlugins, OpNewWorkConn, content, pluginErrorLogWarn)
}
func (m *Manager) NewUserConn(content *NewUserConnContent) (*NewUserConnContent, error) {
if len(m.newUserConnPlugins) == 0 {
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.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
// Preserve the pre-refactor log level for NewUserConn plugin errors.
return handleMutableContent(m.newUserConnPlugins, OpNewUserConn, content, pluginErrorLogInfo)
}
+336
View File
@@ -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)
}
}
}
+2
View File
@@ -140,6 +140,8 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
_, err = udpConn.Write(buf)
if err != nil {
udpConn.Close()
} else {
_ = udpConn.SetReadDeadline(time.Now().Add(30 * time.Second))
}
if !ok {
+30
View File
@@ -26,6 +26,12 @@ type GeneralResponse struct {
Msg string
}
type V2Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
// APIHandler is a handler function that returns a response object or an error.
type APIHandler func(ctx *Context) (any, error)
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
}
}
}
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := NewContext(w, r)
res, err := handler(ctx)
if err != nil {
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
code := http.StatusInternalServerError
if e, ok := err.(*Error); ok {
code = e.Code
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
}
}
+32 -28
View File
@@ -17,6 +17,8 @@ package metric
import (
"sync"
"time"
"k8s.io/utils/clock"
)
type DateCounter interface {
@@ -38,27 +40,33 @@ func NewDateCounter(reserveDays int64) DateCounter {
type StandardDateCounter struct {
reserveDays int64
counts []int64
clock clock.PassiveClock
lastUpdateDate time.Time
mu sync.Mutex
}
func newStandardDateCounter(reserveDays int64) *StandardDateCounter {
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
s := &StandardDateCounter{
return newStandardDateCounterWithClock(reserveDays, clock.RealClock{})
}
func newStandardDateCounterWithClock(reserveDays int64, clk clock.PassiveClock) *StandardDateCounter {
if clk == nil {
clk = clock.RealClock{}
}
return &StandardDateCounter{
reserveDays: reserveDays,
counts: make([]int64, reserveDays),
lastUpdateDate: now,
clock: clk,
lastUpdateDate: startOfDay(clk.Now()),
}
return s
}
func (c *StandardDateCounter) TodayCount() int64 {
c.mu.Lock()
defer c.mu.Unlock()
c.rotate(time.Now())
c.rotate(c.clock.Now())
return c.counts[0]
}
@@ -70,65 +78,61 @@ func (c *StandardDateCounter) GetLastDaysCount(lastdays int64) []int64 {
c.mu.Lock()
defer c.mu.Unlock()
c.rotate(time.Now())
for i := 0; i < int(lastdays); i++ {
counts[i] = c.counts[i]
}
c.rotate(c.clock.Now())
copy(counts, c.counts)
return counts
}
func (c *StandardDateCounter) Inc(count int64) {
c.mu.Lock()
defer c.mu.Unlock()
c.rotate(time.Now())
c.rotate(c.clock.Now())
c.counts[0] += count
}
func (c *StandardDateCounter) Dec(count int64) {
c.mu.Lock()
defer c.mu.Unlock()
c.rotate(time.Now())
c.rotate(c.clock.Now())
c.counts[0] -= count
}
func (c *StandardDateCounter) Snapshot() DateCounter {
c.mu.Lock()
defer c.mu.Unlock()
tmp := newStandardDateCounter(c.reserveDays)
for i := 0; i < int(c.reserveDays); i++ {
tmp.counts[i] = c.counts[i]
}
tmp := newStandardDateCounterWithClock(c.reserveDays, c.clock)
copy(tmp.counts, c.counts)
return tmp
}
func (c *StandardDateCounter) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
for i := 0; i < int(c.reserveDays); i++ {
c.counts[i] = 0
}
clear(c.counts)
}
// rotate
// Must hold the lock before calling this function.
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)
defer func() {
c.lastUpdateDate = now
}()
reserveDays := int(c.reserveDays)
if days <= 0 {
return
} else if days >= int(c.reserveDays) {
} else if days >= reserveDays {
c.counts = make([]int64, c.reserveDays)
c.lastUpdateDate = now
return
}
newCounts := make([]int64, c.reserveDays)
for i := days; i < int(c.reserveDays); i++ {
newCounts[i] = c.counts[i-days]
}
copy(newCounts[days:], c.counts[:reserveDays-days])
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())
}
+107
View File
@@ -1,9 +1,12 @@
package metric
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
clocktesting "k8s.io/utils/clock/testing"
)
func TestDateCounter(t *testing.T) {
@@ -25,3 +28,107 @@ func TestDateCounter(t *testing.T) {
dcTmp := dc.Snapshot()
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()
}
+5
View File
@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
if err != nil {
return nil, nil, err
}
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
// frames and close the connection on invalid bytes.
conn.PayloadType = websocket.BinaryFrame
return ctx, conn, nil
}
}
+5
View File
@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
muxer := http.NewServeMux()
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
// frames and close the connection on invalid bytes.
c.PayloadType = websocket.BinaryFrame
notifyCh := make(chan struct{})
conn := WrapCloseNotifyConn(c, func(_ error) {
close(notifyCh)
+4 -1
View File
@@ -39,11 +39,14 @@ type HTTPConnectTCPMuxer struct {
func NewHTTPConnectTCPMuxer(listener net.Listener, passthrough bool, timeout time.Duration) (*HTTPConnectTCPMuxer, error) {
ret := &HTTPConnectTCPMuxer{passthrough: passthrough}
mux, err := vhost.NewMuxer(listener, ret.getHostFromHTTPConnect, timeout)
if err != nil {
return nil, err
}
mux.SetCheckAuthFunc(ret.auth).
SetSuccessHookFunc(ret.sendConnectResponse).
SetFailHookFunc(vhostFailed)
ret.Muxer = mux
return ret, err
return ret, nil
}
func (muxer *HTTPConnectTCPMuxer) readHTTPConnectRequest(rd io.Reader) (host, httpUser, httpPwd string, err error) {
+1 -1
View File
@@ -14,7 +14,7 @@
package version
var version = "0.69.0"
var version = "0.70.0"
func Full() string {
return version
+2 -47
View File
@@ -24,7 +24,6 @@ import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
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 {
vr, ok := rp.getVhost(domain, location, routeByHTTPUser)
vr, ok := rp.vhostRouter.getByRoute(domain, location, routeByHTTPUser)
if ok {
log.Debugf("get new http request host [%s] path [%s] httpuser [%s]", domain, location, routeByHTTPUser)
return vr.payload.(*RouteConfig)
@@ -171,7 +170,7 @@ func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser str
// CreateConnection create a new connection by route config
func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byEndpoint bool) (net.Conn, error) {
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 byEndpoint {
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
}
// 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) {
hj, ok := rw.(http.Hijacker)
if !ok {
+2 -2
View File
@@ -29,11 +29,11 @@ type HTTPSMuxer struct {
func NewHTTPSMuxer(listener net.Listener, timeout time.Duration) (*HTTPSMuxer, error) {
mux, err := NewMuxer(listener, GetHTTPSHostname, timeout)
mux.SetFailHookFunc(vhostFailed)
if err != nil {
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) {
+36 -6
View File
@@ -16,23 +16,53 @@ func TestGetHTTPSHostname(t *testing.T) {
require.NoError(err)
defer l.Close()
var conn net.Conn
connCh := make(chan net.Conn, 1)
acceptErrCh := make(chan error, 1)
go func() {
conn, _ = l.Accept()
require.NotNil(conn)
conn, err := l.Accept()
if err != nil {
acceptErrCh <- err
return
}
connCh <- conn
}()
clientErrCh := make(chan error, 1)
go func() {
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,
ServerName: "example.com",
})
if conn != nil {
_ = conn.Close()
}
clientErrCh <- err
}()
time.Sleep(200 * time.Millisecond)
_, infos, err := GetHTTPSHostname(conn)
var conn net.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.Equal("example.com", infos["Host"])
require.Equal("https", infos["Scheme"])
select {
case <-clientErrCh:
case <-time.After(time.Second):
t.Fatal("timed out waiting for TLS client")
}
}
+50
View File
@@ -93,12 +93,19 @@ func (r *Routers) Del(domain, location, httpUser string) {
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) {
host = strings.ToLower(host)
r.mutex.RLock()
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]
if !found {
return
@@ -117,6 +124,49 @@ func (r *Routers) Get(host, path, httpUser string) (vr *Router, exist bool) {
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) {
routersByHTTPUser, found := r.indexByDomain[host]
if !found {
+257
View File
@@ -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
View File
@@ -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) {
findRouter := func(inName, inPath, inHTTPUser string) (*Listener, bool) {
vr, ok := v.registryRouter.Get(inName, inPath, inHTTPUser)
vr, ok := v.registryRouter.getByRoute(name, path, httpUser)
if ok {
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
}
+13 -2
View File
@@ -18,6 +18,8 @@ import (
"math/rand/v2"
"time"
"k8s.io/utils/clock"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -48,6 +50,7 @@ type FastBackoffOptions struct {
type fastBackoffImpl struct {
options FastBackoffOptions
clock clock.PassiveClock
lastCalledTime time.Time
consecutiveErrCount int
@@ -57,18 +60,26 @@ type fastBackoffImpl struct {
}
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{
options: options,
clock: clk,
countsInFastRetryWindow: 1,
}
}
func (f *fastBackoffImpl) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration {
if f.lastCalledTime.IsZero() {
f.lastCalledTime = time.Now()
f.lastCalledTime = f.clock.Now()
return f.options.Duration
}
now := time.Now()
now := f.clock.Now()
f.lastCalledTime = now
if previousConditionError {
+27
View File
@@ -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)
}
-6
View File
@@ -19,7 +19,6 @@ import "strings"
// 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.
type LogWriter struct {
xl *Logger
logFunc func(string)
}
@@ -31,35 +30,30 @@ func (w LogWriter) Write(p []byte) (n int, err error) {
func NewTraceWriter(xl *Logger) LogWriter {
return LogWriter{
xl: xl,
logFunc: func(msg string) { xl.Tracef("%s", msg) },
}
}
func NewDebugWriter(xl *Logger) LogWriter {
return LogWriter{
xl: xl,
logFunc: func(msg string) { xl.Debugf("%s", msg) },
}
}
func NewInfoWriter(xl *Logger) LogWriter {
return LogWriter{
xl: xl,
logFunc: func(msg string) { xl.Infof("%s", msg) },
}
}
func NewWarnWriter(xl *Logger) LogWriter {
return LogWriter{
xl: xl,
logFunc: func(msg string) { xl.Warnf("%s", msg) },
}
}
func NewErrorWriter(xl *Logger) LogWriter {
return LogWriter{
xl: xl,
logFunc: func(msg string) { xl.Errorf("%s", msg) },
}
}
-2
View File
@@ -286,7 +286,6 @@ func (r *clientRouter) addRoute(name string, routes []net.IPNet, conn io.ReadWri
r.mu.Lock()
defer r.mu.Unlock()
r.routes[name] = &routeElement{
name: name,
routes: routes,
conn: conn,
}
@@ -383,7 +382,6 @@ func (r *serverRouter) cleanupConnIPs(conn io.Writer) {
}
type routeElement struct {
name string
routes []net.IPNet
conn io.ReadWriteCloser
}
+11
View File
@@ -48,6 +48,17 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
v2EncodedPathRouter.UseEncodedPath()
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
+3
View File
@@ -112,6 +112,8 @@ type SessionContext struct {
ServerCfg *v1.ServerConfig
// client registry
ClientRegistry *registry.ClientRegistry
// negotiated wire protocol for this client session
WireProtocol string
}
type Control struct {
@@ -452,6 +454,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
Configurer: pxyConf,
ServerCfg: ctl.sessionCtx.ServerCfg,
EncryptionKey: ctl.sessionCtx.EncryptionKey,
WireProtocol: ctl.sessionCtx.WireProtocol,
})
if err != nil {
return remoteAddr, err
+5 -3
View File
@@ -58,8 +58,12 @@ func NewController(
// /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
return c.buildServerInfoResp(), nil
}
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
serverStats := mem.StatsCollector.GetServer()
svrResp := model.ServerInfoResp{
return model.ServerInfoResp{
Version: version.Full(),
BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
@@ -80,8 +84,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts,
}
return svrResp, nil
}
// /api/clients
+647
View File
@@ -0,0 +1,647 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"cmp"
"fmt"
"maps"
"math"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/server/http/model"
"github.com/fatedier/frp/server/registry"
)
const (
defaultV2Page = 1
defaultV2PageSize = 50
maxV2PageSize = 200
v2SystemPruneTypeOfflineProxies = "offline_proxies"
v2ProxyTrafficDefaultDays = 7
v2ProxyTrafficUnit = "bytes"
v2ProxyTrafficGranularity = "day"
)
var apiV2ProxyTypes = []string{
string(v1.ProxyTypeTCP),
string(v1.ProxyTypeUDP),
string(v1.ProxyTypeHTTP),
string(v1.ProxyTypeHTTPS),
string(v1.ProxyTypeTCPMUX),
string(v1.ProxyTypeSTCP),
string(v1.ProxyTypeXTCP),
string(v1.ProxyTypeSUDP),
}
// /api/v2/system/info
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
info := c.buildServerInfoResp()
proxyTypeCounts := info.ProxyTypeCounts
if proxyTypeCounts == nil {
proxyTypeCounts = map[string]int64{}
}
return model.V2SystemInfoResp{
Version: info.Version,
Config: model.V2SystemInfoConfigResp{
BindPort: info.BindPort,
VhostHTTPPort: info.VhostHTTPPort,
VhostHTTPSPort: info.VhostHTTPSPort,
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
KCPBindPort: info.KCPBindPort,
QUICBindPort: info.QUICBindPort,
SubdomainHost: info.SubdomainHost,
MaxPoolCount: info.MaxPoolCount,
MaxPortsPerClient: info.MaxPortsPerClient,
HeartbeatTimeout: info.HeartBeatTimeout,
AllowPortsStr: info.AllowPortsStr,
TLSForce: info.TLSForce,
},
Status: model.V2SystemInfoStatusResp{
TotalTrafficIn: info.TotalTrafficIn,
TotalTrafficOut: info.TotalTrafficOut,
CurConns: info.CurConns,
ClientCounts: info.ClientCounts,
ProxyTypeCounts: proxyTypeCounts,
},
}, nil
}
// /api/v2/system/prune
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
if err != nil {
return nil, err
}
cleared, total := mem.StatsCollector.PruneOfflineProxies()
return model.V2SystemPruneResp{
Type: pruneType,
Cleared: cleared,
Total: total,
}, nil
}
// /api/v2/users
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
if err != nil {
return nil, err
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
userStats := make(map[string]*model.V2UserResp)
for _, info := range c.clientRegistry.List() {
item := getOrCreateV2User(userStats, info.User)
item.ClientCount++
}
for _, proxyInfo := range c.listV2ProxyStats("") {
item := getOrCreateV2User(userStats, proxyInfo.User)
item.ProxyCount++
}
q := strings.ToLower(ctx.Query("q"))
items := make([]model.V2UserResp, 0, len(userStats))
for _, item := range userStats {
if q != "" && !strings.Contains(strings.ToLower(item.User), q) {
continue
}
items = append(items, *item)
}
slices.SortFunc(items, func(a, b model.V2UserResp) int {
return cmp.Compare(a.User, b.User)
})
return buildV2PageResp(items, page, pageSize), nil
}
// /api/v2/clients
func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
if err != nil {
return nil, err
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
if err != nil {
return nil, err
}
userFilter, filterByUser := queryValue(ctx, "user")
clientIDFilter := ctx.Query("clientID")
runIDFilter := ctx.Query("runID")
q := strings.ToLower(ctx.Query("q"))
records := c.clientRegistry.List()
items := make([]model.ClientInfoResp, 0, len(records))
for _, info := range records {
if filterByUser && info.User != userFilter {
continue
}
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
continue
}
if runIDFilter != "" && info.RunID != runIDFilter {
continue
}
if !matchV2StatusFilter(info.Online, statusFilter) {
continue
}
resp := buildClientInfoResp(info)
if q != "" && !matchV2ClientQuery(resp, q) {
continue
}
items = append(items, resp)
}
slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
return v
}
return cmp.Compare(a.Key, b.Key)
})
return buildV2PageResp(items, page, pageSize), nil
}
// /api/v2/clients/{key}
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
key, err := decodeV2PathParam(ctx, "key", "client key")
if err != nil {
return nil, err
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
info, ok := c.clientRegistry.GetByKey(key)
if !ok {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
}
resp := buildClientInfoResp(info)
status := c.buildV2ClientStatus(info)
return model.V2ClientDetailResp{
ClientInfoResp: resp,
Status: status,
}, nil
}
// /api/v2/proxies
func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx)
if err != nil {
return nil, err
}
statusFilter, err := parseV2StatusFilter(ctx.Query("status"))
if err != nil {
return nil, err
}
proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type"))
if err != nil {
return nil, err
}
userFilter, filterByUser := queryValue(ctx, "user")
clientIDFilter := ctx.Query("clientID")
q := strings.ToLower(ctx.Query("q"))
stats := c.listV2ProxyStats(proxyType)
items := make([]model.V2ProxyResp, 0, len(stats))
for _, ps := range stats {
resp := c.buildV2ProxyResp(ps)
if filterByUser && resp.User != userFilter {
continue
}
if clientIDFilter != "" && resp.ClientID != clientIDFilter {
continue
}
if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) {
continue
}
if q != "" && !matchV2ProxyQuery(resp, q) {
continue
}
items = append(items, resp)
}
slices.SortFunc(items, func(a, b model.V2ProxyResp) int {
if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 {
return v
}
return cmp.Compare(a.Name, b.Name)
})
return buildV2PageResp(items, page, pageSize), nil
}
// /api/v2/proxies/{name}
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
name, err := decodeV2PathParam(ctx, "name", "proxy name")
if err != nil {
return nil, err
}
ps := mem.StatsCollector.GetProxyByName(name)
if ps == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
return c.buildV2ProxyResp(ps), nil
}
// /api/v2/proxies/{name}/traffic
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
name, err := decodeV2PathParam(ctx, "name", "proxy name")
if err != nil {
return nil, err
}
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
if proxyTrafficInfo == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
}
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
raw := ctx.Param(key)
if raw == "" {
return "", fmt.Errorf("missing %s", label)
}
decoded, err := url.PathUnescape(raw)
if err != nil {
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
}
return decoded, nil
}
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
item, ok := items[user]
if !ok {
item = &model.V2UserResp{User: user}
items[user] = item
}
return item
}
func parseV2PageParams(ctx *httppkg.Context) (int, int, error) {
page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page")
if err != nil {
return 0, 0, err
}
pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize")
if err != nil {
return 0, 0, err
}
if pageSize > maxV2PageSize {
return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize))
}
if page > math.MaxInt/pageSize {
return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large")
}
return page, pageSize, nil
}
func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) {
if raw == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < 1 {
return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name))
}
return value, nil
}
func parseV2StatusFilter(raw string) (string, error) {
status := strings.ToLower(raw)
switch status {
case "", "all", "online", "offline":
return status, nil
default:
return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline")
}
}
func parseV2ProxyTypeFilter(raw string) (string, error) {
proxyType := strings.ToLower(raw)
if proxyType == "" {
return "", nil
}
if slices.Contains(apiV2ProxyTypes, proxyType) {
return proxyType, nil
}
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
}
func parseV2SystemPruneType(raw string) (string, error) {
pruneType := strings.ToLower(raw)
switch pruneType {
case "":
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
case v2SystemPruneTypeOfflineProxies:
return pruneType, nil
default:
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
}
}
func matchV2StatusFilter(online bool, filter string) bool {
switch filter {
case "", "all":
return true
case "online":
return online
case "offline":
return !online
default:
return true
}
}
func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] {
total := len(items)
return model.V2PageResp[T]{
Total: total,
Page: page,
PageSize: pageSize,
Items: paginateV2Items(items, page, pageSize),
}
}
func paginateV2Items[T any](items []T, page, pageSize int) []T {
start := (page - 1) * pageSize
if start >= len(items) {
return []T{}
}
end := min(start+pageSize, len(items))
return items[start:end]
}
func queryValue(ctx *httppkg.Context, key string) (string, bool) {
values, ok := ctx.Req.URL.Query()[key]
if !ok {
return "", false
}
if len(values) == 0 {
return "", true
}
return values[0], true
}
func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
return containsV2Query(q,
item.Key,
item.User,
item.ClientID,
item.RunID,
item.Version,
item.WireProtocol,
item.Hostname,
item.ClientIP,
)
}
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
values := []string{
item.Name,
item.Spec.Type,
item.User,
item.ClientID,
item.Status.State,
}
switch item.Spec.Type {
case string(v1.ProxyTypeTCP):
if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil {
values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort))
}
case string(v1.ProxyTypeUDP):
if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil {
values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort))
}
case string(v1.ProxyTypeHTTP):
if item.Spec.HTTP != nil {
values = append(values, item.Spec.HTTP.CustomDomains...)
values = append(values, item.Spec.HTTP.Subdomain)
}
case string(v1.ProxyTypeHTTPS):
if item.Spec.HTTPS != nil {
values = append(values, item.Spec.HTTPS.CustomDomains...)
values = append(values, item.Spec.HTTPS.Subdomain)
}
case string(v1.ProxyTypeTCPMUX):
if item.Spec.TCPMux != nil {
values = append(values, item.Spec.TCPMux.CustomDomains...)
values = append(values, item.Spec.TCPMux.Subdomain)
}
}
return containsV2Query(q, values...)
}
func containsV2Query(q string, values ...string) bool {
for _, value := range values {
if strings.Contains(strings.ToLower(value), q) {
return true
}
}
return false
}
func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
if proxyType != "" {
return mem.StatsCollector.GetProxiesByType(proxyType)
}
items := make([]*mem.ProxyStats, 0)
for _, t := range apiV2ProxyTypes {
items = append(items, mem.StatsCollector.GetProxiesByType(t)...)
}
return items
}
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
history = append(history, model.V2ProxyTrafficPointResp{
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
})
}
return model.V2ProxyTrafficResp{
Name: name,
Unit: v2ProxyTrafficUnit,
Granularity: v2ProxyTrafficGranularity,
History: history,
}
}
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
if todayFirstIndex >= len(values) {
return 0
}
return values[todayFirstIndex]
}
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
status := model.V2ClientStatusResp{State: "offline"}
if info.Online {
status.State = "online"
}
user := info.User
clientID := info.ClientID()
for _, ps := range c.listV2ProxyStats("") {
if ps.User != user || ps.ClientID != clientID {
continue
}
status.CurConns += ps.CurConns
status.ProxyCount++
}
return status
}
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
state := "offline"
var cfg v1.ProxyConfigurer
if c.pxyManager != nil {
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
state = "online"
cfg = pxy.GetConfigurer()
}
}
return model.V2ProxyResp{
Name: ps.Name,
User: ps.User,
ClientID: ps.ClientID,
Spec: buildV2ProxySpec(ps.Type, cfg),
Status: model.V2ProxyStatusResp{
State: state,
TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns,
LastStartAt: ps.LastStartAt,
LastCloseAt: ps.LastCloseAt,
},
}
}
func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec {
spec := model.V2ProxySpec{Type: proxyType}
switch proxyType {
case string(v1.ProxyTypeTCP):
block := &model.V2TCPProxySpec{}
if c, ok := cfg.(*v1.TCPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.RemotePort = &c.RemotePort
}
spec.TCP = block
case string(v1.ProxyTypeUDP):
block := &model.V2UDPProxySpec{}
if c, ok := cfg.(*v1.UDPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.RemotePort = &c.RemotePort
}
spec.UDP = block
case string(v1.ProxyTypeHTTP):
block := &model.V2HTTPProxySpec{}
if c, ok := cfg.(*v1.HTTPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.CustomDomains = slices.Clone(c.CustomDomains)
block.Subdomain = c.SubDomain
block.Locations = slices.Clone(c.Locations)
block.HostHeaderRewrite = c.HostHeaderRewrite
}
spec.HTTP = block
case string(v1.ProxyTypeHTTPS):
block := &model.V2HTTPSProxySpec{}
if c, ok := cfg.(*v1.HTTPSProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.CustomDomains = slices.Clone(c.CustomDomains)
block.Subdomain = c.SubDomain
}
spec.HTTPS = block
case string(v1.ProxyTypeTCPMUX):
block := &model.V2TCPMuxProxySpec{}
if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
block.CustomDomains = slices.Clone(c.CustomDomains)
block.Subdomain = c.SubDomain
block.Multiplexer = c.Multiplexer
block.RouteByHTTPUser = c.RouteByHTTPUser
}
spec.TCPMux = block
case string(v1.ProxyTypeSTCP):
block := &model.V2STCPProxySpec{}
if c, ok := cfg.(*v1.STCPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
}
spec.STCP = block
case string(v1.ProxyTypeSUDP):
block := &model.V2SUDPProxySpec{}
if c, ok := cfg.(*v1.SUDPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
}
spec.SUDP = block
case string(v1.ProxyTypeXTCP):
block := &model.V2XTCPProxySpec{}
if c, ok := cfg.(*v1.XTCPProxyConfig); ok {
block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig())
}
spec.XTCP = block
}
return spec
}
func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec {
return model.V2ProxyBaseSpec{
Annotations: maps.Clone(base.Annotations),
Metadatas: maps.Clone(base.Metadatas),
Transport: &model.V2ProxyTransportSpec{
UseEncryption: base.Transport.UseEncryption,
UseCompression: base.Transport.UseCompression,
BandwidthLimit: base.Transport.BandwidthLimit.String(),
BandwidthLimitMode: base.Transport.BandwidthLimitMode,
},
LoadBalancer: &model.V2ProxyLoadBalancerSpec{
Group: base.LoadBalancer.Group,
},
}
}
@@ -0,0 +1,393 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"encoding/json"
"strings"
"testing"
configtypes "github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
"github.com/fatedier/frp/server/http/model"
)
func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) {
tests := []struct {
proxyType string
cfg v1.ProxyConfigurer
blockKeys []string
}{
{
proxyType: "tcp",
cfg: &v1.TCPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"),
RemotePort: 6000,
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
},
{
proxyType: "udp",
cfg: &v1.UDPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
RemotePort: 7000,
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"},
},
{
proxyType: "http",
cfg: &v1.HTTPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"),
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"},
Locations: []string{"/api"},
HTTPUser: "secret-http-user",
HTTPPassword: "secret-http-password",
HostHeaderRewrite: "backend.example.com",
RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}},
ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}},
RouteByHTTPUser: "secret-http-route-user",
},
blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"},
},
{
proxyType: "https",
cfg: &v1.HTTPSProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"),
DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"},
},
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"},
},
{
proxyType: "tcpmux",
cfg: &v1.TCPMuxProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"),
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"},
HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"),
HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"),
RouteByHTTPUser: "displayed-mux-user",
Multiplexer: "httpconnect",
},
blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"},
},
{
proxyType: "stcp",
cfg: &v1.STCPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"),
Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"),
AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")},
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
},
{
proxyType: "sudp",
cfg: &v1.SUDPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"),
Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"),
AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")},
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
},
{
proxyType: "xtcp",
cfg: &v1.XTCPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"),
Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"),
AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")},
},
blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"},
},
}
for _, tt := range tests {
t.Run(tt.proxyType, func(t *testing.T) {
spec := buildV2ProxySpec(tt.proxyType, tt.cfg)
raw := mustMarshalJSON(t, spec)
var specObject map[string]json.RawMessage
if err := json.Unmarshal(raw, &specObject); err != nil {
t.Fatalf("unmarshal spec failed: %v", err)
}
assertRawJSONKeys(t, specObject, tt.proxyType, "type")
var gotType string
if err := json.Unmarshal(specObject["type"], &gotType); err != nil {
t.Fatalf("unmarshal spec type failed: %v", err)
}
if gotType != tt.proxyType {
t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType)
}
var block map[string]json.RawMessage
if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil {
t.Fatalf("unmarshal active block failed: %v", err)
}
assertRawJSONKeys(t, block, tt.blockKeys...)
assertV2ProxyCommonSpec(t, block)
assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType])
assertNoV2ProxySensitiveFields(t, block)
content := string(raw)
for _, secret := range []string{
"secret-proxy-name",
"secret-group-key",
"secret-local-host",
"secret-plugin-user",
"secret-plugin-password",
"secret-health-path",
"secret-http-user",
"secret-http-password",
"secret-request-header",
"secret-response-header",
"secret-http-route-user",
"secret-mux-http-user",
"secret-mux-http-password",
"secret-stcp-key",
"secret-stcp-user",
"secret-sudp-key",
"secret-sudp-user",
"secret-xtcp-key",
"secret-xtcp-user",
} {
if strings.Contains(content, secret) {
t.Fatalf("sensitive value %q leaked in spec: %s", secret, content)
}
}
})
}
}
func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) {
t.Helper()
switch proxyType {
case "tcp":
var block model.V2TCPProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal tcp block failed: %v", err)
}
if block.RemotePort == nil || *block.RemotePort != 6000 {
t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort)
}
case "udp":
var block model.V2UDPProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal udp block failed: %v", err)
}
if block.RemotePort == nil || *block.RemotePort != 7000 {
t.Fatalf("udp remote port mismatch: %#v", block.RemotePort)
}
case "http":
var block model.V2HTTPProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal http block failed: %v", err)
}
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" ||
block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" ||
block.HostHeaderRewrite != "backend.example.com" {
t.Fatalf("http fields mismatch: %#v", block)
}
case "https":
var block model.V2HTTPSProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal https block failed: %v", err)
}
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" {
t.Fatalf("https fields mismatch: %#v", block)
}
case "tcpmux":
var block model.V2TCPMuxProxySpec
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("unmarshal tcpmux block failed: %v", err)
}
if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" ||
block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" {
t.Fatalf("tcpmux fields mismatch: %#v", block)
}
}
}
func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) {
for _, proxyType := range apiV2ProxyTypes {
t.Run(proxyType, func(t *testing.T) {
resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{
Name: "offline-" + proxyType,
Type: proxyType,
})
if resp.Status.State != "offline" {
t.Fatalf("offline phase mismatch: %#v", resp.Status)
}
var specObject map[string]json.RawMessage
if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil {
t.Fatalf("unmarshal offline spec failed: %v", err)
}
assertRawJSONKeys(t, specObject, proxyType, "type")
assertRawJSONKeysFromMessage(t, specObject[proxyType])
})
}
}
func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) {
spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{
ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"),
RemotePort: 7000,
})
var specObject map[string]json.RawMessage
if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil {
t.Fatalf("unmarshal mismatched spec failed: %v", err)
}
assertRawJSONKeys(t, specObject, "tcp", "type")
assertRawJSONKeysFromMessage(t, specObject["tcp"])
}
func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig {
t.Helper()
bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB")
if err != nil {
t.Fatalf("create bandwidth limit failed: %v", err)
}
enabled := false
return v1.ProxyBaseConfig{
Name: "secret-proxy-name",
Type: proxyType,
Enabled: &enabled,
Annotations: map[string]string{"annotation-key": "annotation-value"},
Metadatas: map[string]string{"metadata-key": "metadata-value"},
Transport: v1.ProxyTransport{
UseEncryption: true,
UseCompression: true,
BandwidthLimit: bandwidthLimit,
BandwidthLimitMode: configtypes.BandwidthLimitModeServer,
ProxyProtocolVersion: "v2",
},
LoadBalancer: v1.LoadBalancerConfig{
Group: "public-group",
GroupKey: "secret-group-key",
},
HealthCheck: v1.HealthCheckConfig{
Type: "http",
Path: "secret-health-path",
},
ProxyBackend: v1.ProxyBackend{
LocalIP: "secret-local-host",
LocalPort: 8080,
Plugin: v1.TypedClientPluginOptions{
Type: v1.PluginHTTPProxy,
ClientPluginOptions: &v1.HTTPProxyPluginOptions{
Type: v1.PluginHTTPProxy,
HTTPUser: "secret-plugin-user",
HTTPPassword: "secret-plugin-password",
},
},
},
}
}
func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) {
t.Helper()
var annotations map[string]string
if err := json.Unmarshal(block["annotations"], &annotations); err != nil {
t.Fatalf("unmarshal annotations failed: %v", err)
}
if annotations["annotation-key"] != "annotation-value" {
t.Fatalf("annotations mismatch: %#v", annotations)
}
var metadatas map[string]string
if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil {
t.Fatalf("unmarshal metadatas failed: %v", err)
}
if metadatas["metadata-key"] != "metadata-value" {
t.Fatalf("metadatas mismatch: %#v", metadatas)
}
assertRawJSONKeysFromMessage(t, block["transport"],
"bandwidthLimit",
"bandwidthLimitMode",
"useCompression",
"useEncryption",
)
var transport model.V2ProxyTransportSpec
if err := json.Unmarshal(block["transport"], &transport); err != nil {
t.Fatalf("unmarshal transport failed: %v", err)
}
if !transport.UseEncryption || !transport.UseCompression ||
transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" {
t.Fatalf("transport mismatch: %#v", transport)
}
assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group")
var loadBalancer model.V2ProxyLoadBalancerSpec
if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil {
t.Fatalf("unmarshal load balancer failed: %v", err)
}
if loadBalancer.Group != "public-group" {
t.Fatalf("load balancer mismatch: %#v", loadBalancer)
}
}
func assertNoV2ProxySensitiveFields(t *testing.T, value any) {
t.Helper()
forbidden := map[string]struct{}{
"allowUsers": {},
"enabled": {},
"groupKey": {},
"healthCheck": {},
"httpPassword": {},
"httpUser": {},
"localIP": {},
"localPort": {},
"name": {},
"natTraversal": {},
"plugin": {},
"proxyProtocolVersion": {},
"requestHeaders": {},
"responseHeaders": {},
"secretKey": {},
"type": {},
}
var walk func(any)
walk = func(current any) {
switch current := current.(type) {
case map[string]any:
for key, nested := range current {
if _, ok := forbidden[key]; ok {
t.Fatalf("sensitive field %q leaked in active block", key)
}
walk(nested)
}
case []any:
for _, nested := range current {
walk(nested)
}
}
}
raw, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal active block failed: %v", err)
}
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode active block failed: %v", err)
}
walk(decoded)
}
+908
View File
@@ -0,0 +1,908 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/server/http/model"
serverproxy "github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
type v2EnvelopeForTest[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
type fakeStatsCollector struct {
server *mem.ServerStats
proxies map[string]*mem.ProxyStats
traffic map[string]*mem.ProxyTrafficInfo
pruneable map[string]bool
}
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
if f.server != nil {
return f.server
}
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
}
func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
items := make([]*mem.ProxyStats, 0)
for _, ps := range f.proxies {
if ps.Type == proxyType {
items = append(items, ps)
}
}
return items
}
func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
ps := f.proxies[proxyName]
if ps != nil && ps.Type == proxyType {
return ps
}
return nil
}
func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
return f.proxies[proxyName]
}
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
return f.traffic[name]
}
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
return 0, len(f.proxies)
}
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
total := len(f.proxies)
cleared := 0
for name := range f.pruneable {
if _, ok := f.proxies[name]; ok {
delete(f.proxies, name)
cleared++
}
}
f.pruneable = map[string]bool{}
return cleared, total
}
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
server: &mem.ServerStats{
TotalTrafficIn: 1024,
TotalTrafficOut: 2048,
CurConns: 3,
ClientCounts: 4,
ProxyTypeCounts: map[string]int64{
"tcp": 2,
"http": 1,
},
},
proxies: map[string]*mem.ProxyStats{},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
VhostHTTPSPort: 8443,
TCPMuxHTTPConnectPort: 9000,
KCPBindPort: 7001,
QUICBindPort: 7002,
SubDomainHost: "example.com",
MaxPortsPerClient: 8,
AllowPorts: []types.PortsRange{
{Start: 1000, End: 1002},
{Single: 2000},
},
Transport: v1.ServerTransportConfig{
MaxPoolCount: 5,
HeartbeatTimeout: 90,
TLS: v1.TLSServerConfig{
Force: true,
},
},
}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/system/info")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
"allowPortsStr",
"bindPort",
"heartbeatTimeout",
"kcpBindPort",
"maxPoolCount",
"maxPortsPerClient",
"quicBindPort",
"subdomainHost",
"tcpmuxHTTPConnectPort",
"tlsForce",
"vhostHTTPPort",
"vhostHTTPSPort",
)
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
"clientCounts",
"curConns",
"proxyTypeCount",
"totalTrafficIn",
"totalTrafficOut",
)
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
if systemResp.Data.Version == "" {
t.Fatal("version should be set at top level")
}
if systemResp.Data.Config.BindPort != 7000 ||
systemResp.Data.Config.VhostHTTPPort != 8080 ||
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
systemResp.Data.Config.KCPBindPort != 7001 ||
systemResp.Data.Config.QUICBindPort != 7002 ||
systemResp.Data.Config.SubdomainHost != "example.com" ||
systemResp.Data.Config.MaxPoolCount != 5 ||
systemResp.Data.Config.MaxPortsPerClient != 8 ||
systemResp.Data.Config.HeartbeatTimeout != 90 ||
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
!systemResp.Data.Config.TLSForce {
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
}
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
systemResp.Data.Status.TotalTrafficOut != 2048 ||
systemResp.Data.Status.CurConns != 3 ||
systemResp.Data.Status.ClientCounts != 4 ||
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
}
}
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
oldStatsCollector := mem.StatsCollector
collector := &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
"http-offline": {Name: "http-offline", Type: "http"},
"udp-offline": {Name: "udp-offline", Type: "udp"},
"tcp-online": {Name: "tcp-online", Type: "tcp"},
"http-online": {Name: "http-online", Type: "http"},
"udp-online": {Name: "udp-online", Type: "udp"},
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
},
pruneable: map[string]bool{
"tcp-offline": true,
"http-offline": true,
"udp-offline": true,
},
}
mem.StatsCollector = collector
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
}
if _, ok := collector.proxies["tcp-offline"]; ok {
t.Fatal("pruned proxy statistics should be removed")
}
if _, ok := collector.proxies["tcp-online"]; !ok {
t.Fatal("online proxy statistics should remain")
}
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
if resp.Code != http.StatusOK {
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
}
}
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
}
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
if resp.Code != http.StatusBadRequest {
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp = decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
}
}
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", pageResp)
}
if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
t.Fatalf("page data mismatch: %#v", pageResp.Data)
}
if got := pageResp.Data.Items[0].User; got != "" {
t.Fatalf("first sorted user mismatch, want empty got %q", got)
}
resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
}
resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
}
resp = performRequest(router, "/api/v2/clients?status=offline")
offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
}
}
func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/clients?page=0")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
t.Fatalf("error envelope mismatch: %#v", errResp)
}
resp = performRequest(router, "/api/v2/clients?pageSize=201")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
}
}
func TestAPIV2ClientDetailEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/clients/alice.client-a")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
}
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
}
}
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-url": {
Name: "tcp-url",
Type: "tcp",
User: "url",
ClientID: "client/a?b#c",
CurConns: 7,
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
clientRegistry := registry.NewClientRegistry()
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
router := newV2TestRouter(controller)
encodedKey := url.PathEscape("url.client/a?b#c")
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
if resp.Code != http.StatusOK {
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
}
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
}
}
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies?type=invalid")
if resp.Code != http.StatusBadRequest {
t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
}
resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
if proxyResp.Data.Total != 1 {
t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data)
}
proxyItem := proxyResp.Data.Items[0]
if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
t.Fatalf("proxy item mismatch: %#v", proxyItem)
}
rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp)
assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user")
var rawListSpec map[string]json.RawMessage
if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil {
t.Fatalf("unmarshal list proxy spec failed: %v", err)
}
assertRawJSONKeys(t, rawListSpec, "tcp", "type")
assertRawJSONKeysFromMessage(t, rawListSpec["tcp"])
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
"curConns",
"lastCloseAt",
"lastStartAt",
"phase",
"todayTrafficIn",
"todayTrafficOut",
)
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
}
assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user")
var rawDetailSpec map[string]json.RawMessage
if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil {
t.Fatalf("unmarshal detail proxy spec failed: %v", err)
}
assertRawJSONKeys(t, rawDetailSpec, "tcp", "type")
assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"])
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
}
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
if userResp.Data.Total != 3 {
t.Fatalf("user total mismatch: %#v", userResp.Data)
}
expectedProxyCounts := map[string]int{
"": 1,
"alice": 2,
"bob": 1,
}
for _, item := range userResp.Data.Items {
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
t.Fatalf("user counts mismatch: %#v", item)
}
}
}
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"ssh": {Name: "ssh", Type: "tcp"},
},
traffic: map[string]*mem.ProxyTrafficInfo{
"ssh": {
Name: "ssh",
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
t.Fatalf("envelope mismatch: %#v", rawResp)
}
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
}
if len(trafficResp.Data.History) != 7 {
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
}
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
var prevDate time.Time
for i, point := range trafficResp.Data.History {
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
}
parsedDate, err := time.Parse(time.DateOnly, point.Date)
if err != nil {
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
}
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
}
prevDate = parsedDate
}
}
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
if resp.Code != http.StatusNotFound {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
t.Fatalf("not found envelope mismatch: %#v", errResp)
}
}
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
name := "folder/ssh?x#y"
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
name: {Name: name, Type: "tcp", User: "encoded"},
},
traffic: map[string]*mem.ProxyTrafficInfo{
name: {
Name: name,
TrafficIn: []int64{1},
TrafficOut: []int64{2},
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager())
router := newV2TestRouter(controller)
encodedName := url.PathEscape(name)
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
if resp.Code != http.StatusOK {
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
}
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
if resp.Code != http.StatusOK {
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
if trafficResp.Data.Name != name {
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
}
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
}
}
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
controller := newV2TestController(t)
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
}
errResp := decodeResponse[httppkg.V2Response](t, resp)
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
}
}
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
tests := []struct {
name string
item model.V2ProxyResp
q string
want bool
}{
{
name: "tcp remote port",
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
}},
q: "6000",
want: true,
},
{
name: "udp remote port",
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
Type: "udp",
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
}},
q: "7000",
want: true,
},
{
name: "remote port does not match colon form",
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
}},
q: ":6000",
want: false,
},
{
name: "http custom domain",
item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{
Type: "http",
HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}},
}},
q: "app.example.com",
want: true,
},
{
name: "https subdomain",
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
Type: "https",
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
}},
q: "portal",
want: true,
},
{
name: "subdomain does not match expanded host",
item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{
Type: "https",
HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"},
}},
q: "portal.example.com",
want: false,
},
{
name: "tcpmux custom domain",
item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{
Type: "tcpmux",
TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}},
}},
q: "mux.example.com",
want: true,
},
{
name: "offline shell does not match online spec fields",
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{},
}},
q: "6000",
want: false,
},
{
name: "offline shell does not contribute zero remote port",
item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{},
}},
q: "0",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
}
})
}
}
func TestLegacyAPIResponsesRemainBare(t *testing.T) {
controller := newV2TestController(t)
router := newV2TestRouter(controller)
resp := performRequest(router, "/api/serverinfo")
var serverInfo model.ServerInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
}
if serverInfo.Version == "" {
t.Fatal("legacy serverinfo version should be set")
}
var serverInfoRaw map[string]json.RawMessage
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
}
if _, ok := serverInfoRaw["data"]; ok {
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
}
if _, ok := serverInfoRaw["config"]; ok {
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
}
resp = performRequest(router, "/api/clients")
var clients []model.ClientInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
}
if len(clients) != 3 {
t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
}
resp = performRequest(router, "/api/proxy/tcp")
var proxies model.GetProxyInfoResp
if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
}
if len(proxies.Proxies) != 2 {
t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
}
var envelope httppkg.V2Response
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
}
resp = performRequest(router, "/api/traffic/tcp-alice")
var traffic model.GetProxyTrafficResp
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
}
if traffic.Name != "tcp-alice" ||
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
}
var trafficRaw map[string]json.RawMessage
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
}
if _, ok := trafficRaw["data"]; ok {
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
}
}
func v2TestIntPtr(value int) *int {
return &value
}
func newV2TestController(t *testing.T) *Controller {
t.Helper()
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-empty": {
Name: "tcp-empty",
Type: "tcp",
User: "",
ClientID: "legacy-client",
TodayTrafficIn: 10,
TodayTrafficOut: 20,
CurConns: 1,
},
"tcp-alice": {
Name: "tcp-alice",
Type: "tcp",
User: "alice",
ClientID: "client-a",
TodayTrafficIn: 30,
TodayTrafficOut: 40,
CurConns: 2,
LastStartTime: "07-08 12:30:00",
LastCloseTime: "07-08 12:31:40",
LastStartAt: 1783504200,
LastCloseAt: 1783504300,
},
"http-alice": {
Name: "http-alice",
Type: "http",
User: "alice",
ClientID: "client-a",
CurConns: 3,
},
"udp-bob": {
Name: "udp-bob",
Type: "udp",
User: "bob",
ClientID: "client-b",
},
},
traffic: map[string]*mem.ProxyTrafficInfo{
"tcp-alice": {
Name: "tcp-alice",
TrafficIn: []int64{7, 6},
TrafficOut: []int64{70, 60},
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
clientRegistry := registry.NewClientRegistry()
clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
clientRegistry.MarkOfflineByRunID("run-b")
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
}
func newV2TestRouter(controller *Controller) *mux.Router {
router := mux.NewRouter()
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
encodedPathRouter := router.NewRoute().Subrouter()
encodedPathRouter.UseEncodedPath()
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
return router
}
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
return performRequestWithMethod(handler, http.MethodGet, target)
}
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
return resp
}
func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
}
return out
}
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
t.Helper()
if len(raw) != len(want) {
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
}
for _, key := range want {
if _, ok := raw[key]; !ok {
t.Fatalf("json key %q missing from %v", key, raw)
}
}
}
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
t.Helper()
var out map[string]json.RawMessage
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
}
assertRawJSONKeys(t, out, want...)
}
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
t.Helper()
out, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json failed: %v", err)
}
return out
}
+179
View File
@@ -0,0 +1,179 @@
// 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 model
type V2PageResp[T any] struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Items []T `json:"items"`
}
type V2SystemInfoResp struct {
Version string `json:"version"`
Config V2SystemInfoConfigResp `json:"config"`
Status V2SystemInfoStatusResp `json:"status"`
}
type V2SystemInfoConfigResp struct {
BindPort int `json:"bindPort"`
VhostHTTPPort int `json:"vhostHTTPPort"`
VhostHTTPSPort int `json:"vhostHTTPSPort"`
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
KCPBindPort int `json:"kcpBindPort"`
QUICBindPort int `json:"quicBindPort"`
SubdomainHost string `json:"subdomainHost"`
MaxPoolCount int64 `json:"maxPoolCount"`
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
AllowPortsStr string `json:"allowPortsStr"`
TLSForce bool `json:"tlsForce"`
}
type V2SystemInfoStatusResp struct {
TotalTrafficIn int64 `json:"totalTrafficIn"`
TotalTrafficOut int64 `json:"totalTrafficOut"`
CurConns int64 `json:"curConns"`
ClientCounts int64 `json:"clientCounts"`
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
type V2SystemPruneResp struct {
Type string `json:"type"`
Cleared int `json:"cleared"`
Total int `json:"total"`
}
type V2UserResp struct {
User string `json:"user"`
ClientCount int `json:"clientCount"`
ProxyCount int `json:"proxyCount"`
}
type V2ClientDetailResp struct {
ClientInfoResp
Status V2ClientStatusResp `json:"status"`
}
type V2ClientStatusResp struct {
State string `json:"phase"`
CurConns int64 `json:"curConns"`
ProxyCount int64 `json:"proxyCount"`
}
type V2ProxyResp struct {
Name string `json:"name"`
User string `json:"user"`
ClientID string `json:"clientID"`
Spec V2ProxySpec `json:"spec"`
Status V2ProxyStatusResp `json:"status"`
}
type V2ProxySpec struct {
Type string `json:"type"`
TCP *V2TCPProxySpec `json:"tcp,omitempty"`
UDP *V2UDPProxySpec `json:"udp,omitempty"`
HTTP *V2HTTPProxySpec `json:"http,omitempty"`
HTTPS *V2HTTPSProxySpec `json:"https,omitempty"`
TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"`
STCP *V2STCPProxySpec `json:"stcp,omitempty"`
SUDP *V2SUDPProxySpec `json:"sudp,omitempty"`
XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"`
}
type V2ProxyBaseSpec struct {
Annotations map[string]string `json:"annotations,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Transport *V2ProxyTransportSpec `json:"transport,omitempty"`
LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"`
}
type V2ProxyTransportSpec struct {
UseEncryption bool `json:"useEncryption"`
UseCompression bool `json:"useCompression"`
BandwidthLimit string `json:"bandwidthLimit"`
BandwidthLimitMode string `json:"bandwidthLimitMode"`
}
type V2ProxyLoadBalancerSpec struct {
Group string `json:"group"`
}
type V2TCPProxySpec struct {
V2ProxyBaseSpec
RemotePort *int `json:"remotePort,omitempty"`
}
type V2UDPProxySpec struct {
V2ProxyBaseSpec
RemotePort *int `json:"remotePort,omitempty"`
}
type V2HTTPProxySpec struct {
V2ProxyBaseSpec
CustomDomains []string `json:"customDomains,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
Locations []string `json:"locations,omitempty"`
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
}
type V2HTTPSProxySpec struct {
V2ProxyBaseSpec
CustomDomains []string `json:"customDomains,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
}
type V2TCPMuxProxySpec struct {
V2ProxyBaseSpec
CustomDomains []string `json:"customDomains,omitempty"`
Subdomain string `json:"subdomain,omitempty"`
Multiplexer string `json:"multiplexer,omitempty"`
RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"`
}
type V2STCPProxySpec struct {
V2ProxyBaseSpec
}
type V2SUDPProxySpec struct {
V2ProxyBaseSpec
}
type V2XTCPProxySpec struct {
V2ProxyBaseSpec
}
type V2ProxyStatusResp struct {
State string `json:"phase"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartAt int64 `json:"lastStartAt,omitempty"`
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
}
type V2ProxyTrafficResp struct {
Name string `json:"name"`
Unit string `json:"unit"`
Granularity string `json:"granularity"`
History []V2ProxyTrafficPointResp `json:"history"`
}
type V2ProxyTrafficPointResp struct {
Date string `json:"date"`
TrafficIn int64 `json:"trafficIn"`
TrafficOut int64 `json:"trafficOut"`
}
+43 -15
View File
@@ -7,6 +7,8 @@ import (
"sync"
"time"
"k8s.io/utils/clock"
"github.com/fatedier/frp/pkg/config/types"
)
@@ -38,16 +40,25 @@ type Manager struct {
bindAddr string
netType string
clock clock.WithTicker
mu sync.Mutex
}
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{
reservedPorts: make(map[string]*PortCtx),
usedPorts: make(map[int]*PortCtx),
freePorts: make(map[int]struct{}),
bindAddr: bindAddr,
netType: netType,
clock: clk,
}
if len(allowPorts) > 0 {
for _, pair := range allowPorts {
@@ -72,7 +83,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
portCtx := &PortCtx{
ProxyName: name,
Closed: false,
UpdateTime: time.Now(),
UpdateTime: pm.clock.Now(),
}
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 pm.isPortAvailable(ctx.Port) {
realPort = ctx.Port
pm.usedPorts[realPort] = portCtx
pm.reservedPorts[name] = portCtx
delete(pm.freePorts, realPort)
pm.markPortAcquiredLocked(name, realPort, portCtx)
return
}
}
@@ -109,9 +118,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
}
if pm.isPortAvailable(k) {
realPort = k
pm.usedPorts[realPort] = portCtx
pm.reservedPorts[name] = portCtx
delete(pm.freePorts, realPort)
pm.markPortAcquiredLocked(name, realPort, portCtx)
break
}
}
@@ -123,9 +130,7 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
if _, ok = pm.freePorts[port]; ok {
if pm.isPortAvailable(port) {
realPort = port
pm.usedPorts[realPort] = portCtx
pm.reservedPorts[name] = portCtx
delete(pm.freePorts, realPort)
pm.markPortAcquiredLocked(name, realPort, portCtx)
} else {
err = ErrPortUnAvailable
}
@@ -140,6 +145,13 @@ func (pm *Manager) Acquire(name string, port int) (realPort int, err error) {
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 {
if pm.netType == "udp" {
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{}{}
delete(pm.usedPorts, port)
ctx.Closed = true
ctx.UpdateTime = time.Now()
ctx.UpdateTime = pm.clock.Now()
}
}
// Release reserved port if it isn't used in last 24 hours.
func (pm *Manager) cleanReservedPortsWorker() {
pm.cleanReservedPortsWorkerUntil(nil)
}
func (pm *Manager) cleanReservedPortsWorkerUntil(stopCh <-chan struct{}) {
ticker := pm.clock.NewTicker(CleanReservedPortsInterval)
defer ticker.Stop()
for {
time.Sleep(CleanReservedPortsInterval)
select {
case <-ticker.C():
pm.cleanReservedPortsOnce()
case <-stopCh:
return
}
}
}
func (pm *Manager) cleanReservedPortsOnce() {
pm.mu.Lock()
defer pm.mu.Unlock()
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)
}
}
pm.mu.Unlock()
}
}
+72
View File
@@ -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
}
+140 -1
View File
@@ -16,6 +16,7 @@ package proxy
import (
"context"
"errors"
"fmt"
"io"
"net"
@@ -31,6 +32,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
plugin "github.com/fatedier/frp/pkg/plugin/server"
"github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/util/limit"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/xlog"
@@ -92,6 +94,7 @@ type BaseProxy struct {
userInfo plugin.UserInfo
loginMsg *msg.Login
configurer v1.ProxyConfigurer
wireProtocol string
mu sync.RWMutex
xl *xlog.Logger
@@ -315,13 +318,147 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
name := pxy.GetName()
proxyType := cfg.Type
metrics.Server.OpenConnection(name, proxyType)
inCount, outCount, _ := libio.Join(local, userConn)
inCount, outCount, _ := pxy.joinUserConnection(local, userConn, proxyType, xl)
metrics.Server.CloseConnection(name, proxyType)
metrics.Server.AddTrafficIn(name, proxyType, inCount)
metrics.Server.AddTrafficOut(name, proxyType, outCount)
xl.Debugf("join connections closed")
}
func (pxy *BaseProxy) joinUserConnection(local io.ReadWriteCloser, userConn net.Conn, proxyType string, xl *xlog.Logger) (int64, int64, []error) {
visitorWireProtocol := wireProtocolFromConn(userConn)
if proxyType == string(v1.ProxyTypeSUDP) && isMixedWireProtocol(pxy.wireProtocol, visitorWireProtocol) {
xl.Infof("bridge mixed SUDP payload codecs, proxy wireProtocol [%s], visitor wireProtocol [%s]",
normalizeWireProtocol(pxy.wireProtocol), normalizeWireProtocol(visitorWireProtocol))
return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, visitorWireProtocol, xl)
}
return libio.Join(local, userConn)
}
type wireProtocolGetter interface {
WireProtocol() string
}
func wireProtocolFromConn(conn net.Conn) string {
if getter, ok := conn.(wireProtocolGetter); ok {
return getter.WireProtocol()
}
return ""
}
func isMixedWireProtocol(left, right string) bool {
return normalizeWireProtocol(left) != normalizeWireProtocol(right)
}
func normalizeWireProtocol(wireProtocol string) string {
if wireProtocol == wire.ProtocolV2 {
return wire.ProtocolV2
}
return wire.ProtocolV1
}
func joinSUDPMessageBridge(
proxyConn io.ReadWriteCloser,
visitorConn io.ReadWriteCloser,
proxyWireProtocol string,
visitorWireProtocol string,
xl *xlog.Logger,
) (inCount int64, outCount int64, errs []error) {
// The mixed bridge decodes and re-encodes messages, so raw framed byte counts
// are not available. Count UDP payload bytes and ignore heartbeat traffic.
proxyRW := msg.NewReadWriter(proxyConn, proxyWireProtocol)
visitorRW := msg.NewReadWriter(visitorConn, visitorWireProtocol)
var (
once sync.Once
wait sync.WaitGroup
recordErrs = make([]error, 2)
)
closeBoth := func() {
_ = proxyConn.Close()
_ = visitorConn.Close()
}
wait.Add(2)
go func() {
defer wait.Done()
defer once.Do(closeBoth)
recordErrs[0] = bridgeSUDPProxyToVisitor(proxyRW, visitorRW, &outCount, xl)
}()
go func() {
defer wait.Done()
defer once.Do(closeBoth)
recordErrs[1] = bridgeSUDPVisitorToProxy(visitorRW, proxyRW, &inCount, xl)
}()
wait.Wait()
for _, err := range recordErrs {
if err != nil {
errs = append(errs, err)
}
}
return
}
func bridgeSUDPProxyToVisitor(from msg.ReadWriter, to msg.ReadWriter, count *int64, xl *xlog.Logger) error {
for {
rawMsg, err := from.ReadMsg()
if err != nil {
return normalizeSUDPBridgeError(err)
}
switch m := rawMsg.(type) {
case *msg.UDPPacket:
if err := to.WriteMsg(m); err != nil {
return normalizeSUDPBridgeError(err)
}
*count += int64(len(m.Content))
case *msg.Ping:
traceSUDPBridge(xl, "bridge SUDP ping from proxy to visitor")
if err := to.WriteMsg(m); err != nil {
return normalizeSUDPBridgeError(err)
}
default:
return fmt.Errorf("unexpected SUDP proxy message %T", rawMsg)
}
}
}
func bridgeSUDPVisitorToProxy(from msg.ReadWriter, to msg.ReadWriter, count *int64, xl *xlog.Logger) error {
for {
rawMsg, err := from.ReadMsg()
if err != nil {
return normalizeSUDPBridgeError(err)
}
switch m := rawMsg.(type) {
case *msg.UDPPacket:
if err := to.WriteMsg(m); err != nil {
return normalizeSUDPBridgeError(err)
}
*count += int64(len(m.Content))
case *msg.Ping:
traceSUDPBridge(xl, "drop SUDP ping from visitor to proxy")
continue
default:
return fmt.Errorf("unexpected SUDP visitor message %T", rawMsg)
}
}
}
func normalizeSUDPBridgeError(err error) error {
if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
func traceSUDPBridge(xl *xlog.Logger, format string, args ...any) {
if xl != nil {
xl.Tracef(format, args...)
}
}
type Options struct {
UserInfo plugin.UserInfo
LoginMsg *msg.Login
@@ -331,6 +468,7 @@ type Options struct {
Configurer v1.ProxyConfigurer
ServerCfg *v1.ServerConfig
EncryptionKey []byte
WireProtocol string
}
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
@@ -357,6 +495,7 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
userInfo: options.UserInfo,
loginMsg: options.LoginMsg,
configurer: configurer,
wireProtocol: options.WireProtocol,
}
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
+56
View File
@@ -15,12 +15,15 @@
package proxy
import (
"context"
"net"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
@@ -51,3 +54,56 @@ func TestWorkConnStartWritesStartWorkConn(t *testing.T) {
require.NoError(t, result.err)
require.Same(t, serverMsgConn, result.conn)
}
func TestGetWorkConnFromPoolStartWorkConnUnchangedForUDPWireV2(t *testing.T) {
startMsg := getStartWorkConnFromPool(t, &v1.UDPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "udp", Type: string(v1.ProxyTypeUDP)},
}, wire.ProtocolV2)
require.Equal(t, msg.StartWorkConn{ProxyName: "udp"}, startMsg)
}
func TestGetWorkConnFromPoolLeavesRawTCPPayloadUnframed(t *testing.T) {
startMsg := getStartWorkConnFromPool(t, &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "tcp", Type: string(v1.ProxyTypeTCP)},
}, wire.ProtocolV2)
require.Equal(t, msg.StartWorkConn{ProxyName: "tcp"}, startMsg)
}
func getStartWorkConnFromPool(t *testing.T, cfg v1.ProxyConfigurer, wireProtocol string) msg.StartWorkConn {
t.Helper()
client, server := net.Pipe()
t.Cleanup(func() {
client.Close()
server.Close()
})
serverMsgConn := msg.NewConn(server, msg.NewV2ReadWriter(server))
clientMsgConn := msg.NewConn(client, msg.NewV2ReadWriter(client))
pxy := &BaseProxy{
name: cfg.GetBaseConfig().Name,
configurer: cfg,
poolCount: 0,
ctx: context.Background(),
wireProtocol: wireProtocol,
getWorkConnFn: func() (*WorkConn, error) {
return NewWorkConn(serverMsgConn), nil
},
}
errCh := make(chan error, 1)
go func() {
conn, err := pxy.GetWorkConnFromPool(nil, nil)
if conn != nil {
conn.Close()
}
errCh <- err
}()
var startMsg msg.StartWorkConn
require.NoError(t, clientMsgConn.ReadMsgInto(&startMsg))
require.NoError(t, <-errCh)
return startMsg
}
+141
View File
@@ -0,0 +1,141 @@
// 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 proxy
import (
"bufio"
"bytes"
"encoding/binary"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.UDPPacket{Content: []byte("proxy-to-visitor")})
var count int64
err := bridgeSUDPProxyToVisitor(
msg.NewReadWriter(&in, wire.ProtocolV1),
msg.NewReadWriter(&out, wire.ProtocolV2),
&count,
nil,
)
require.NoError(t, err)
require.Equal(t, int64(len("proxy-to-visitor")), count)
frame, err := wire.NewConn(&out).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.GreaterOrEqual(t, len(frame.Payload), 2)
require.Equal(t, msg.V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
var got msg.UDPPacket
require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &got))
require.Equal(t, []byte("proxy-to-visitor"), got.Content)
}
func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.UDPPacket{Content: []byte("visitor-to-proxy")})
var count int64
err := bridgeSUDPVisitorToProxy(
msg.NewReadWriter(&in, wire.ProtocolV2),
msg.NewReadWriter(&out, wire.ProtocolV1),
&count,
nil,
)
require.NoError(t, err)
require.Equal(t, int64(len("visitor-to-proxy")), count)
reader := bufio.NewReader(&out)
typeByte, err := reader.ReadByte()
require.NoError(t, err)
require.Equal(t, msg.TypeUDPPacket, typeByte)
require.NoError(t, reader.UnreadByte())
var got msg.UDPPacket
require.NoError(t, msg.ReadMsgInto(reader, &got))
require.Equal(t, []byte("visitor-to-proxy"), got.Content)
}
func TestSUDPBridgeForwardsProxyPing(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.Ping{})
var count int64
err := bridgeSUDPProxyToVisitor(
msg.NewReadWriter(&in, wire.ProtocolV1),
msg.NewReadWriter(&out, wire.ProtocolV2),
&count,
nil,
)
require.NoError(t, err)
require.Zero(t, count)
rawMsg, err := msg.NewReadWriter(&out, wire.ProtocolV2).ReadMsg()
require.NoError(t, err)
require.IsType(t, &msg.Ping{}, rawMsg)
}
func TestSUDPBridgeDropsVisitorPing(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Ping{})
var count int64
err := bridgeSUDPVisitorToProxy(
msg.NewReadWriter(&in, wire.ProtocolV2),
msg.NewReadWriter(&out, wire.ProtocolV1),
&count,
nil,
)
require.NoError(t, err)
require.Zero(t, count)
require.Empty(t, out.Bytes())
}
func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Pong{})
var count int64
err := bridgeSUDPVisitorToProxy(
msg.NewReadWriter(&in, wire.ProtocolV2),
msg.NewReadWriter(&out, wire.ProtocolV1),
&count,
nil,
)
require.ErrorContains(t, err, "unexpected SUDP visitor message *msg.Pong")
require.Zero(t, count)
require.Empty(t, out.Bytes())
}
func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) {
require.False(t, isMixedWireProtocol("", wire.ProtocolV1))
require.False(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV2))
require.True(t, isMixedWireProtocol("", wire.ProtocolV2))
require.True(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV1))
}
func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol string, m msg.Message) {
t.Helper()
require.NoError(t, msg.NewReadWriter(buf, wireProtocol).WriteMsg(m))
}
+13 -11
View File
@@ -108,7 +108,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
pxy.checkCloseCh = make(chan int)
// read message from workConn, if it returns any error, notify proxy to start a new workConn
workConnReaderFn := func(conn net.Conn) {
workConnReaderFn := func(payloadConn *msg.Conn) {
for {
var (
rawMsg msg.Message
@@ -116,10 +116,10 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
)
xl.Tracef("loop waiting message from udp workConn")
// client will send heartbeat in workConn for keeping alive
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
_ = payloadConn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
xl.Warnf("read from workConn for udp error: %v", errRet)
_ = conn.Close()
_ = payloadConn.Close()
// notify proxy to start a new work connection
// ignore error here, it means the proxy is closed
_ = errors.PanicToError(func() {
@@ -127,7 +127,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
})
return
}
if err := conn.SetReadDeadline(time.Time{}); err != nil {
if err := payloadConn.SetReadDeadline(time.Time{}); err != nil {
xl.Warnf("set read deadline error: %v", err)
}
switch m := rawMsg.(type) {
@@ -144,7 +144,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
int64(len(m.Content)),
)
}); errRet != nil {
conn.Close()
_ = payloadConn.Close()
xl.Infof("reader goroutine for udp work connection closed")
return
}
@@ -153,7 +153,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
}
// send message to workConn
workConnSenderFn := func(conn net.Conn, ctx context.Context) {
workConnSenderFn := func(payloadConn *msg.Conn, ctx context.Context) {
var errRet error
for {
select {
@@ -162,9 +162,9 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
xl.Infof("sender goroutine for udp work connection closed")
return
}
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
xl.Infof("sender goroutine for udp work connection closed: %v", errRet)
conn.Close()
_ = payloadConn.Close()
return
}
xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
@@ -223,9 +223,11 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
}
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
// Plain UDP payload follows the negotiated wire protocol for message framing.
payloadConn := msg.NewConn(pxy.workConn, msg.NewReadWriter(pxy.workConn, pxy.wireProtocol))
ctx, cancel := context.WithCancel(context.Background())
go workConnReaderFn(pxy.workConn)
go workConnSenderFn(pxy.workConn, ctx)
go workConnReaderFn(payloadConn)
go workConnSenderFn(payloadConn, ctx)
_, ok := <-pxy.checkCloseCh
cancel()
if !ok {
+9 -4
View File
@@ -16,6 +16,7 @@ package proxy
import (
"fmt"
"net"
"reflect"
"sync"
@@ -73,10 +74,7 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
if errRet != nil {
continue
}
m := &msg.NatHoleSid{
Sid: sid,
}
errRet = msg.WriteMsg(workConn, m)
errRet = writeNatHoleSid(workConn, pxy.wireProtocol, sid)
if errRet != nil {
xl.Warnf("write nat hole sid package error, %v", errRet)
}
@@ -87,6 +85,13 @@ func (pxy *XTCPProxy) Run() (remoteAddr string, err error) {
return
}
func writeNatHoleSid(workConn net.Conn, wireProtocol string, sid string) error {
workMsgConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, wireProtocol))
return workMsgConn.WriteMsg(&msg.NatHoleSid{
Sid: sid,
})
}
func (pxy *XTCPProxy) Close() {
pxy.closeOnce.Do(func() {
pxy.BaseProxy.Close()
+93
View File
@@ -0,0 +1,93 @@
// 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 proxy
import (
"bufio"
"encoding/binary"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestWriteNatHoleSidUsesWireV2MessageFrame(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setPipeDeadline(t, client, server)
errCh := make(chan error, 1)
go func() {
errCh <- writeNatHoleSid(server, wire.ProtocolV2, "sid-v2")
}()
frame, err := wire.NewConn(client).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.GreaterOrEqual(t, len(frame.Payload), 2)
require.Equal(t, msg.V2TypeNatHoleSid, binary.BigEndian.Uint16(frame.Payload[:2]))
var out msg.NatHoleSid
require.NoError(t, msg.DecodeV2MessageFrameInto(frame, &out))
require.Equal(t, "sid-v2", out.Sid)
require.NoError(t, <-errCh)
}
func TestWriteNatHoleSidUsesLegacyCodecForWireV1AndDefault(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
}{
{name: "default", wireProtocol: ""},
{name: "v1", wireProtocol: wire.ProtocolV1},
} {
t.Run(tc.name, func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setPipeDeadline(t, client, server)
errCh := make(chan error, 1)
go func() {
errCh <- writeNatHoleSid(server, tc.wireProtocol, "sid-legacy")
}()
reader := bufio.NewReader(client)
typeByte, err := reader.ReadByte()
require.NoError(t, err)
require.Equal(t, msg.TypeNatHoleSid, typeByte)
require.NoError(t, reader.UnreadByte())
var out msg.NatHoleSid
require.NoError(t, msg.ReadMsgInto(reader, &out))
require.Equal(t, "sid-legacy", out.Sid)
require.NoError(t, <-errCh)
})
}
}
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
t.Helper()
deadline := time.Now().Add(time.Second)
for _, conn := range conns {
require.NoError(t, conn.SetDeadline(deadline))
}
}
+13 -2
View File
@@ -18,6 +18,8 @@ import (
"fmt"
"sync"
"time"
"k8s.io/utils/clock"
)
// ClientInfo captures metadata about a connected frpc instance.
@@ -42,12 +44,21 @@ type ClientRegistry struct {
mu sync.RWMutex
clients map[string]*ClientInfo
runIndex map[string]string
clock clock.PassiveClock
}
func NewClientRegistry() *ClientRegistry {
return newClientRegistryWithClock(clock.RealClock{})
}
func newClientRegistryWithClock(clk clock.PassiveClock) *ClientRegistry {
if clk == nil {
clk = clock.RealClock{}
}
return &ClientRegistry{
clients: make(map[string]*ClientInfo),
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)
enforceUnique := rawClientID != ""
now := time.Now()
now := cr.clock.Now()
cr.mu.Lock()
defer cr.mu.Unlock()
@@ -116,7 +127,7 @@ func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
} else {
info.RunID = ""
info.Online = false
now := time.Now()
now := cr.clock.Now()
info.DisconnectedAt = now
}
}
+37
View File
@@ -16,6 +16,9 @@ package registry
import (
"testing"
"time"
clocktesting "k8s.io/utils/clock/testing"
"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)
}
}
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)
}
}
+4 -3
View File
@@ -508,7 +508,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
conn.Close()
}
case *msg.NewVisitorConn:
if err = svr.RegisterVisitorConn(conn, m); err != nil {
if err = svr.RegisterVisitorConn(conn, m, acceptedConn.wireProtocol); err != nil {
xl.Warnf("register visitor conn error: %v", err)
_ = acceptedConn.conn.WriteMsg(&msg.NewVisitorConnResp{
ProxyName: m.ProxyName,
@@ -777,6 +777,7 @@ func (svr *Service) RegisterControl(
LoginMsg: loginMsg,
ServerCfg: svr.cfg,
ClientRegistry: svr.clientRegistry,
WireProtocol: wireProtocol,
})
if err != nil {
xl.Warnf("create new controller error: %v", err)
@@ -832,7 +833,7 @@ func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn
return ctl.RegisterWorkConn(proxy.NewWorkConn(workConn))
}
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn) error {
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
visitorUser := ""
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
// If runID is required, it is not compatible with versions prior to v0.50.0.
@@ -844,5 +845,5 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
visitorUser = ctl.sessionCtx.LoginMsg.User
}
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
}
+15 -1
View File
@@ -65,6 +65,7 @@ func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.
func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,
useEncryption bool, useCompression bool, visitorUser string,
wireProtocol string,
) (err error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
@@ -90,7 +91,11 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
if useCompression {
rwc = libio.WithCompression(rwc)
}
err = l.l.PutConn(netpkg.WrapReadWriteCloserToConn(rwc, conn))
visitorConn := netpkg.WrapReadWriteCloserToConn(rwc, conn)
err = l.l.PutConn(&wireProtocolConn{
Conn: visitorConn,
wireProtocol: wireProtocol,
})
} else {
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
return
@@ -98,6 +103,15 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
return
}
type wireProtocolConn struct {
net.Conn
wireProtocol string
}
func (c *wireProtocolConn) WireProtocol() string {
return c.wireProtocol
}
func (vm *Manager) CloseListener(name string) {
vm.mu.Lock()
defer vm.mu.Unlock()
+61
View File
@@ -0,0 +1,61 @@
// 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 visitor
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/util/util"
)
func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
vm := NewManager()
listener, err := vm.Listen("sudp", "secret", []string{"*"})
require.NoError(t, err)
defer listener.Close()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
now := time.Now().Unix()
errCh := make(chan error, 1)
go func() {
errCh <- vm.NewConn(
"sudp",
server,
now,
util.GetAuthKey("secret", now),
false,
false,
"user",
wire.ProtocolV2,
)
}()
acceptedConn, err := listener.Accept()
require.NoError(t, err)
defer acceptedConn.Close()
getter, ok := acceptedConn.(interface{ WireProtocol() string })
require.True(t, ok)
require.Equal(t, wire.ProtocolV2, getter.WireProtocol())
require.NoError(t, <-errCh)
}
+51 -1
View File
@@ -7,6 +7,8 @@ import (
"io"
"net/http"
"os"
"strconv"
"strings"
"testing"
"time"
@@ -158,7 +160,7 @@ webServer.port = %d
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
ginkgo.It("baseline frps rejects current frpc forced to v2", func() {
ginkgo.It("baseline frps handles current frpc forced to v2 according to baseline support", func() {
portName := port.GenName("CompatBaselineFRPSForcedV2")
clientConf := tcpClientConfig("tcp", portName, `
transport.wireProtocol = "v2"
@@ -170,6 +172,21 @@ transport.wireProtocol = "v2"
consts.DefaultServerConfig,
[]string{clientConf},
)
// frp v0.69.0 added control connection wireProtocol v2 support, so
// baseline frps v0.69.0 and newer should accept a current frpc forced
// to v2. Older known baselines still must reject the unsupported protocol.
// For custom baselines, the version is unknown and the binary may be either
// side of the support boundary, so this versioned expectation is skipped.
supportsV2, knownVersion := baselineSupportsControlWireProtocolV2(compatCtx.BaselineVersion)
if !knownVersion {
ginkgo.Skip(fmt.Sprintf("baseline version %q is not semver; skip versioned forced-v2 expectation", compatCtx.BaselineVersion))
}
if supportsV2 {
framework.NewRequestExpect(f).PortName(portName).Ensure()
return
}
expectProcessExit(clientProcesses[0], 5*time.Second)
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
})
@@ -199,6 +216,39 @@ func expectProcessExit(p *process.Process, timeout time.Duration) {
}
}
func baselineSupportsControlWireProtocolV2(version string) (supports bool, known bool) {
version = strings.TrimPrefix(version, "v")
parts := strings.Split(version, ".")
if len(parts) != 3 {
return false, false
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return false, false
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return false, false
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return false, false
}
return compareSemanticVersion(major, minor, patch, 0, 69, 0) >= 0, true
}
func compareSemanticVersion(major, minor, patch int, baseMajor, baseMinor, basePatch int) int {
if major != baseMajor {
return major - baseMajor
}
if minor != baseMinor {
return minor - baseMinor
}
return patch - basePatch
}
type wireClientInfo struct {
ClientID string `json:"clientID"`
WireProtocol string `json:"wireProtocol"`
-62
View File
@@ -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()
}
}
-20
View File
@@ -23,11 +23,6 @@ func ExpectNotEqual(actual any, extra any, explain ...any) {
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) {
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...)
}
// 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) {
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...)
}
// 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) {
gomega.ExpectWithOffset(1, actual).Should(gomega.BeTrue(), explain...)
}
-13
View File
@@ -38,11 +38,6 @@ type Framework struct {
// Multiple default mock servers used for e2e testing.
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 bool
@@ -87,8 +82,6 @@ func NewFramework(opt Options) *Framework {
func (f *Framework) BeforeEach() {
f.beforeEachStarted = true
f.cleanupHandle = AddCleanupAction(f.AfterEach)
dir, err := os.MkdirTemp(os.TempDir(), "frp-e2e-test-*")
ExpectNoError(err)
f.TempDirectory = dir
@@ -113,8 +106,6 @@ func (f *Framework) AfterEach() {
return
}
RemoveCleanupAction(f.cleanupHandle)
// stop processor
for _, p := range f.serverProcesses {
_ = p.Stop()
@@ -266,10 +257,6 @@ func (f *Framework) AllocPortExcludingRanges(ranges ...[2]int) int {
return 0
}
func (f *Framework) ReleasePort(port int) {
f.portAllocator.Release(port)
}
func (f *Framework) RunServer(portName string, s server.Server) {
f.servers = append(f.servers, s)
if s.BindPort() > 0 && portName != "" {
-8
View File
@@ -75,11 +75,3 @@ func (m *MockServers) GetTemplateParams() map[string]any {
ret[HTTPSimpleServerPort] = m.httpSimpleServer.BindPort()
return ret
}
func (m *MockServers) GetParam(key string) any {
params := m.GetTemplateParams()
if v, ok := params[key]; ok {
return v
}
return nil
}
-4
View File
@@ -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
}
func WithBindAddr(addr string) Option {
return func(s *Server) *Server {
s.bindAddr = addr
return s
}
}
func WithBindPort(port int) Option {
return func(s *Server) *Server {
s.bindPort = port
@@ -61,21 +61,6 @@ func WithBindPort(port int) Option {
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 {
return func(s *Server) { s.expiresIn = seconds }
}
-12
View File
@@ -41,14 +41,9 @@ type Process struct {
waitErr error
started bool
beforeStopHandler func()
stopped bool
}
func New(path string, params []string) *Process {
return NewWithEnvs(path, params, nil)
}
func NewWithEnvs(path string, params []string, envs []string) *Process {
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, path, params...)
@@ -100,9 +95,6 @@ func (p *Process) Stop() error {
defer func() {
p.stopped = true
}()
if p.beforeStopHandler != nil {
p.beforeStopHandler()
}
p.cancel()
<-p.done
return p.waitErr
@@ -125,10 +117,6 @@ func (p *Process) CountOutput(pattern string) int {
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
// 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 {
+1 -8
View File
@@ -26,7 +26,6 @@ type Request struct {
port int
body []byte
timeout time.Duration
resolver *net.Resolver
// for http or https
method string
@@ -134,11 +133,6 @@ func (r *Request) Body(content []byte) *Request {
return r
}
func (r *Request) Resolver(resolver *net.Resolver) *Request {
r.resolver = resolver
return r
}
func (r *Request) Do() (*Response, error) {
var (
conn net.Conn
@@ -169,7 +163,7 @@ func (r *Request) Do() (*Response, error) {
return nil, err
}
} else {
dialer := &net.Dialer{Resolver: r.resolver}
dialer := &net.Dialer{}
switch r.protocol {
case "tcp":
conn, err = dialer.Dial("tcp", addr)
@@ -225,7 +219,6 @@ func (r *Request) sendHTTPRequest(method, urlstr string, host string, headers ma
Timeout: time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
Resolver: r.resolver,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
+67
View File
@@ -98,6 +98,73 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
framework.NewRequestExpect(f).PortName(bindPortName).Ensure()
})
for _, tc := range []struct {
name string
proxyWireConfig string
visitorWireConfig string
extraProxyConfig string
extraVisitorConfig string
}{
{
name: "default sudp visitor",
},
{
name: "v2 sudp visitor",
proxyWireConfig: `transport.wireProtocol = "v2"`,
visitorWireConfig: `transport.wireProtocol = "v2"`,
},
{
name: "mixed sudp proxy v1 visitor v2",
proxyWireConfig: `transport.wireProtocol = "v1"`,
visitorWireConfig: `transport.wireProtocol = "v2"`,
extraProxyConfig: `
transport.useEncryption = true
transport.useCompression = true
`,
extraVisitorConfig: `
transport.useEncryption = true
transport.useCompression = true
`,
},
{
name: "mixed sudp proxy v2 visitor v1",
proxyWireConfig: `transport.wireProtocol = "v2"`,
visitorWireConfig: `transport.wireProtocol = "v1"`,
},
} {
ginkgo.It(tc.name, func() {
serverConf := consts.DefaultServerConfig
bindPortName := port.GenName("WireSUDP")
clientServerConf := consts.DefaultClientConfig + fmt.Sprintf(`
user = "user1"
%s
[[proxies]]
name = "sudp"
type = "sudp"
secretKey = "abc"
localPort = {{ .%s }}
%s
`, tc.proxyWireConfig, framework.UDPEchoServerPort, tc.extraProxyConfig)
clientVisitorConf := consts.DefaultClientConfig + fmt.Sprintf(`
user = "user1"
%s
[[visitors]]
name = "sudp-visitor"
type = "sudp"
serverName = "sudp"
secretKey = "abc"
bindPort = {{ .%s }}
%s
`, tc.visitorWireConfig, bindPortName, tc.extraVisitorConfig)
f.RunProcesses(serverConf, []string{clientServerConf, clientVisitorConf})
framework.NewRequestExpect(f).Protocol("udp").PortName(bindPortName).Ensure()
})
}
ginkgo.It("reports client wire protocol", func() {
webPort := f.AllocPort()
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+87
View File
@@ -1,18 +1,24 @@
package plugin
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"github.com/onsi/ginkgo/v2"
pp "github.com/pires/go-proxyproto"
"github.com/fatedier/frp/pkg/transport"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
"github.com/fatedier/frp/test/e2e/mock/server/streamserver"
"github.com/fatedier/frp/test/e2e/pkg/cert"
"github.com/fatedier/frp/test/e2e/pkg/port"
"github.com/fatedier/frp/test/e2e/pkg/request"
@@ -450,4 +456,85 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
ExpectResp([]byte("test")).
Ensure()
})
ginkgo.It("tls2raw with proxy protocol v2", func() {
generator := &cert.SelfSignedCertGenerator{}
artifacts, err := generator.Generate("example.com")
framework.ExpectNoError(err)
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
serverConf := consts.DefaultServerConfig
vhostHTTPSPort := f.AllocPort()
serverConf += fmt.Sprintf(`
vhostHTTPSPort = %d
`, vhostHTTPSPort)
localPort := f.AllocPort()
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
[[proxies]]
name = "tls2raw-proxy-protocol-test"
type = "https"
customDomains = ["example.com"]
transport.proxyProtocolVersion = "v2"
[proxies.plugin]
type = "tls2raw"
localAddr = "127.0.0.1:%d"
crtPath = "%s"
keyPath = "%s"
`, localPort, crtPath, keyPath)
f.RunProcesses(serverConf, []string{clientConf})
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
streamserver.WithCustomHandler(func(c net.Conn) {
defer c.Close()
writeResp := func(body string) {
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
}
rd := bufio.NewReader(c)
ppHeader, err := pp.Read(rd)
if err != nil {
log.Errorf("read proxy protocol error: %v", err)
writeResp("missing proxy protocol")
return
}
if ppHeader.Version != 2 {
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
writeResp("unexpected proxy protocol version")
return
}
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
if !ok || srcAddr.IP.String() != "127.0.0.1" {
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
writeResp("unexpected proxy protocol source address")
return
}
req, err := http.ReadRequest(rd)
if err != nil {
log.Errorf("read http request after proxy protocol error: %v", err)
writeResp("missing http request")
return
}
_, _ = io.Copy(io.Discard, req.Body)
_ = req.Body.Close()
writeResp("test")
}))
f.RunServer("", localServer)
framework.NewRequestExpect(f).
Port(vhostHTTPSPort).
RequestModify(func(r *request.Request) {
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
ServerName: "example.com",
InsecureSkipVerify: true,
})
}).
ExpectResp([]byte("test")).
Ensure()
})
})
-1
View File
@@ -15,7 +15,6 @@ declare module 'vue' {
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRow: typeof import('element-plus/es')['ElRow']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag']
+22 -2
View File
@@ -1,10 +1,30 @@
import { http } from './http'
import type { ClientInfoData } from '../types/client'
import { buildQueryString, http } from './http'
import type { V2Page } from './http'
import type { ClientInfoData, ClientListV2Params } from '../types/client'
export const getClients = () => {
return http.get<ClientInfoData[]>('../api/clients')
}
export const getClientsV2 = (params: ClientListV2Params = {}) => {
return http.getV2<V2Page<ClientInfoData>>(
`../api/v2/clients${buildQueryString({
page: params.page,
pageSize: params.pageSize,
status:
params.status && params.status !== 'all' ? params.status : undefined,
q: params.q || undefined,
user: params.user,
clientID: params.clientID || undefined,
runID: params.runID || undefined,
})}`,
)
}
export const getClient = (key: string) => {
return http.get<ClientInfoData>(`../api/clients/${key}`)
}
export const getClientV2 = (key: string) => {
return http.getV2<ClientInfoData>(`../api/v2/clients/${encodeURIComponent(key)}`)
}
+68
View File
@@ -11,6 +11,21 @@ class HTTPError extends Error {
}
}
export interface V2Envelope<T> {
code: number
msg: string
data: T
}
export interface V2Page<T> {
total: number
page: number
pageSize: number
items: T[]
}
type QueryParamValue = string | number | boolean | null | undefined
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
@@ -34,9 +49,62 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
return response.json()
}
async function requestV2<T>(
url: string,
options: RequestInit = {},
): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
}
const response = await fetch(url, { ...defaultOptions, ...options })
const envelope = (await response.json().catch(() => null)) as
| V2Envelope<T>
| null
if (!response.ok) {
throw new HTTPError(
response.status,
response.statusText,
envelope?.msg || `HTTP ${response.status}`,
)
}
if (!envelope || typeof envelope.code !== 'number') {
throw new Error('Invalid API v2 response')
}
if (envelope.code >= 400) {
throw new HTTPError(envelope.code, envelope.msg, envelope.msg)
}
return envelope.data
}
export const buildQueryString = (
params: Record<string, QueryParamValue>,
): string => {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) continue
query.append(key, String(value))
}
const text = query.toString()
return text ? `?${text}` : ''
}
export const http = {
get: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'GET' }),
getV2: <T>(url: string, options?: RequestInit) =>
requestV2<T>(url, { ...options, method: 'GET' }),
postV2: <T>(url: string, body?: any, options?: RequestInit) =>
requestV2<T>(url, {
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(body),
}),
post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, {
...options,
+95 -3
View File
@@ -1,26 +1,118 @@
import { http } from './http'
import { buildQueryString, http } from './http'
import { formatUnixSeconds } from '../utils/format'
import type { V2Page } from './http'
import type {
GetProxyResponse,
ProxyListV2Params,
ProxyStatsInfo,
ProxyV2Info,
ProxyV2Spec,
ProxyV2SpecBlocks,
ProxyV2Type,
TrafficResponse,
} from '../types/proxy'
export interface SystemPruneResponse {
type: 'offline_proxies'
cleared: number
total: number
}
export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
}
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
const page = await http.getV2<V2Page<ProxyV2Info>>(
`../api/v2/proxies${buildQueryString({
page: params.page,
pageSize: params.pageSize,
status:
params.status && params.status !== 'all' ? params.status : undefined,
q: params.q || undefined,
type: params.type || undefined,
user: params.user,
clientID: params.clientID || undefined,
})}`,
)
return {
...page,
items: page.items.map(toLegacyProxyStats),
}
}
const getActiveProxySpec = (
spec: ProxyV2Spec,
): ProxyV2SpecBlocks[ProxyV2Type] => {
switch (spec.type) {
case 'tcp':
return spec.tcp
case 'udp':
return spec.udp
case 'http':
return spec.http
case 'https':
return spec.https
case 'tcpmux':
return spec.tcpmux
case 'stcp':
return spec.stcp
case 'sudp':
return spec.sudp
case 'xtcp':
return spec.xtcp
default:
return assertNever(spec)
}
}
const assertNever = (value: never): never => {
throw new Error(`Unsupported proxy spec: ${JSON.stringify(value)}`)
}
export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => {
const type = proxy.spec.type
const activeSpec = getActiveProxySpec(proxy.spec)
return {
name: proxy.name,
type,
conf: proxy.status.phase === 'offline' ? null : activeSpec,
user: proxy.user,
clientID: proxy.clientID,
todayTrafficIn: proxy.status.todayTrafficIn,
todayTrafficOut: proxy.status.todayTrafficOut,
curConns: proxy.status.curConns,
lastStartTime: formatUnixSeconds(proxy.status.lastStartAt),
lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt),
status: proxy.status.phase,
}
}
export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
}
export const getProxyByNameV2 = async (name: string) => {
const proxy = await http.getV2<ProxyV2Info>(
`../api/v2/proxies/${encodeURIComponent(name)}`,
)
return toLegacyProxyStats(proxy)
}
export const getProxyByName = (name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
}
export const getProxyTraffic = (name: string) => {
return http.get<TrafficResponse>(`../api/traffic/${name}`)
return http.getV2<TrafficResponse>(
`../api/v2/proxies/${encodeURIComponent(name)}/traffic`,
)
}
export const clearOfflineProxies = () => {
return http.delete('../api/proxies?status=offline')
return http.postV2<SystemPruneResponse>(
'../api/v2/system/prune?type=offline_proxies',
)
}
+1 -1
View File
@@ -2,5 +2,5 @@ import { http } from './http'
import type { ServerInfo } from '../types/server'
export const getServerInfo = () => {
return http.get<ServerInfo>('../api/serverinfo')
return http.getV2<ServerInfo>('../api/v2/system/info')
}
+16 -32
View File
@@ -54,6 +54,7 @@ import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { formatFileSize } from '../utils/format'
import { getProxyTraffic } from '../api/proxy'
import type { TrafficResponse } from '../types/proxy'
const props = defineProps<{
proxyName: string
@@ -71,41 +72,24 @@ const chartData = ref<
>([])
const maxVal = ref(0)
const processData = (trafficIn: number[], trafficOut: number[]) => {
// Ensure we have arrays and reverse them (server returns newest first)
const inArr = [...(trafficIn || [])].reverse()
const outArr = [...(trafficOut || [])].reverse()
// Pad with zeros if less than 7 days
while (inArr.length < 7) inArr.unshift(0)
while (outArr.length < 7) outArr.unshift(0)
// Slice to last 7 entries just in case
const finalIn = inArr.slice(-7)
const finalOut = outArr.slice(-7)
// Calculate dates (last 7 days ending today)
const dates: string[] = []
const d = new Date()
d.setDate(d.getDate() - 6)
for (let i = 0; i < 7; i++) {
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
d.setDate(d.getDate() + 1)
const formatDateLabel = (date: string) => {
const parts = date.split('-')
if (parts.length !== 3) return date
return `${Number(parts[1])}-${Number(parts[2])}`
}
// Find max value for scaling
const maxIn = Math.max(...finalIn)
const maxOut = Math.max(...finalOut)
const processData = (history: TrafficResponse['history'] = []) => {
const points = history || []
const maxIn = Math.max(0, ...points.map((item) => item.trafficIn))
const maxOut = Math.max(0, ...points.map((item) => item.trafficOut))
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
// Build chart data
chartData.value = dates.map((date, i) => ({
date,
in: finalIn[i],
out: finalOut[i],
inPercent: (finalIn[i] / maxVal.value) * 100,
outPercent: (finalOut[i] / maxVal.value) * 100,
chartData.value = points.map((item) => ({
date: formatDateLabel(item.date),
in: item.trafficIn,
out: item.trafficOut,
inPercent: (item.trafficIn / maxVal.value) * 100,
outPercent: (item.trafficOut / maxVal.value) * 100,
}))
}
@@ -113,7 +97,7 @@ const fetchData = () => {
loading.value = true
getProxyTraffic(props.proxyName)
.then((json) => {
processData(json.trafficIn, json.trafficOut)
processData(json.history)
})
.catch((err) => {
ElMessage({
+17 -1
View File
@@ -7,9 +7,25 @@ export interface ClientInfoData {
wireProtocol?: string
hostname: string
clientIP?: string
metas?: Record<string, string>
firstConnectedAt: number
lastConnectedAt: number
disconnectedAt?: number
online: boolean
status?: ClientStatus
}
export interface ClientStatus {
phase: 'online' | 'offline'
curConns: number
proxyCount: number
}
export interface ClientListV2Params {
page?: number
pageSize?: number
status?: 'all' | 'online' | 'offline'
q?: string
user?: string
clientID?: string
runID?: string
}
+109 -2
View File
@@ -1,5 +1,6 @@
export interface ProxyStatsInfo {
name: string
type?: string
conf: any
user: string
clientID: string
@@ -15,8 +16,114 @@ export interface GetProxyResponse {
proxies: ProxyStatsInfo[]
}
export interface ProxyListV2Params {
page?: number
pageSize?: number
status?: 'all' | 'online' | 'offline'
q?: string
type?: string
user?: string
clientID?: string
}
export interface ProxyV2Info {
name: string
user: string
clientID: string
spec: ProxyV2Spec
status: ProxyV2Status
}
export interface ProxyV2BaseSpec {
annotations?: Record<string, string>
metadatas?: Record<string, string>
transport?: {
useEncryption: boolean
useCompression: boolean
bandwidthLimit: string
bandwidthLimitMode: string
}
loadBalancer?: {
group: string
}
}
export interface ProxyV2TCPBlock extends ProxyV2BaseSpec {
remotePort?: number
}
export interface ProxyV2UDPBlock extends ProxyV2BaseSpec {
remotePort?: number
}
export interface ProxyV2HTTPBlock extends ProxyV2BaseSpec {
customDomains?: string[]
subdomain?: string
locations?: string[]
hostHeaderRewrite?: string
}
export interface ProxyV2HTTPSBlock extends ProxyV2BaseSpec {
customDomains?: string[]
subdomain?: string
}
export interface ProxyV2TCPMuxBlock extends ProxyV2BaseSpec {
customDomains?: string[]
subdomain?: string
multiplexer?: string
routeByHTTPUser?: string
}
export type ProxyV2STCPBlock = ProxyV2BaseSpec
export type ProxyV2SUDPBlock = ProxyV2BaseSpec
export type ProxyV2XTCPBlock = ProxyV2BaseSpec
export interface ProxyV2SpecBlocks {
tcp: ProxyV2TCPBlock
udp: ProxyV2UDPBlock
http: ProxyV2HTTPBlock
https: ProxyV2HTTPSBlock
tcpmux: ProxyV2TCPMuxBlock
stcp: ProxyV2STCPBlock
sudp: ProxyV2SUDPBlock
xtcp: ProxyV2XTCPBlock
}
export type ProxyV2Type = keyof ProxyV2SpecBlocks
type ProxyV2SpecFor<T extends ProxyV2Type> = {
type: T
} & {
[K in T]: ProxyV2SpecBlocks[K]
} & {
[K in Exclude<ProxyV2Type, T>]?: never
}
export type ProxyV2Spec = {
[T in ProxyV2Type]: ProxyV2SpecFor<T>
}[ProxyV2Type]
export interface ProxyV2Status {
phase: 'online' | 'offline'
todayTrafficIn: number
todayTrafficOut: number
curConns: number
lastStartAt?: number
lastCloseAt?: number
}
export interface TrafficResponse {
name: string
trafficIn: number[]
trafficOut: number[]
unit: 'bytes'
granularity: 'day'
history: TrafficPoint[]
}
export interface TrafficPoint {
date: string
trafficIn: number
trafficOut: number
}
+7 -1
View File
@@ -1,5 +1,10 @@
export interface ServerInfo {
version: string
config: ServerInfoConfig
status: ServerInfoStatus
}
export interface ServerInfoConfig {
bindPort: number
vhostHTTPPort: number
vhostHTTPSPort: number
@@ -12,8 +17,9 @@ export interface ServerInfo {
heartbeatTimeout: number
allowPortsStr: string
tlsForce: boolean
}
// Stats
export interface ServerInfoStatus {
totalTrafficIn: number
totalTrafficOut: number
curConns: number
+7 -36
View File
@@ -1,5 +1,5 @@
import { formatDistanceToNow } from './format'
import type { ClientInfoData } from '../types/client'
import type { ClientInfoData, ClientStatus } from '../types/client'
export class Client {
key: string
@@ -10,11 +10,11 @@ export class Client {
wireProtocol: string
hostname: string
ip: string
metas: Map<string, string>
firstConnectedAt: Date
lastConnectedAt: Date
disconnectedAt?: Date
online: boolean
status: ClientStatus
constructor(data: ClientInfoData) {
this.key = data.key
@@ -25,18 +25,17 @@ export class Client {
this.wireProtocol = data.wireProtocol || ''
this.hostname = data.hostname
this.ip = data.clientIP || ''
this.metas = new Map<string, string>()
if (data.metas) {
for (const [key, value] of Object.entries(data.metas)) {
this.metas.set(key, value)
}
}
this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
if (data.disconnectedAt && data.disconnectedAt > 0) {
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
}
this.online = data.online
this.status = data.status || {
phase: this.online ? 'online' : 'offline',
curConns: 0,
proxyCount: 0,
}
}
get displayName(): string {
@@ -46,10 +45,6 @@ export class Client {
return this.runID
}
get shortRunId(): string {
return this.runID.substring(0, 8)
}
get wireProtocolLabel(): string {
if (!this.wireProtocol) return ''
return `Protocol ${this.wireProtocol}`
@@ -67,28 +62,4 @@ export class Client {
if (!this.disconnectedAt) return ''
return formatDistanceToNow(this.disconnectedAt)
}
get statusColor(): string {
return this.online ? 'success' : 'danger'
}
get metasArray(): Array<{ key: string; value: string }> {
const arr: Array<{ key: string; value: string }> = []
this.metas.forEach((value, key) => {
arr.push({ key, value })
})
return arr
}
matchesFilter(searchText: string): boolean {
const search = searchText.toLowerCase()
return (
this.key.toLowerCase().includes(search) ||
this.user.toLowerCase().includes(search) ||
this.clientID.toLowerCase().includes(search) ||
this.runID.toLowerCase().includes(search) ||
this.wireProtocol.toLowerCase().includes(search) ||
this.hostname.toLowerCase().includes(search)
)
}
}
+8
View File
@@ -19,6 +19,14 @@ export function formatDistanceToNow(date: Date): string {
return Math.floor(seconds) + ' seconds ago'
}
export function formatUnixSeconds(seconds?: number): string {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return ''
const date = new Date(seconds * 1000)
const pad = (value: number) => value.toString().padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
if (bytes === 0) return '0 B'
+186 -95
View File
@@ -55,7 +55,7 @@
<div class="info-section">
<div class="info-item">
<span class="info-label">Connections</span>
<span class="info-value">{{ totalConnections }}</span>
<span class="info-value">{{ client.status.curConns }}</span>
</div>
<div class="info-item">
<span class="info-label">Run ID</span>
@@ -85,7 +85,7 @@
<div class="proxies-header">
<div class="proxies-title">
<h2>Proxies</h2>
<span class="proxies-count">{{ filteredProxies.length }}</span>
<span class="proxies-count">{{ total }}</span>
</div>
<el-input
v-model="proxySearch"
@@ -100,21 +100,32 @@
<el-icon class="is-loading"><Loading /></el-icon>
<span>Loading...</span>
</div>
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
<div v-else-if="proxies.length > 0" class="proxies-list">
<ProxyCard
v-for="proxy in filteredProxies"
:key="proxy.name"
v-for="proxy in proxies"
:key="`${proxy.type}:${proxy.name}`"
:proxy="proxy"
show-type
/>
</div>
<div v-else-if="clientProxies.length > 0" class="empty-state">
<div v-else-if="proxySearch.trim()" class="empty-state">
<p>No proxies match "{{ proxySearch }}"</p>
</div>
<div v-else class="empty-state">
<p>No proxies found</p>
</div>
</div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div>
</template>
@@ -130,13 +141,13 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElPagination } from 'element-plus'
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client'
import { getClient } from '../api/client'
import { getProxiesByType } from '../api/proxy'
import { getClientV2 } from '../api/client'
import { getProxiesV2 } from '../api/proxy'
import {
BaseProxy,
TCPProxy,
@@ -149,6 +160,8 @@ import {
} from '../utils/proxy'
import { getServerInfo } from '../api/server'
import ProxyCard from '../components/ProxyCard.vue'
import type { ProxyStatsInfo } from '../types/proxy'
import type { ServerInfo } from '../types/server'
const route = useRoute()
const router = useRouter()
@@ -163,119 +176,191 @@ const goBack = () => {
}
}
const proxiesLoading = ref(false)
const allProxies = ref<BaseProxy[]>([])
const proxies = ref<BaseProxy[]>([])
const proxySearch = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let requestSeq = 0
let searchDebounceTimer: number | null = null
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
let serverInfoPromise: Promise<ServerInfo> | null = null
const clientProxies = computed(() => {
if (!client.value) return []
return allProxies.value.filter(
(p) =>
p.clientID === client.value!.clientID && p.user === client.value!.user,
)
const fetchServerInfo = (): Promise<ServerInfo> => {
if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => {
serverInfoPromise = null
throw err
})
const filteredProxies = computed(() => {
if (!proxySearch.value) return clientProxies.value
const search = proxySearch.value.toLowerCase()
return clientProxies.value.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search),
)
})
const totalConnections = computed(() => {
return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)
})
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
}
return serverInfoPromise
}
const fetchClient = async () => {
const fetchClient = async (): Promise<boolean> => {
const key = route.params.key as string
if (!key) {
loading.value = false
return
return false
}
try {
const data = await getClient(key)
const data = await getClientV2(key)
client.value = new Client(data)
return true
} catch (error: any) {
ElMessage.error('Failed to fetch client: ' + error.message)
return false
} finally {
loading.value = false
}
}
const fetchProxies = async () => {
proxiesLoading.value = true
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
const proxies: BaseProxy[] = []
try {
const info = await fetchServerInfo()
for (const type of proxyTypes) {
try {
const json = await getProxiesByType(type)
if (!json.proxies) continue
const convertProxy = async (
proxy: ProxyStatsInfo,
): Promise<BaseProxy | null> => {
const type = proxy.type || ''
if (type === 'tcp') {
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
} else if (type === 'udp') {
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
} else if (type === 'http' && info?.vhostHTTPPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
),
)
} else if (type === 'https' && info?.vhostHTTPSPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
),
)
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new TCPMuxProxy(
p,
info.tcpmuxHTTPConnectPort,
info.subdomainHost,
),
),
)
} else if (type === 'stcp') {
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
} else if (type === 'sudp') {
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
return new TCPProxy(proxy)
}
} catch {
// Ignore
if (type === 'udp') {
return new UDPProxy(proxy)
}
if (type === 'http') {
const info = await fetchServerInfo()
if (info && info.config.vhostHTTPPort) {
return new HTTPProxy(
proxy,
info.config.vhostHTTPPort,
info.config.subdomainHost,
)
}
allProxies.value = proxies
} catch {
// Ignore
return null
}
if (type === 'https') {
const info = await fetchServerInfo()
if (info && info.config.vhostHTTPSPort) {
return new HTTPSProxy(
proxy,
info.config.vhostHTTPSPort,
info.config.subdomainHost,
)
}
return null
}
if (type === 'tcpmux') {
const info = await fetchServerInfo()
if (info && info.config.tcpmuxHTTPConnectPort) {
return new TCPMuxProxy(
proxy,
info.config.tcpmuxHTTPConnectPort,
info.config.subdomainHost,
)
}
return null
}
if (type === 'stcp') {
return new STCPProxy(proxy)
}
if (type === 'sudp') {
return new SUDPProxy(proxy)
}
const bp = new BaseProxy(proxy)
bp.type = type
return bp
}
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
const converted = await Promise.all(items.map((item) => convertProxy(item)))
return converted.filter((item): item is BaseProxy => item !== null)
}
const fetchProxies = async () => {
if (!client.value) return
const seq = ++requestSeq
proxiesLoading.value = true
try {
const q = proxySearch.value.trim()
const data = await getProxiesV2({
page: page.value,
pageSize: pageSize.value,
q: q || undefined,
clientID: client.value.clientID,
user: client.value.user,
})
if (seq !== requestSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchProxies()
return
}
const converted = await convertProxies(data.items)
if (seq !== requestSeq) return
proxies.value = converted
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) {
if (seq !== requestSeq) return
ElMessage.error('Failed to fetch proxies: ' + error.message)
} finally {
if (seq === requestSeq) {
proxiesLoading.value = false
}
}
}
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const invalidateProxyRequests = () => {
requestSeq++
proxiesLoading.value = false
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchProxies()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchProxies()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
watch(proxySearch, () => {
clearSearchDebounce()
invalidateProxyRequests()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchProxies()
}, 300)
})
onUnmounted(() => {
clearSearchDebounce()
})
onMounted(async () => {
const ok = await fetchClient()
if (!ok || !client.value) return
onMounted(() => {
fetchClient()
fetchProxies()
})
</script>
@@ -483,6 +568,12 @@ html.dark .status-badge.online {
padding: 16px;
}
.pagination-section {
display: flex;
justify-content: center;
padding: 0 20px 20px;
}
.proxies-list {
display: flex;
flex-direction: column;
+113 -47
View File
@@ -16,7 +16,9 @@
>
<span class="status-dot" :class="tab.value"></span>
<span class="tab-label">{{ tab.label }}</span>
<span class="tab-count">{{ tab.count }}</span>
<span v-if="tab.count !== null" class="tab-count">{{
tab.count
}}</span>
</button>
</div>
</div>
@@ -33,9 +35,9 @@
</div>
<div v-loading="loading" class="clients-content">
<div v-if="filteredClients.length > 0" class="clients-list">
<div v-if="clients.length > 0" class="clients-list">
<ClientCard
v-for="client in filteredClients"
v-for="client in clients"
:key="client.key"
:client="client"
/>
@@ -44,82 +46,123 @@
<el-empty description="No clients found" />
</div>
</div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client'
import ClientCard from '../components/ClientCard.vue'
import { getClients } from '../api/client'
import { getClientsV2 } from '../api/client'
const clients = ref<Client[]>([])
const loading = ref(false)
const searchText = ref('')
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let refreshTimer: number | null = null
const stats = computed(() => {
const total = clients.value.length
const online = clients.value.filter((c) => c.online).length
const offline = total - online
return { total, online, offline }
})
let searchDebounceTimer: number | null = null
let requestSeq = 0
const statusTabs = computed(() => [
{ value: 'all' as const, label: 'All', count: stats.value.total },
{ value: 'online' as const, label: 'Online', count: stats.value.online },
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
{
value: 'all' as const,
label: 'All',
count: statusFilter.value === 'all' ? total.value : null,
},
{
value: 'online' as const,
label: 'Online',
count: statusFilter.value === 'online' ? total.value : null,
},
{
value: 'offline' as const,
label: 'Offline',
count: statusFilter.value === 'offline' ? total.value : null,
},
])
const filteredClients = computed(() => {
let result = clients.value
// Filter by status
if (statusFilter.value === 'online') {
result = result.filter((c) => c.online)
} else if (statusFilter.value === 'offline') {
result = result.filter((c) => !c.online)
}
// Filter by search text
if (searchText.value) {
result = result.filter((c) => c.matchesFilter(searchText.value))
}
// Sort: online first, then by display name
result.sort((a, b) => {
if (a.online !== b.online) {
return a.online ? -1 : 1
}
return a.displayName.localeCompare(b.displayName)
})
return result
})
const fetchData = async () => {
loading.value = true
const fetchData = async (silent = false) => {
const seq = ++requestSeq
if (!silent) loading.value = true
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
const data = await getClientsV2({
page: page.value,
pageSize: pageSize.value,
status: statusFilter.value,
q: searchText.value.trim(),
})
if (seq !== requestSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchData(silent)
return
}
clients.value = data.items.map((item) => new Client(item))
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) {
if (seq !== requestSeq) return
ElMessage({
showClose: true,
message: 'Failed to fetch clients: ' + error.message,
type: 'error',
})
} finally {
if (seq === requestSeq) {
loading.value = false
}
}
}
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const startAutoRefresh = () => {
refreshTimer = window.setInterval(() => {
fetchData()
fetchData(true)
}, 5000)
}
@@ -130,6 +173,19 @@ const stopAutoRefresh = () => {
}
}
watch(statusFilter, () => {
resetPageAndFetch()
})
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
onMounted(() => {
fetchData()
startAutoRefresh()
@@ -137,6 +193,7 @@ onMounted(() => {
onUnmounted(() => {
stopAutoRefresh()
clearSearchDebounce()
})
</script>
@@ -274,6 +331,11 @@ onUnmounted(() => {
padding: 60px 0;
}
.pagination-section {
display: flex;
justify-content: flex-end;
}
/* Dark mode adjustments */
html.dark .status-tab {
background: var(--el-bg-color-overlay);
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
.status-tab {
flex-shrink: 0;
}
.pagination-section {
justify-content: center;
}
}
</style>
+186 -188
View File
@@ -8,7 +8,7 @@
</div>
<div class="actions-section">
<ActionButton variant="outline" size="small" @click="fetchData">
<ActionButton variant="outline" size="small" @click="refreshData">
Refresh
</ActionButton>
@@ -27,36 +27,6 @@
clearable
class="main-search"
/>
<PopoverMenu
:model-value="selectedClientKey"
:width="220"
placement="bottom-end"
selectable
filterable
filter-placeholder="Search clients..."
:display-value="selectedClientLabel"
clearable
class="client-filter"
@update:model-value="onClientFilterChange($event as string)"
>
<template #default="{ filterText }">
<PopoverMenuItem value="">All Clients</PopoverMenuItem>
<PopoverMenuItem
v-if="clientIDFilter && !selectedClientInList"
:value="selectedClientKey"
>
{{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)
</PopoverMenuItem>
<PopoverMenuItem
v-for="client in filteredClientOptions(filterText)"
:key="client.key"
:value="client.key"
>
{{ client.label }}
</PopoverMenuItem>
</template>
</PopoverMenu>
</div>
<div class="type-tabs">
@@ -74,11 +44,12 @@
</div>
<div v-loading="loading" class="proxies-content">
<div v-if="filteredProxies.length > 0" class="proxies-list">
<div v-if="proxies.length > 0" class="proxies-list">
<ProxyCard
v-for="proxy in filteredProxies"
:key="proxy.name"
v-for="proxy in proxies"
:key="`${proxy.type}:${proxy.name}`"
:proxy="proxy"
:show-type="activeType === 'all'"
/>
</div>
<div v-else-if="!loading" class="empty-state">
@@ -86,6 +57,18 @@
</div>
</div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
<ConfirmDialog
v-model="showClearDialog"
title="Clear Offline"
@@ -98,9 +81,9 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
@@ -115,190 +98,185 @@ import {
SUDPProxy,
} from '../utils/proxy'
import ProxyCard from '../components/ProxyCard.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import {
getProxiesByType,
getProxiesV2,
clearOfflineProxies as apiClearOfflineProxies,
} from '../api/proxy'
import { getServerInfo } from '../api/server'
import { getClients } from '../api/client'
import { Client } from '../utils/client'
import type { ProxyStatsInfo } from '../types/proxy'
import type { ServerInfo } from '../types/server'
const route = useRoute()
const router = useRouter()
const proxyTypes = [
{ label: 'All', value: 'all' },
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'TCPMUX', value: 'tcpmux' },
{ label: 'STCP', value: 'stcp' },
{ label: 'XTCP', value: 'xtcp' },
{ label: 'SUDP', value: 'sudp' },
]
const activeType = ref((route.params.type as string) || 'tcp')
const activeType = ref((route.params.type as string) || 'all')
const proxies = ref<BaseProxy[]>([])
const clients = ref<Client[]>([])
const loading = ref(false)
const searchText = ref('')
const showClearDialog = ref(false)
const clientIDFilter = ref((route.query.clientID as string) || '')
const userFilter = ref((route.query.user as string) || '')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let requestSeq = 0
let searchDebounceTimer: number | null = null
const clientOptions = computed(() => {
return clients.value
.map((c) => ({
key: c.key,
clientID: c.clientID,
user: c.user,
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
}))
.sort((a, b) => a.label.localeCompare(b.label))
})
// Server info cache - cache the Promise itself so concurrent first calls
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
let serverInfoPromise: Promise<ServerInfo> | null = null
// Compute selected client key for el-select v-model
const selectedClientKey = computed(() => {
if (!clientIDFilter.value) return ''
const client = clientOptions.value.find(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
// Return a synthetic key even if not found, so the select shows the filter is active
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
})
const selectedClientLabel = computed(() => {
if (!clientIDFilter.value) return 'All Clients'
const client = clientOptions.value.find(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`
})
const filteredClientOptions = (filterText: string) => {
if (!filterText) return clientOptions.value
const search = filterText.toLowerCase()
return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))
}
// Check if the filtered client exists in the client list
const selectedClientInList = computed(() => {
if (!clientIDFilter.value) return true
return clientOptions.value.some(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
})
const filteredProxies = computed(() => {
let result = proxies.value
// Filter by clientID and user if specified
if (clientIDFilter.value) {
result = result.filter(
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
)
}
// Filter by search text
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter((p) => p.name.toLowerCase().includes(search))
}
return result
})
const onClientFilterChange = (key: string) => {
if (key) {
const client = clientOptions.value.find((c) => c.key === key)
if (client) {
router.replace({
query: { ...route.query, clientID: client.clientID, user: client.user },
const fetchServerInfo = (): Promise<ServerInfo> => {
if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => {
// Allow retry after failure
serverInfoPromise = null
throw err
})
}
} else {
const query = { ...route.query }
delete query.clientID
delete query.user
router.replace({ query })
}
return serverInfoPromise
}
const fetchClients = async () => {
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
} catch {
// Ignore errors when fetching clients
}
}
// Server info cache
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
}
const fetchData = async () => {
loading.value = true
proxies.value = []
try {
const type = activeType.value
const json = await getProxiesByType(type)
const convertProxy = async (
proxy: ProxyStatsInfo,
): Promise<BaseProxy | null> => {
const type = proxy.type || activeType.value
if (type === 'tcp') {
proxies.value = json.proxies.map((p: any) => new TCPProxy(p))
} else if (type === 'udp') {
proxies.value = json.proxies.map((p: any) => new UDPProxy(p))
} else if (type === 'http') {
return new TCPProxy(proxy)
}
if (type === 'udp') {
return new UDPProxy(proxy)
}
if (type === 'http') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) {
proxies.value = json.proxies.map(
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
if (info && info.config.vhostHTTPPort) {
return new HTTPProxy(
proxy,
info.config.vhostHTTPPort,
info.config.subdomainHost,
)
}
} else if (type === 'https') {
return null
}
if (type === 'https') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) {
proxies.value = json.proxies.map(
(p: any) =>
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
if (info && info.config.vhostHTTPSPort) {
return new HTTPSProxy(
proxy,
info.config.vhostHTTPSPort,
info.config.subdomainHost,
)
}
} else if (type === 'tcpmux') {
return null
}
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),
if (info && info.config.tcpmuxHTTPConnectPort) {
return new TCPMuxProxy(
proxy,
info.config.tcpmuxHTTPConnectPort,
info.config.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))
return null
}
if (type === 'stcp') {
return new STCPProxy(proxy)
}
if (type === 'sudp') {
return new SUDPProxy(proxy)
}
// Fallback for types without a dedicated class (e.g. xtcp). Matches the
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
const bp = new BaseProxy(proxy)
bp.type = type
return bp
}
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
const converted = await Promise.all(items.map((item) => convertProxy(item)))
return converted.filter((item): item is BaseProxy => item !== null)
}
const fetchData = async (silent = false) => {
const seq = ++requestSeq
if (!silent) loading.value = true
try {
const q = searchText.value.trim()
const data = await getProxiesV2({
page: page.value,
pageSize: pageSize.value,
type: activeType.value === 'all' ? undefined : activeType.value,
q: q || undefined,
})
if (seq !== requestSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchData(silent)
return
}
const converted = await convertProxies(data.items)
if (seq !== requestSeq) return
proxies.value = converted
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) {
if (seq !== requestSeq) return
ElMessage({
showClose: true,
message: 'Failed to fetch proxies: ' + error.message,
type: 'error',
})
} finally {
if (seq === requestSeq) {
loading.value = false
}
}
}
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const refreshData = () => {
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const handleClearConfirm = async () => {
showClearDialog.value = false
@@ -321,25 +299,45 @@ const clearOfflineProxies = async () => {
}
}
const sanitizeClientQuery = () => {
const hasClientQuery =
Object.prototype.hasOwnProperty.call(route.query, 'clientID') ||
Object.prototype.hasOwnProperty.call(route.query, 'user')
if (!hasClientQuery) return
const query = { ...route.query }
delete query.clientID
delete query.user
router.replace({ query })
}
// Watch for type changes
watch(activeType, (newType) => {
clearSearchDebounce()
page.value = 1
// Update route but preserve query params
router.replace({ params: { type: newType }, query: route.query })
fetchData()
})
// Watch for route query changes (client filter)
watch(
() => [route.query.clientID, route.query.user],
([newClientID, newUser]) => {
clientIDFilter.value = (newClientID as string) || ''
userFilter.value = (newUser as string) || ''
},
)
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
watch(() => route.query, sanitizeClientQuery)
onUnmounted(() => {
clearSearchDebounce()
})
// Initial fetch
sanitizeClientQuery()
fetchData()
fetchClients()
</script>
<style scoped>
@@ -406,16 +404,11 @@ fetchClients()
flex: 1;
}
.main-search :deep(.el-input__wrapper),
.client-filter :deep(.el-input__wrapper) {
.main-search :deep(.el-input__wrapper) {
height: 32px;
border-radius: 8px;
}
.client-filter {
width: 240px;
}
.type-tabs {
display: flex;
gap: 8px;
@@ -460,13 +453,18 @@ fetchClients()
padding: 60px 0;
}
.pagination-section {
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.search-row {
flex-direction: column;
}
.client-filter {
width: 100%;
.pagination-section {
justify-content: center;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More