mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 17:29:16 +08:00
Compare commits
36 Commits
v0.68.0
...
ae1c0504ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae1c0504ec | ||
|
|
393a533744 | ||
|
|
035889c360 | ||
|
|
940bde5c46 | ||
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec | ||
|
|
9bde0b07de | ||
|
|
c6c545289c | ||
|
|
503afe78b7 | ||
|
|
9ea1d86f03 | ||
|
|
ac3e82db4e | ||
|
|
0773938d70 | ||
|
|
9bacce22a2 | ||
|
|
7f8d68b666 | ||
|
|
3e19ef9bfd | ||
|
|
e20f974d61 | ||
|
|
a88e0e9a49 | ||
|
|
ad07d27914 | ||
|
|
8666e3643f | ||
|
|
57bb9e80fe | ||
|
|
cef71fb949 | ||
|
|
410c4861c4 | ||
|
|
e9464919d1 | ||
|
|
e8dfd6efcc | ||
|
|
a9a4416ecf | ||
|
|
d667be7a0a | ||
|
|
31c3deb4f7 | ||
|
|
31e271939b | ||
|
|
061c141756 | ||
|
|
98ee1adb13 | ||
|
|
76abeff881 | ||
|
|
c694b1f6a9 | ||
|
|
5ed02275da | ||
|
|
60c4f5d4bd |
14
.github/workflows/build-and-push-image.yml
vendored
14
.github/workflows/build-and-push-image.yml
vendored
@@ -19,15 +19,15 @@ jobs:
|
||||
steps:
|
||||
# environment
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: '0'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# get image tag name
|
||||
- name: Get Image Tag Name
|
||||
@@ -38,13 +38,13 @@ jobs:
|
||||
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
||||
fi
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to the GPR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push frpc
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./dockerfiles/Dockerfile-for-frpc
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
${{ env.TAG_FRPC_GPR }}
|
||||
|
||||
- name: Build and push frps
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./dockerfiles/Dockerfile-for-frps
|
||||
|
||||
8
.github/workflows/golangci-lint.yml
vendored
8
.github/workflows/golangci-lint.yml
vendored
@@ -14,12 +14,12 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache: false
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Build web assets (frps)
|
||||
@@ -32,4 +32,4 @@ jobs:
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
||||
version: v2.10
|
||||
version: v2.11
|
||||
|
||||
8
.github/workflows/goreleaser.yml
vendored
8
.github/workflows/goreleaser.yml
vendored
@@ -8,15 +8,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Build web assets (frps)
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
./package.sh
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean --release-notes=./Release.md
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
actions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
stale-issue-message: 'Issues go stale after 14d of inactivity. Stale issues rot after an additional 3d of inactivity and eventually close.'
|
||||
stale-pr-message: "PRs go stale after 14d of inactivity. Stale PRs rot after an additional 3d of inactivity and eventually close."
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@ release/
|
||||
test/bin/
|
||||
vendor/
|
||||
lastversion/
|
||||
.cache/
|
||||
dist/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -34,7 +34,7 @@ linters:
|
||||
disabled-checks:
|
||||
- exitAfterDefer
|
||||
gosec:
|
||||
excludes: ["G115", "G117", "G204", "G401", "G402", "G404", "G501", "G703", "G704", "G705"]
|
||||
excludes: ["G115", "G117", "G118", "G204", "G401", "G402", "G404", "G501", "G703", "G704", "G705"]
|
||||
severity: low
|
||||
confidence: low
|
||||
govet:
|
||||
|
||||
15
Makefile
15
Makefile
@@ -2,8 +2,10 @@ export PATH := $(PATH):`go env GOPATH`/bin
|
||||
export GO111MODULE=on
|
||||
LDFLAGS := -s -w
|
||||
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
|
||||
FRP_COMPAT_BASELINE_COUNT ?= 8
|
||||
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
|
||||
|
||||
.PHONY: web frps-web frpc-web frps frpc
|
||||
.PHONY: web frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
|
||||
|
||||
all: env fmt web build
|
||||
|
||||
@@ -53,6 +55,15 @@ e2e:
|
||||
e2e-trace:
|
||||
DEBUG=true LOG_LEVEL=trace ./hack/run-e2e.sh
|
||||
|
||||
e2e-compatibility-smoke: build
|
||||
FRP_COMPAT_BASELINE_COUNT=1 ./hack/run-e2e-compatibility.sh
|
||||
|
||||
e2e-compatibility: build
|
||||
FRP_COMPAT_BASELINE_COUNT="$(FRP_COMPAT_BASELINE_COUNT)" ./hack/run-e2e-compatibility.sh
|
||||
|
||||
e2e-compatibility-floor: build
|
||||
FRP_COMPAT_BASELINE_VERSIONS="$(FRP_COMPAT_FLOOR_VERSION)" ./hack/run-e2e-compatibility.sh
|
||||
|
||||
e2e-compatibility-last-frpc:
|
||||
if [ ! -d "./lastversion" ]; then \
|
||||
TARGET_DIRNAME=lastversion ./hack/download.sh; \
|
||||
@@ -73,3 +84,5 @@ clean:
|
||||
rm -f ./bin/frpc
|
||||
rm -f ./bin/frps
|
||||
rm -rf ./lastversion
|
||||
rm -rf ./.cache
|
||||
rm -rf ./.compat
|
||||
|
||||
50
README.md
50
README.md
@@ -13,26 +13,6 @@ 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://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
||||
<br>
|
||||
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
||||
<br>
|
||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -50,6 +30,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<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?
|
||||
@@ -81,6 +71,7 @@ frp also offers a P2P connect mode.
|
||||
* [Split Configures Into Different Files](#split-configures-into-different-files)
|
||||
* [Server Dashboard](#server-dashboard)
|
||||
* [Client Admin UI](#client-admin-ui)
|
||||
* [Dynamic Proxy Management (Store)](#dynamic-proxy-management-store)
|
||||
* [Monitor](#monitor)
|
||||
* [Prometheus](#prometheus)
|
||||
* [Authenticating the Client](#authenticating-the-client)
|
||||
@@ -149,7 +140,9 @@ We sincerely appreciate your support for frp.
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img src="/doc/pic/architecture.jpg" alt="architecture" width="760">
|
||||
</p>
|
||||
|
||||
## Example Usage
|
||||
|
||||
@@ -593,7 +586,7 @@ Then visit `https://[serverAddr]:7500` to see the dashboard in secure HTTPS conn
|
||||
|
||||
### Client Admin UI
|
||||
|
||||
The Client Admin UI helps you check and manage frpc's configuration.
|
||||
The Client Admin UI helps you check and manage frpc's configuration and proxies.
|
||||
|
||||
Configure an address for admin UI to enable this feature:
|
||||
|
||||
@@ -606,6 +599,19 @@ webServer.password = "admin"
|
||||
|
||||
Then visit `http://127.0.0.1:7400` to see admin UI, with username and password both being `admin`.
|
||||
|
||||
#### Dynamic Proxy Management (Store)
|
||||
|
||||
You can dynamically create, update, and delete proxies and visitors at runtime through the Web UI or API, without restarting frpc.
|
||||
|
||||
To enable this feature, configure `store.path` to specify a file for persisting the configurations:
|
||||
|
||||
```toml
|
||||
[store]
|
||||
path = "./db.json"
|
||||
```
|
||||
|
||||
Proxies and visitors managed through the Store are saved to disk and automatically restored on frpc restart. They work alongside proxies defined in the configuration file — Store entries take precedence when names conflict.
|
||||
|
||||
### Monitor
|
||||
|
||||
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
|
||||
|
||||
30
README_zh.md
30
README_zh.md
@@ -15,26 +15,6 @@ 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://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
||||
<br>
|
||||
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
||||
<br>
|
||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -52,6 +32,16 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<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 ?
|
||||
|
||||
12
Release.md
12
Release.md
@@ -1,10 +1,10 @@
|
||||
## Features
|
||||
|
||||
* Added a built-in `store` capability for frpc, including persisted store source (`[store] path = "..."`), Store CRUD admin APIs (`/api/store/proxies*`, `/api/store/visitors*`) with runtime reload, and Store management pages in the frpc web dashboard.
|
||||
* Added dashboard API v2 pagination endpoints for users, clients, and proxies.
|
||||
* The frps dashboard Clients and Proxies pages now use API v2 pagination and server-side search, including proxy type filtering and searchable proxy spec fields such as remote ports, custom domains, and subdomains.
|
||||
|
||||
## Improvements
|
||||
## Fixes
|
||||
|
||||
* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.
|
||||
* Added `noweb` build tag to allow compiling without frontend assets. `make build` now auto-detects missing `web/*/dist` directories and skips embedding, so a fresh clone can build without running `make web` first. The dashboard gracefully returns 404 when assets are not embedded.
|
||||
* Improved config parsing errors: for `.toml` files, syntax errors now return immediately with parser position details (line/column when available) instead of falling through to YAML/JSON parsing, and TOML type mismatches report field-level errors without misleading line numbers.
|
||||
* OIDC auth now caches the access token and refreshes it before expiry, avoiding a new token request on every heartbeat. Falls back to per-request fetch when the provider omits `expires_in`.
|
||||
* 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.
|
||||
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
"github.com/samber/lo"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
"github.com/fatedier/frp/pkg/transport"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
@@ -41,6 +43,39 @@ type Connector interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
type MessageConnector interface {
|
||||
Connect() (*msg.Conn, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type messageConnector struct {
|
||||
connector Connector
|
||||
wireProtocol string
|
||||
}
|
||||
|
||||
func newMessageConnector(connector Connector, wireProtocol string) *messageConnector {
|
||||
return &messageConnector{
|
||||
connector: connector,
|
||||
wireProtocol: wireProtocol,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *messageConnector) Connect() (*msg.Conn, error) {
|
||||
conn, err := c.connector.Connect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = wire.WriteMagicIfV2(conn, c.wireProtocol); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return msg.NewConn(conn, msg.NewReadWriter(conn, c.wireProtocol)), nil
|
||||
}
|
||||
|
||||
func (c *messageConnector) Close() error {
|
||||
return c.connector.Close()
|
||||
}
|
||||
|
||||
// defaultConnectorImpl is the default implementation of Connector for normal frpc.
|
||||
type defaultConnectorImpl struct {
|
||||
ctx context.Context
|
||||
@@ -119,6 +154,7 @@ func (c *defaultConnectorImpl) Open() error {
|
||||
fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
|
||||
session, err := fmux.Client(conn, fmuxCfg)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
c.muxSession = session
|
||||
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/naming"
|
||||
"github.com/fatedier/frp/pkg/transport"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
"github.com/fatedier/frp/pkg/util/wait"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
"github.com/fatedier/frp/pkg/vnet"
|
||||
@@ -41,13 +40,11 @@ type SessionContext struct {
|
||||
// It should be attached to the login message when reconnecting.
|
||||
RunID string
|
||||
// Underlying control connection. Once conn is closed, the msgDispatcher and the entire Control will exit.
|
||||
Conn net.Conn
|
||||
// Indicates whether the connection is encrypted.
|
||||
ConnEncrypted bool
|
||||
Conn *msg.Conn
|
||||
// Auth runtime used for login, heartbeats, and encryption.
|
||||
Auth *auth.ClientAuth
|
||||
// Connector is used to create new connections, which could be real TCP connections or virtual streams.
|
||||
Connector Connector
|
||||
// Connector is used to create message connections to frps.
|
||||
Connector MessageConnector
|
||||
// Virtual net controller
|
||||
VnetController *vnet.Controller
|
||||
}
|
||||
@@ -91,15 +88,7 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
||||
}
|
||||
ctl.lastPong.Store(time.Now())
|
||||
|
||||
if sessionCtx.ConnEncrypted {
|
||||
cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.Auth.EncryptionKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctl.msgDispatcher = msg.NewDispatcher(cryptoRW)
|
||||
} else {
|
||||
ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
|
||||
}
|
||||
ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
|
||||
ctl.registerMsgHandlers()
|
||||
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
|
||||
|
||||
@@ -139,14 +128,14 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
|
||||
workConn.Close()
|
||||
return
|
||||
}
|
||||
if err = msg.WriteMsg(workConn, m); err != nil {
|
||||
if err = workConn.WriteMsg(m); err != nil {
|
||||
xl.Warnf("work connection write to server error: %v", err)
|
||||
workConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var startMsg msg.StartWorkConn
|
||||
if err = msg.ReadMsgInto(workConn, &startMsg); err != nil {
|
||||
if err = workConn.ReadMsgInto(&startMsg); err != nil {
|
||||
xl.Tracef("work connection closed before response StartWorkConn message: %v", err)
|
||||
workConn.Close()
|
||||
return
|
||||
@@ -227,7 +216,7 @@ func (ctl *Control) Done() <-chan struct{} {
|
||||
}
|
||||
|
||||
// connectServer return a new connection to frps
|
||||
func (ctl *Control) connectServer() (net.Conn, error) {
|
||||
func (ctl *Control) connectServer() (*msg.Conn, error) {
|
||||
return ctl.sessionCtx.Connector.Connect()
|
||||
}
|
||||
|
||||
|
||||
220
client/control_session.go
Normal file
220
client/control_session.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/fatedier/frp/pkg/auth"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
"github.com/fatedier/frp/pkg/util/version"
|
||||
"github.com/fatedier/frp/pkg/vnet"
|
||||
)
|
||||
|
||||
type controlSessionDialer struct {
|
||||
ctx context.Context
|
||||
|
||||
common *v1.ClientCommonConfig
|
||||
auth *auth.ClientAuth
|
||||
clientSpec *msg.ClientSpec
|
||||
vnetController *vnet.Controller
|
||||
|
||||
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
|
||||
}
|
||||
|
||||
func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, error) {
|
||||
connector := d.connectorCreator(d.ctx, d.common)
|
||||
if err := connector.Open(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = connector.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
conn, err := connector.Connect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
loginMsg, err := d.buildLoginMsg(previousRunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loginResult, err := d.exchangeLogin(conn, loginMsg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loginRespMsg := loginResult.resp
|
||||
if loginRespMsg.Error != "" {
|
||||
return nil, errors.New(loginRespMsg.Error)
|
||||
}
|
||||
|
||||
var controlRW io.ReadWriter = conn
|
||||
if d.clientSpec == nil || d.clientSpec.Type != "ssh-tunnel" {
|
||||
controlRW, err = d.newControlReadWriter(conn, loginResult.crypto)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create control crypto read writer: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
success = true
|
||||
return &SessionContext{
|
||||
Common: d.common,
|
||||
RunID: loginRespMsg.RunID,
|
||||
Conn: msg.NewConn(conn, msg.NewReadWriter(controlRW, d.common.Transport.WireProtocol)),
|
||||
Auth: d.auth,
|
||||
Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
|
||||
VnetController: d.vnetController,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
loginMsg := &msg.Login{
|
||||
Arch: runtime.GOARCH,
|
||||
Os: runtime.GOOS,
|
||||
Hostname: hostname,
|
||||
PoolCount: d.common.Transport.PoolCount,
|
||||
User: d.common.User,
|
||||
ClientID: d.common.ClientID,
|
||||
Version: version.Full(),
|
||||
Timestamp: time.Now().Unix(),
|
||||
RunID: previousRunID,
|
||||
Metas: d.common.Metadatas,
|
||||
}
|
||||
if d.clientSpec != nil {
|
||||
loginMsg.ClientSpec = *d.clientSpec
|
||||
}
|
||||
|
||||
if err := d.auth.Setter.SetLogin(loginMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loginMsg, nil
|
||||
}
|
||||
|
||||
type loginExchangeResult struct {
|
||||
resp *msg.LoginResp
|
||||
crypto *wire.CryptoContext
|
||||
}
|
||||
|
||||
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
|
||||
rw := msg.NewV1ReadWriter(conn)
|
||||
var wireConn *wire.Conn
|
||||
var clientHello wire.ClientHello
|
||||
var clientHelloPayload []byte
|
||||
|
||||
if d.common.Transport.WireProtocol == wire.ProtocolV2 {
|
||||
if err := wire.WriteMagic(conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wireConn = wire.NewConn(conn)
|
||||
rw = msg.NewV2ReadWriterWithConn(wireConn)
|
||||
var err error
|
||||
clientHello, err = wire.NewClientHello(wire.BootstrapInfo{
|
||||
Transport: d.common.Transport.Protocol,
|
||||
TLS: lo.FromPtr(d.common.Transport.TLS.Enable) || d.common.Transport.Protocol == "wss" || d.common.Transport.Protocol == "quic",
|
||||
TCPMux: lo.FromPtr(d.common.Transport.TCPMux),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeClientHello, clientHello)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := wireConn.WriteFrame(clientHelloFrame); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientHelloPayload = clientHelloFrame.Payload
|
||||
}
|
||||
if err := rw.WriteMsg(loginMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
defer func() {
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
}()
|
||||
|
||||
var cryptoContext *wire.CryptoContext
|
||||
if wireConn != nil {
|
||||
serverHelloFrame, err := wireConn.ReadFrame()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if serverHelloFrame.Type != wire.FrameTypeServerHello {
|
||||
return nil, fmt.Errorf("unexpected frame type %d, want %d", serverHelloFrame.Type, wire.FrameTypeServerHello)
|
||||
}
|
||||
var serverHello wire.ServerHello
|
||||
if err := wireConn.UnmarshalFrame(serverHelloFrame, &serverHello); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if serverHello.Error != "" {
|
||||
return nil, errors.New(serverHello.Error)
|
||||
}
|
||||
cryptoContext, err = wire.NewClientCryptoContext(clientHelloPayload, serverHelloFrame.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var loginRespMsg msg.LoginResp
|
||||
if err := rw.ReadMsgInto(&loginRespMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &loginExchangeResult{
|
||||
resp: &loginRespMsg,
|
||||
crypto: cryptoContext,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *controlSessionDialer) newControlReadWriter(conn net.Conn, cryptoContext *wire.CryptoContext) (io.ReadWriter, error) {
|
||||
if d.common.Transport.WireProtocol == wire.ProtocolV2 {
|
||||
if cryptoContext == nil {
|
||||
return nil, errors.New("missing v2 crypto negotiation")
|
||||
}
|
||||
return netpkg.NewAEADCryptoReadWriter(
|
||||
conn,
|
||||
d.auth.EncryptionKey(),
|
||||
netpkg.AEADCryptoRoleClient,
|
||||
cryptoContext.Algorithm,
|
||||
cryptoContext.TranscriptHash,
|
||||
)
|
||||
}
|
||||
return netpkg.NewCryptoReadWriter(conn, d.auth.EncryptionKey())
|
||||
}
|
||||
297
client/control_session_test.go
Normal file
297
client/control_session_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/auth"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
)
|
||||
|
||||
type testConnector struct {
|
||||
conn net.Conn
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *testConnector) Open() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *testConnector) Connect() (net.Conn, error) {
|
||||
return c.conn, nil
|
||||
}
|
||||
|
||||
func (c *testConnector) Close() error {
|
||||
c.closed.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
type trackingConn struct {
|
||||
net.Conn
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *trackingConn) Close() error {
|
||||
c.closed.Store(true)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
func newTestControlSessionDialer(t *testing.T, protocol string, connector Connector, clientSpec *msg.ClientSpec) *controlSessionDialer {
|
||||
t.Helper()
|
||||
|
||||
authRuntime, err := auth.BuildClientAuth(&v1.AuthClientConfig{
|
||||
Method: v1.AuthMethodToken,
|
||||
Token: "token",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return &controlSessionDialer{
|
||||
ctx: context.Background(),
|
||||
common: &v1.ClientCommonConfig{
|
||||
User: "test-user",
|
||||
Transport: v1.ClientTransportConfig{
|
||||
Protocol: "tcp",
|
||||
WireProtocol: protocol,
|
||||
},
|
||||
},
|
||||
auth: authRuntime,
|
||||
clientSpec: clientSpec,
|
||||
connectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||
return connector
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlSessionDialerDialV1(t *testing.T) {
|
||||
clientRaw, serverRaw := net.Pipe()
|
||||
defer serverRaw.Close()
|
||||
|
||||
connector := &testConnector{conn: &trackingConn{Conn: clientRaw}}
|
||||
serverErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
rw := msg.NewV1ReadWriter(serverRaw)
|
||||
var loginMsg msg.Login
|
||||
if err := rw.ReadMsgInto(&loginMsg); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
if loginMsg.RunID != "previous-run-id" {
|
||||
serverErrCh <- fmt.Errorf("unexpected previous run id: %s", loginMsg.RunID)
|
||||
return
|
||||
}
|
||||
if loginMsg.User != "test-user" {
|
||||
serverErrCh <- fmt.Errorf("unexpected user: %s", loginMsg.User)
|
||||
return
|
||||
}
|
||||
serverErrCh <- rw.WriteMsg(&msg.LoginResp{RunID: "run-v1"})
|
||||
}()
|
||||
|
||||
dialer := newTestControlSessionDialer(t, wire.ProtocolV1, connector, nil)
|
||||
sessionCtx, err := dialer.Dial("previous-run-id")
|
||||
require.NoError(t, err)
|
||||
defer sessionCtx.Conn.Close()
|
||||
defer sessionCtx.Connector.Close()
|
||||
|
||||
require.Equal(t, "run-v1", sessionCtx.RunID)
|
||||
require.NotNil(t, sessionCtx.Conn)
|
||||
require.NotNil(t, sessionCtx.Connector)
|
||||
require.False(t, connector.closed.Load())
|
||||
require.NoError(t, <-serverErrCh)
|
||||
}
|
||||
|
||||
func TestControlSessionDialerDialV2(t *testing.T) {
|
||||
clientRaw, serverRaw := net.Pipe()
|
||||
defer serverRaw.Close()
|
||||
|
||||
connector := &testConnector{conn: &trackingConn{Conn: clientRaw}}
|
||||
serverErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
magic := make([]byte, len(wire.MagicV2))
|
||||
if _, err := io.ReadFull(serverRaw, magic); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
if string(magic) != wire.MagicV2 {
|
||||
serverErrCh <- fmt.Errorf("unexpected magic: %q", string(magic))
|
||||
return
|
||||
}
|
||||
|
||||
wireConn := wire.NewConn(serverRaw)
|
||||
clientHelloFrame, err := wireConn.ReadFrame()
|
||||
if err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
if clientHelloFrame.Type != wire.FrameTypeClientHello {
|
||||
serverErrCh <- fmt.Errorf("unexpected frame type %d, want %d", clientHelloFrame.Type, wire.FrameTypeClientHello)
|
||||
return
|
||||
}
|
||||
var hello wire.ClientHello
|
||||
if err := wireConn.UnmarshalFrame(clientHelloFrame, &hello); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
if err := wire.ValidateClientHello(hello); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
|
||||
rw := msg.NewV2ReadWriterWithConn(wireConn)
|
||||
var loginMsg msg.Login
|
||||
if err := rw.ReadMsgInto(&loginMsg); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
if loginMsg.User != "test-user" {
|
||||
serverErrCh <- fmt.Errorf("unexpected user: %s", loginMsg.User)
|
||||
return
|
||||
}
|
||||
serverHello, err := wire.NewServerHello(hello)
|
||||
if err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
serverHelloFrame, err := wire.NewJSONFrame(wire.FrameTypeServerHello, serverHello)
|
||||
if err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
cryptoContext := wire.NewCryptoContext(
|
||||
serverHello.Selected.Crypto.Algorithm,
|
||||
clientHelloFrame.Payload,
|
||||
serverHelloFrame.Payload,
|
||||
)
|
||||
if err := wireConn.WriteFrame(serverHelloFrame); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
if err := rw.WriteMsg(&msg.LoginResp{RunID: "run-v2"}); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
|
||||
controlRW, err := netpkg.NewAEADCryptoReadWriter(
|
||||
serverRaw,
|
||||
[]byte("token"),
|
||||
netpkg.AEADCryptoRoleServer,
|
||||
cryptoContext.Algorithm,
|
||||
cryptoContext.TranscriptHash,
|
||||
)
|
||||
if err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
controlMsgRW := msg.NewReadWriter(controlRW, wire.ProtocolV2)
|
||||
var ping msg.Ping
|
||||
if err := controlMsgRW.ReadMsgInto(&ping); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
if ping.PrivilegeKey != "v2-ping" || ping.Timestamp != 12345 {
|
||||
serverErrCh <- fmt.Errorf("unexpected ping: %+v", ping)
|
||||
return
|
||||
}
|
||||
serverErrCh <- nil
|
||||
}()
|
||||
|
||||
dialer := newTestControlSessionDialer(t, wire.ProtocolV2, connector, nil)
|
||||
sessionCtx, err := dialer.Dial("")
|
||||
require.NoError(t, err)
|
||||
defer sessionCtx.Conn.Close()
|
||||
defer sessionCtx.Connector.Close()
|
||||
|
||||
require.Equal(t, "run-v2", sessionCtx.RunID)
|
||||
require.NotNil(t, sessionCtx.Conn)
|
||||
require.NotNil(t, sessionCtx.Connector)
|
||||
require.False(t, connector.closed.Load())
|
||||
require.NoError(t, sessionCtx.Conn.WriteMsg(&msg.Ping{PrivilegeKey: "v2-ping", Timestamp: 12345}))
|
||||
require.NoError(t, <-serverErrCh)
|
||||
}
|
||||
|
||||
func TestControlSessionDialerDialLoginErrorClosesResources(t *testing.T) {
|
||||
clientRaw, serverRaw := net.Pipe()
|
||||
defer serverRaw.Close()
|
||||
|
||||
clientConn := &trackingConn{Conn: clientRaw}
|
||||
connector := &testConnector{conn: clientConn}
|
||||
serverErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
rw := msg.NewV1ReadWriter(serverRaw)
|
||||
var loginMsg msg.Login
|
||||
if err := rw.ReadMsgInto(&loginMsg); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
serverErrCh <- rw.WriteMsg(&msg.LoginResp{Error: "login denied"})
|
||||
}()
|
||||
|
||||
dialer := newTestControlSessionDialer(t, wire.ProtocolV1, connector, nil)
|
||||
sessionCtx, err := dialer.Dial("")
|
||||
require.Nil(t, sessionCtx)
|
||||
require.ErrorContains(t, err, "login denied")
|
||||
require.True(t, clientConn.closed.Load())
|
||||
require.True(t, connector.closed.Load())
|
||||
require.NoError(t, <-serverErrCh)
|
||||
}
|
||||
|
||||
func TestControlSessionDialerDialSSHTunnelSkipsControlEncryption(t *testing.T) {
|
||||
clientRaw, serverRaw := net.Pipe()
|
||||
defer serverRaw.Close()
|
||||
|
||||
connector := &testConnector{conn: &trackingConn{Conn: clientRaw}}
|
||||
serverErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
rw := msg.NewV1ReadWriter(serverRaw)
|
||||
var loginMsg msg.Login
|
||||
if err := rw.ReadMsgInto(&loginMsg); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
if err := rw.WriteMsg(&msg.LoginResp{RunID: "run-ssh-tunnel"}); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
|
||||
_ = serverRaw.SetReadDeadline(time.Now().Add(time.Second))
|
||||
var ping msg.Ping
|
||||
if err := rw.ReadMsgInto(&ping); err != nil {
|
||||
serverErrCh <- err
|
||||
return
|
||||
}
|
||||
serverErrCh <- nil
|
||||
}()
|
||||
|
||||
dialer := newTestControlSessionDialer(t, wire.ProtocolV1, connector, &msg.ClientSpec{Type: "ssh-tunnel"})
|
||||
sessionCtx, err := dialer.Dial("")
|
||||
require.NoError(t, err)
|
||||
defer sessionCtx.Conn.Close()
|
||||
defer sessionCtx.Connector.Close()
|
||||
|
||||
require.Equal(t, "run-ssh-tunnel", sessionCtx.RunID)
|
||||
require.NoError(t, sessionCtx.Conn.WriteMsg(&msg.Ping{}))
|
||||
require.NoError(t, <-serverErrCh)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
client/proxy/xtcp_test.go
Normal file
66
client/proxy/xtcp_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -38,7 +37,6 @@ import (
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
"github.com/fatedier/frp/pkg/util/version"
|
||||
"github.com/fatedier/frp/pkg/util/wait"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
"github.com/fatedier/frp/pkg/vnet"
|
||||
@@ -303,80 +301,20 @@ func (svr *Service) keepControllerWorking() {
|
||||
), true, svr.ctx.Done())
|
||||
}
|
||||
|
||||
// login creates a connection to frps and registers it self as a client
|
||||
// conn: control connection
|
||||
// session: if it's not nil, using tcp mux
|
||||
func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
|
||||
xl := xlog.FromContextSafe(svr.ctx)
|
||||
connector = svr.connectorCreator(svr.ctx, svr.common)
|
||||
if err = connector.Open(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
connector.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
conn, err = connector.Connect()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
loginMsg := &msg.Login{
|
||||
Arch: runtime.GOARCH,
|
||||
Os: runtime.GOOS,
|
||||
Hostname: hostname,
|
||||
PoolCount: svr.common.Transport.PoolCount,
|
||||
User: svr.common.User,
|
||||
ClientID: svr.common.ClientID,
|
||||
Version: version.Full(),
|
||||
Timestamp: time.Now().Unix(),
|
||||
RunID: svr.runID,
|
||||
Metas: svr.common.Metadatas,
|
||||
}
|
||||
if svr.clientSpec != nil {
|
||||
loginMsg.ClientSpec = *svr.clientSpec
|
||||
}
|
||||
|
||||
// Add auth
|
||||
if err = svr.auth.Setter.SetLogin(loginMsg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = msg.WriteMsg(conn, loginMsg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var loginRespMsg msg.LoginResp
|
||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
if err = msg.ReadMsgInto(conn, &loginRespMsg); err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
|
||||
if loginRespMsg.Error != "" {
|
||||
err = fmt.Errorf("%s", loginRespMsg.Error)
|
||||
xl.Errorf("%s", loginRespMsg.Error)
|
||||
return
|
||||
}
|
||||
|
||||
svr.runID = loginRespMsg.RunID
|
||||
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
|
||||
|
||||
xl.Infof("login to server success, get run id [%s]", loginRespMsg.RunID)
|
||||
return
|
||||
}
|
||||
|
||||
func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginExit bool) {
|
||||
xl := xlog.FromContextSafe(svr.ctx)
|
||||
|
||||
loginFunc := func() (bool, error) {
|
||||
xl.Infof("try to connect to server...")
|
||||
conn, connector, err := svr.login()
|
||||
dialer := &controlSessionDialer{
|
||||
ctx: svr.ctx,
|
||||
common: svr.common,
|
||||
auth: svr.auth,
|
||||
clientSpec: svr.clientSpec,
|
||||
vnetController: svr.vnetController,
|
||||
connectorCreator: svr.connectorCreator,
|
||||
}
|
||||
sessionCtx, err := dialer.Dial(svr.runID)
|
||||
if err != nil {
|
||||
xl.Warnf("connect to server error: %v", err)
|
||||
if firstLoginExit {
|
||||
@@ -385,25 +323,19 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
||||
return false, err
|
||||
}
|
||||
|
||||
svr.runID = sessionCtx.RunID
|
||||
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
|
||||
xl.Infof("login to server success, get run id [%s]", svr.runID)
|
||||
|
||||
svr.cfgMu.RLock()
|
||||
proxyCfgs := svr.proxyCfgs
|
||||
visitorCfgs := svr.visitorCfgs
|
||||
svr.cfgMu.RUnlock()
|
||||
|
||||
connEncrypted := svr.clientSpec == nil || svr.clientSpec.Type != "ssh-tunnel"
|
||||
|
||||
sessionCtx := &SessionContext{
|
||||
Common: svr.common,
|
||||
RunID: svr.runID,
|
||||
Conn: conn,
|
||||
ConnEncrypted: connEncrypted,
|
||||
Auth: svr.auth,
|
||||
Connector: connector,
|
||||
VnetController: svr.vnetController,
|
||||
}
|
||||
ctl, err := NewControl(svr.ctx, sessionCtx)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
sessionCtx.Conn.Close()
|
||||
sessionCtx.Connector.Close()
|
||||
xl.Errorf("new control error: %v", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -38,7 +38,7 @@ import (
|
||||
// Helper wraps some functions for visitor to use.
|
||||
type Helper interface {
|
||||
// ConnectServer directly connects to the frp server.
|
||||
ConnectServer() (net.Conn, error)
|
||||
ConnectServer() (*msg.Conn, error)
|
||||
// TransferConn transfers the connection to another visitor.
|
||||
TransferConn(string, net.Conn) error
|
||||
// MsgTransporter returns the message transporter that is used to send and receive messages
|
||||
@@ -167,15 +167,15 @@ func (v *BaseVisitor) dialRawVisitorConn(cfg *v1.VisitorBaseConfig) (net.Conn, e
|
||||
UseEncryption: cfg.Transport.UseEncryption,
|
||||
UseCompression: cfg.Transport.UseCompression,
|
||||
}
|
||||
err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
|
||||
err = visitorConn.WriteMsg(newVisitorConnMsg)
|
||||
if err != nil {
|
||||
visitorConn.Close()
|
||||
return nil, fmt.Errorf("send newVisitorConnMsg to server error: %v", err)
|
||||
}
|
||||
|
||||
var newVisitorConnRespMsg msg.NewVisitorConnResp
|
||||
_ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
|
||||
var newVisitorConnRespMsg msg.NewVisitorConnResp
|
||||
err = visitorConn.ReadMsgInto(&newVisitorConnRespMsg)
|
||||
if err != nil {
|
||||
visitorConn.Close()
|
||||
return nil, fmt.Errorf("read newVisitorConnRespMsg error: %v", err)
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/samber/lo"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/transport"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
"github.com/fatedier/frp/pkg/vnet"
|
||||
@@ -49,7 +50,7 @@ func NewManager(
|
||||
ctx context.Context,
|
||||
runID string,
|
||||
clientCfg *v1.ClientCommonConfig,
|
||||
connectServer func() (net.Conn, error),
|
||||
connectServer func() (*msg.Conn, error),
|
||||
msgTransporter transport.MessageTransporter,
|
||||
vnetController *vnet.Controller,
|
||||
) *Manager {
|
||||
@@ -199,14 +200,14 @@ func (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
|
||||
}
|
||||
|
||||
type visitorHelperImpl struct {
|
||||
connectServerFn func() (net.Conn, error)
|
||||
connectServerFn func() (*msg.Conn, error)
|
||||
msgTransporter transport.MessageTransporter
|
||||
vnetController *vnet.Controller
|
||||
transferConnFn func(name string, conn net.Conn) error
|
||||
runID string
|
||||
}
|
||||
|
||||
func (v *visitorHelperImpl) ConnectServer() (net.Conn, error) {
|
||||
func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
|
||||
return v.connectServerFn()
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ transport.poolCount = 5
|
||||
# supports tcp, kcp, quic, websocket and wss now, default is tcp
|
||||
transport.protocol = "tcp"
|
||||
|
||||
# FRP wire protocol used inside the selected transport.
|
||||
# supports v1 and v2, default is v1. v2 requires frps support and must be enabled explicitly.
|
||||
# transport.wireProtocol = "v1"
|
||||
|
||||
# set client binding ip when connect server, default is empty.
|
||||
# only when protocol = tcp or websocket, the value will be used.
|
||||
transport.connectServerLocalIP = "0.0.0.0"
|
||||
|
||||
@@ -33,7 +33,51 @@ git commit -m "bump version to vX.Y.Z"
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
## 3. Merge dev → master
|
||||
## 3. Pre-release Validation
|
||||
|
||||
Run the standard e2e suite locally:
|
||||
|
||||
```bash
|
||||
make e2e
|
||||
```
|
||||
|
||||
For releases that touch compatibility-sensitive areas such as login, control
|
||||
connections, work connections, visitors, transport, or wire protocol handling,
|
||||
also run the manual compatibility e2e suite:
|
||||
|
||||
```bash
|
||||
make e2e-compatibility
|
||||
make e2e-compatibility-floor
|
||||
```
|
||||
|
||||
`make e2e-compatibility` builds the current `frps` and `frpc`, resolves the
|
||||
recent stable release baselines from GitHub, downloads or reuses their binaries,
|
||||
and tests current binaries against those historical releases. The default number
|
||||
of recent baselines is controlled by `FRP_COMPAT_BASELINE_COUNT` in the
|
||||
`Makefile`.
|
||||
|
||||
Downloaded release binaries are cached under:
|
||||
|
||||
```text
|
||||
.cache/e2e-compat/<version>/<os>_<arch>/
|
||||
```
|
||||
|
||||
For a release validation run that must be exactly reproducible, pass an explicit
|
||||
baseline matrix instead of using the floating recent-release list:
|
||||
|
||||
```bash
|
||||
FRP_COMPAT_BASELINE_VERSIONS="0.X.0 0.Y.0" make e2e-compatibility
|
||||
```
|
||||
|
||||
Use `make e2e-compatibility-smoke` for a quick single-baseline check while
|
||||
iterating locally. If GitHub release metadata requests are rate-limited, set
|
||||
`GITHUB_TOKEN` or use `FRP_COMPAT_BASELINE_VERSIONS`.
|
||||
|
||||
The compatibility floor is a support-policy decision, not a value that should
|
||||
change every release. Update `FRP_COMPAT_FLOOR_VERSION` only when the declared
|
||||
compatibility window changes.
|
||||
|
||||
## 4. Merge dev → master
|
||||
|
||||
Create a PR from `dev` to `master`:
|
||||
|
||||
@@ -43,7 +87,7 @@ gh pr create --base master --head dev --title "bump version"
|
||||
|
||||
Wait for CI to pass, then merge using **merge commit** (not squash).
|
||||
|
||||
## 4. Tag the Release
|
||||
## 5. Tag the Release
|
||||
|
||||
```bash
|
||||
git checkout master
|
||||
@@ -52,7 +96,7 @@ git tag -a vX.Y.Z -m "bump version"
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
## 5. Trigger GoReleaser
|
||||
## 6. Trigger GoReleaser
|
||||
|
||||
Manually trigger the `goreleaser` workflow in GitHub Actions:
|
||||
|
||||
|
||||
38
doc/deprecations.md
Normal file
38
doc/deprecations.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Deprecations
|
||||
|
||||
This document tracks deprecated features and APIs that are still shipped but scheduled for removal. Maintainers should review this list before each release to decide whether any items are due for removal.
|
||||
|
||||
For the version compatibility policy that bounds these support windows, see the latest `Release.md`.
|
||||
|
||||
## Active
|
||||
|
||||
### Wire protocol v1
|
||||
|
||||
- **Deprecated since:** v0.70.0 (planned, when v2 becomes the default).
|
||||
- **Removal target:** v0.78.0 or later. v0.69.0 (the last release where v1 is the default) is supported until v0.78.0 is released, so v0.77.0 is the last release that must keep v1 support.
|
||||
- **Replacement:** wire protocol v2 (`transport.wireProtocol = "v2"` in frpc).
|
||||
- **Code references:** v1 message types and codec under `pkg/msg/` and the protocol negotiation path in `client/` and `server/`.
|
||||
- **Notes:** Removing v1 will also drop compatibility with any frpc/frps that does not negotiate v2.
|
||||
|
||||
### INI configuration format
|
||||
|
||||
- **Deprecated since:** predates this document; startup warning has been in place for several releases.
|
||||
- **Removal target:** TBD.
|
||||
- **Replacement:** YAML / JSON / TOML.
|
||||
- **Code references:**
|
||||
- `cmd/frpc/sub/root.go` — frpc startup warning.
|
||||
- `cmd/frps/root.go` — frps startup warning.
|
||||
- `pkg/config/legacy/` — legacy INI parser; remove together with the warnings.
|
||||
|
||||
### Visitor connections without `runID`
|
||||
|
||||
- **Deprecated since:** v0.50.0 (when `runID` was introduced).
|
||||
- **Removal target:** TBD.
|
||||
- **Replacement:** require `runID` on every visitor connection.
|
||||
- **Code references:**
|
||||
- `server/service.go` — `RegisterVisitorConn` still accepts empty `runID` for backward compatibility.
|
||||
- **Notes:** Removal will break frpc clients released before v0.50.0. Schedule for a release where dropping pre-v0.50.0 frpc is acceptable.
|
||||
|
||||
## Removed
|
||||
|
||||
_None yet._
|
||||
BIN
doc/pic/architecture.jpg
Normal file
BIN
doc/pic/architecture.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,8 +1,11 @@
|
||||
FROM node:22 AS web-builder
|
||||
|
||||
WORKDIR /web/frpc
|
||||
COPY web/frpc/ ./
|
||||
COPY web/package.json /web/package.json
|
||||
COPY web/shared/ /web/shared/
|
||||
COPY web/frpc/ /web/frpc/
|
||||
WORKDIR /web
|
||||
RUN npm install
|
||||
WORKDIR /web/frpc
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.25 AS building
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
FROM node:22 AS web-builder
|
||||
|
||||
WORKDIR /web/frps
|
||||
COPY web/frps/ ./
|
||||
COPY web/package.json /web/package.json
|
||||
COPY web/shared/ /web/shared/
|
||||
COPY web/frps/ /web/frps/
|
||||
WORKDIR /web
|
||||
RUN npm install
|
||||
WORKDIR /web/frps
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.25 AS building
|
||||
|
||||
34
go.mod
34
go.mod
@@ -5,7 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/fatedier/golib v0.5.1
|
||||
github.com/fatedier/golib v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
@@ -13,7 +13,7 @@ require (
|
||||
github.com/onsi/ginkgo/v2 v2.23.4
|
||||
github.com/onsi/gomega v1.36.3
|
||||
github.com/pelletier/go-toml/v2 v2.2.0
|
||||
github.com/pion/stun/v2 v2.0.0
|
||||
github.com/pion/stun/v3 v3.1.1
|
||||
github.com/pires/go-proxyproto v0.7.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/quic-go/quic-go v0.55.0
|
||||
@@ -22,23 +22,25 @@ require (
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.17.1
|
||||
github.com/vishvananda/netlink v1.3.0
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.28.0
|
||||
golang.org/x/sync v0.16.0
|
||||
golang.org/x/time v0.5.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/time v0.10.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
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 (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
@@ -51,10 +53,9 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.7 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/transport/v2 v2.2.1 // indirect
|
||||
github.com/pion/transport/v3 v3.0.1 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
@@ -66,16 +67,15 @@ require (
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
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
|
||||
)
|
||||
|
||||
105
go.sum
105
go.sum
@@ -1,6 +1,6 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M=
|
||||
github.com/fatedier/golib v0.5.1/go.mod h1:W6kIYkIFxHsTzbgqg5piCxIiDo4LzwgTY6R5W8l9NFQ=
|
||||
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
||||
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
@@ -78,16 +78,14 @@ github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
|
||||
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
|
||||
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
|
||||
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
||||
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -128,11 +126,10 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
|
||||
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
||||
github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
|
||||
@@ -149,11 +146,12 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
@@ -161,89 +159,54 @@ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
|
||||
162
hack/run-e2e-compatibility.sh
Executable file
162
hack/run-e2e-compatibility.sh
Executable file
@@ -0,0 +1,162 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT=$(readlink -f "$0")
|
||||
ROOT=$(unset CDPATH && cd "$(dirname "$SCRIPT")/.." && pwd)
|
||||
|
||||
if ! command -v ginkgo >/dev/null 2>&1; then
|
||||
echo "ginkgo not found, try to install..."
|
||||
go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4
|
||||
fi
|
||||
|
||||
debug=false
|
||||
if [ "x${DEBUG:-}" = "xtrue" ]; then
|
||||
debug=true
|
||||
fi
|
||||
logLevel=debug
|
||||
if [ "${LOG_LEVEL:-}" ]; then
|
||||
logLevel="${LOG_LEVEL}"
|
||||
fi
|
||||
|
||||
currentFrpsPath=${CURRENT_FRPS_PATH:-${ROOT}/bin/frps}
|
||||
currentFrpcPath=${CURRENT_FRPC_PATH:-${ROOT}/bin/frpc}
|
||||
baselineCount=${FRP_COMPAT_BASELINE_COUNT:-8}
|
||||
targetOS=${TARGET_OS:-$(go env GOOS)}
|
||||
targetArch=${TARGET_ARCH:-$(go env GOARCH)}
|
||||
targetPlatform="${targetOS}_${targetArch}"
|
||||
cacheRoot=${FRP_COMPAT_CACHE_DIR:-${ROOT}/.cache/e2e-compat}
|
||||
|
||||
check_file() {
|
||||
if [ ! -f "$2" ]; then
|
||||
echo "$1 not found: $2"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_file "current frps" "${currentFrpsPath}"
|
||||
check_file "current frpc" "${currentFrpcPath}"
|
||||
|
||||
run_current_current=true
|
||||
|
||||
run_compatibility() {
|
||||
baselineVersion=$1
|
||||
baselineFrpsPath=$2
|
||||
baselineFrpcPath=$3
|
||||
|
||||
check_file "baseline frps" "${baselineFrpsPath}"
|
||||
check_file "baseline frpc" "${baselineFrpcPath}"
|
||||
|
||||
echo "Running compatibility e2e with baseline ${baselineVersion}"
|
||||
ginkgo -nodes=1 --poll-progress-after=60s "${ROOT}/test/e2e/compatibility" -- \
|
||||
-current-frps-path="${currentFrpsPath}" \
|
||||
-current-frpc-path="${currentFrpcPath}" \
|
||||
-baseline-frps-path="${baselineFrpsPath}" \
|
||||
-baseline-frpc-path="${baselineFrpcPath}" \
|
||||
-baseline-version="${baselineVersion}" \
|
||||
-run-current-current="${run_current_current}" \
|
||||
-log-level="${logLevel}" \
|
||||
-debug="${debug}"
|
||||
run_current_current=false
|
||||
}
|
||||
|
||||
github_api_curl() {
|
||||
if [ "${GITHUB_TOKEN:-}" ]; then
|
||||
curl -fsSL \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
"$1"
|
||||
else
|
||||
curl -fsSL "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_versions() {
|
||||
if [ "${FRP_COMPAT_BASELINE_VERSIONS:-}" ]; then
|
||||
printf "%s\n" "${FRP_COMPAT_BASELINE_VERSIONS}"
|
||||
return
|
||||
fi
|
||||
|
||||
case "${baselineCount}" in
|
||||
'' | *[!0-9]*)
|
||||
echo "FRP_COMPAT_BASELINE_COUNT must be a positive integer: ${baselineCount}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "${baselineCount}" -eq 0 ]; then
|
||||
echo "FRP_COMPAT_BASELINE_COUNT must be greater than 0" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${baselineCount}" -gt 100 ]; then
|
||||
echo "FRP_COMPAT_BASELINE_COUNT must be less than or equal to 100" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
releaseURL="https://api.github.com/repos/fatedier/frp/releases?per_page=100"
|
||||
resolvedVersions=""
|
||||
if releases=$(github_api_curl "${releaseURL}" 2>/dev/null); then
|
||||
resolvedVersions=$(printf "%s\n" "${releases}" |
|
||||
sed -n 's/.*"tag_name":[[:space:]]*"v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' |
|
||||
awk '!seen[$0]++' |
|
||||
head -n "${baselineCount}" |
|
||||
tr '\n' ' ' |
|
||||
sed 's/[[:space:]]*$//')
|
||||
else
|
||||
echo "Failed to fetch release metadata from GitHub API, falling back to GitHub releases page." >&2
|
||||
fi
|
||||
|
||||
if [ -z "${resolvedVersions}" ]; then
|
||||
releasesPageURL="https://github.com/fatedier/frp/releases"
|
||||
if ! releases=$(curl -fsSL "${releasesPageURL}"); then
|
||||
echo "Failed to fetch release metadata from GitHub: ${releasesPageURL}" >&2
|
||||
echo "Set FRP_COMPAT_BASELINE_VERSIONS to run with explicit baseline versions." >&2
|
||||
exit 1
|
||||
fi
|
||||
resolvedVersions=$(printf "%s\n" "${releases}" |
|
||||
grep -o 'releases/tag/v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"' |
|
||||
sed 's#.*/v##; s/"$//' |
|
||||
awk '!seen[$0]++' |
|
||||
head -n "${baselineCount}" |
|
||||
tr '\n' ' ' |
|
||||
sed 's/[[:space:]]*$//')
|
||||
fi
|
||||
|
||||
set -- ${resolvedVersions}
|
||||
if [ "$#" -lt "${baselineCount}" ]; then
|
||||
echo "Only resolved $# stable release versions from GitHub, expected ${baselineCount}." >&2
|
||||
echo "Set FRP_COMPAT_BASELINE_VERSIONS to run with explicit baseline versions." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "%s\n" "${resolvedVersions}"
|
||||
}
|
||||
|
||||
if [ "${BASELINE_FRPS_PATH:-}" ] || [ "${BASELINE_FRPC_PATH:-}" ]; then
|
||||
if [ -z "${BASELINE_FRPS_PATH:-}" ] || [ -z "${BASELINE_FRPC_PATH:-}" ]; then
|
||||
echo "BASELINE_FRPS_PATH and BASELINE_FRPC_PATH must be set together"
|
||||
exit 1
|
||||
fi
|
||||
run_compatibility "${FRP_COMPAT_BASELINE_VERSION:-custom}" "${BASELINE_FRPS_PATH}" "${BASELINE_FRPC_PATH}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
versions=$(resolve_versions)
|
||||
echo "Compatibility baseline versions: ${versions}"
|
||||
|
||||
mkdir -p "${cacheRoot}"
|
||||
for version in ${versions}; do
|
||||
baselineDir="${cacheRoot}/${version}/${targetPlatform}"
|
||||
if [ ! -f "${baselineDir}/frps" ] || [ ! -f "${baselineDir}/frpc" ]; then
|
||||
tmpDir="${cacheRoot}/.download-${version}-${targetPlatform}"
|
||||
rm -rf "${tmpDir}"
|
||||
(
|
||||
cd "${cacheRoot}"
|
||||
FRP_VERSION="${version}" TARGET_DIRNAME="$(basename "${tmpDir}")" "${ROOT}/hack/download.sh"
|
||||
)
|
||||
mkdir -p "$(dirname "${baselineDir}")"
|
||||
rm -rf "${baselineDir}"
|
||||
mv "${tmpDir}" "${baselineDir}"
|
||||
fi
|
||||
|
||||
run_compatibility "${version}" "${baselineDir}/frps" "${baselineDir}/frpc"
|
||||
done
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
name := proxy.GetBaseConfig().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
|
||||
delete(entries, name)
|
||||
return s.persistOrRollbackUnlocked(func() {
|
||||
entries[name] = old
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.saveToFileUnlocked(); err != nil {
|
||||
delete(s.proxies, name)
|
||||
return fmt.Errorf("failed to persist: %w", err)
|
||||
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
|
||||
name, err := validateProxyName(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
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 {
|
||||
|
||||
@@ -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
pkg/config/source/validation.go
Normal file
43
pkg/config/source/validation.go
Normal 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
|
||||
}
|
||||
@@ -104,6 +104,9 @@ type ClientTransportConfig struct {
|
||||
// Valid values are "tcp", "kcp", "quic", "websocket" and "wss". By default, this value
|
||||
// is "tcp".
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
// WireProtocol specifies the frpc/frps internal wire protocol version.
|
||||
// Valid values are "v1" and "v2". By default, this value is "v1".
|
||||
WireProtocol string `json:"wireProtocol,omitempty"`
|
||||
// The maximum amount of time a dial to server will wait for a connect to complete.
|
||||
DialServerTimeout int64 `json:"dialServerTimeout,omitempty"`
|
||||
// DialServerKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps.
|
||||
@@ -143,6 +146,7 @@ type ClientTransportConfig struct {
|
||||
|
||||
func (c *ClientTransportConfig) Complete() {
|
||||
c.Protocol = util.EmptyOr(c.Protocol, "tcp")
|
||||
c.WireProtocol = util.EmptyOr(c.WireProtocol, "v1")
|
||||
c.DialServerTimeout = util.EmptyOr(c.DialServerTimeout, 10)
|
||||
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
|
||||
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
|
||||
|
||||
@@ -29,6 +29,7 @@ func TestClientConfigComplete(t *testing.T) {
|
||||
|
||||
require.EqualValues("token", c.Auth.Method)
|
||||
require.Equal(true, lo.FromPtr(c.Transport.TCPMux))
|
||||
require.Equal("v1", c.Transport.WireProtocol)
|
||||
require.Equal(true, lo.FromPtr(c.LoginFailExit))
|
||||
require.Equal(true, lo.FromPtr(c.Transport.TLS.Enable))
|
||||
require.Equal(true, lo.FromPtr(c.Transport.TLS.DisableCustomTLSFirstByte))
|
||||
|
||||
43
pkg/config/v1/validation/auth.go
Normal file
43
pkg/config/v1/validation/auth.go
Normal 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
pkg/config/v1/validation/auth_test.go
Normal file
228
pkg/config/v1/validation/auth_test.go
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -146,6 +131,9 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
|
||||
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
|
||||
errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols))
|
||||
}
|
||||
if !slices.Contains(SupportedWireProtocols, c.WireProtocol) {
|
||||
errs = AppendError(errs, fmt.Errorf("invalid transport.wireProtocol, optional values are %v", SupportedWireProtocols))
|
||||
}
|
||||
return warnings, errs
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -29,6 +29,10 @@ var (
|
||||
"websocket",
|
||||
"wss",
|
||||
}
|
||||
SupportedWireProtocols = []string{
|
||||
"v1",
|
||||
"v2",
|
||||
}
|
||||
|
||||
SupportedAuthMethods = []v1.AuthMethod{
|
||||
"token",
|
||||
|
||||
@@ -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"
|
||||
@@ -37,12 +39,21 @@ func init() {
|
||||
}
|
||||
|
||||
type serverMetrics struct {
|
||||
info *ServerStatistics
|
||||
mu sync.Mutex
|
||||
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()
|
||||
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, time.Since(start))
|
||||
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) {
|
||||
@@ -77,7 +97,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
for name, data := range m.info.ProxyStatistics {
|
||||
if !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
time.Since(data.LastCloseTime) > continuousOfflineDuration {
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
||||
delete(m.info.ProxyStatistics, name)
|
||||
count++
|
||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||
@@ -121,7 +141,7 @@ func (m *serverMetrics) NewProxy(name string, proxyType string, user string, cli
|
||||
}
|
||||
proxyStats.User = user
|
||||
proxyStats.ClientID = clientID
|
||||
proxyStats.LastStartTime = time.Now()
|
||||
proxyStats.LastStartTime = m.clock.Now()
|
||||
}
|
||||
|
||||
func (m *serverMetrics) CloseProxy(name string, proxyType string) {
|
||||
@@ -131,7 +151,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
83
pkg/metrics/mem/server_test.go
Normal file
83
pkg/metrics/mem/server_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package mem
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
clocktesting "k8s.io/utils/clock/testing"
|
||||
)
|
||||
|
||||
func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start)
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
|
||||
metrics.NewProxy("proxy", "tcp", "user", "client-id")
|
||||
require.Equal(start, metrics.info.ProxyStatistics["proxy"].LastStartTime)
|
||||
|
||||
closedAt := start.Add(time.Minute)
|
||||
clk.SetTime(closedAt)
|
||||
metrics.CloseProxy("proxy", "tcp")
|
||||
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
||||
}
|
||||
|
||||
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start.Add(25 * time.Hour))
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
|
||||
Name: "proxy",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
|
||||
count, total := metrics.clearUselessInfo(24 * time.Hour)
|
||||
|
||||
require.Equal(1, count)
|
||||
require.Equal(1, total)
|
||||
require.Empty(metrics.info.ProxyStatistics)
|
||||
}
|
||||
|
||||
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start)
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
|
||||
Name: "proxy",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
metrics.runUntil(stopCh)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
close(stopCh)
|
||||
<-done
|
||||
})
|
||||
|
||||
require.Eventually(clk.HasWaiters, time.Second, time.Millisecond)
|
||||
clk.Step(8 * 24 * time.Hour)
|
||||
|
||||
require.Eventually(func() bool {
|
||||
return !metrics.hasProxyStatistics("proxy")
|
||||
}, time.Second, time.Millisecond)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) hasProxyStatistics(name string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
_, ok := m.info.ProxyStatistics[name]
|
||||
return ok
|
||||
}
|
||||
56
pkg/msg/conn_test.go
Normal file
56
pkg/msg/conn_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package msg
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
func TestConnReadWriteMsg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
protocol string
|
||||
}{
|
||||
{name: "v1", protocol: wire.ProtocolV1},
|
||||
{name: "v2", protocol: wire.ProtocolV2},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
clientConn := NewConn(client, NewReadWriter(client, tt.protocol))
|
||||
serverConn := NewConn(server, NewReadWriter(server, tt.protocol))
|
||||
|
||||
in := &Ping{PrivilegeKey: "key", Timestamp: 123}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- clientConn.WriteMsg(in)
|
||||
}()
|
||||
|
||||
out, err := serverConn.ReadMsg()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, in, out)
|
||||
require.NoError(t, <-errCh)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,90 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
type ReadWriter interface {
|
||||
ReadMsg() (Message, error)
|
||||
ReadMsgInto(Message) error
|
||||
WriteMsg(Message) error
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
net.Conn
|
||||
rw ReadWriter
|
||||
}
|
||||
|
||||
func NewConn(conn net.Conn, rw ReadWriter) *Conn {
|
||||
return &Conn{
|
||||
Conn: conn,
|
||||
rw: rw,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) ReadMsg() (Message, error) {
|
||||
return c.rw.ReadMsg()
|
||||
}
|
||||
|
||||
func (c *Conn) ReadMsgInto(m Message) error {
|
||||
return c.rw.ReadMsgInto(m)
|
||||
}
|
||||
|
||||
func (c *Conn) WriteMsg(m Message) error {
|
||||
return c.rw.WriteMsg(m)
|
||||
}
|
||||
|
||||
func (c *Conn) Context() context.Context {
|
||||
if getter, ok := c.Conn.(interface{ Context() context.Context }); ok {
|
||||
return getter.Context()
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func (c *Conn) WithContext(ctx context.Context) {
|
||||
if setter, ok := c.Conn.(interface{ WithContext(context.Context) }); ok {
|
||||
setter.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
type V1ReadWriter struct {
|
||||
rw io.ReadWriter
|
||||
}
|
||||
|
||||
func NewV1ReadWriter(rw io.ReadWriter) ReadWriter {
|
||||
return &V1ReadWriter{rw: rw}
|
||||
}
|
||||
|
||||
// NewReadWriter wraps rw with the message codec for the selected wire protocol.
|
||||
// An empty protocol keeps the historical v1 behavior for tests and older call sites.
|
||||
func NewReadWriter(rw io.ReadWriter, wireProtocol string) ReadWriter {
|
||||
switch wireProtocol {
|
||||
case wire.ProtocolV2:
|
||||
return NewV2ReadWriter(rw)
|
||||
case "", wire.ProtocolV1:
|
||||
return NewV1ReadWriter(rw)
|
||||
default:
|
||||
return NewV1ReadWriter(rw)
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *V1ReadWriter) ReadMsg() (Message, error) {
|
||||
return ReadMsg(rw.rw)
|
||||
}
|
||||
|
||||
func (rw *V1ReadWriter) ReadMsgInto(m Message) error {
|
||||
return ReadMsgInto(rw.rw, m)
|
||||
}
|
||||
|
||||
func (rw *V1ReadWriter) WriteMsg(m Message) error {
|
||||
return WriteMsg(rw.rw, m)
|
||||
}
|
||||
|
||||
func AsyncHandler(f func(Message)) func(Message) {
|
||||
return func(m Message) {
|
||||
go f(m)
|
||||
@@ -27,15 +107,14 @@ func AsyncHandler(f func(Message)) func(Message) {
|
||||
|
||||
// Dispatcher is used to send messages to net.Conn or register handlers for messages read from net.Conn.
|
||||
type Dispatcher struct {
|
||||
rw io.ReadWriter
|
||||
rw ReadWriter
|
||||
|
||||
sendCh chan Message
|
||||
doneCh chan struct{}
|
||||
msgHandlers map[reflect.Type]func(Message)
|
||||
defaultHandler func(Message)
|
||||
sendCh chan Message
|
||||
doneCh chan struct{}
|
||||
msgHandlers map[reflect.Type]func(Message)
|
||||
}
|
||||
|
||||
func NewDispatcher(rw io.ReadWriter) *Dispatcher {
|
||||
func NewDispatcher(rw ReadWriter) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
rw: rw,
|
||||
sendCh: make(chan Message, 100),
|
||||
@@ -56,14 +135,14 @@ func (d *Dispatcher) sendLoop() {
|
||||
case <-d.doneCh:
|
||||
return
|
||||
case m := <-d.sendCh:
|
||||
_ = WriteMsg(d.rw, m)
|
||||
_ = d.rw.WriteMsg(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) readLoop() {
|
||||
for {
|
||||
m, err := ReadMsg(d.rw)
|
||||
m, err := d.rw.ReadMsg()
|
||||
if err != nil {
|
||||
close(d.doneCh)
|
||||
return
|
||||
@@ -71,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,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
|
||||
}
|
||||
|
||||
@@ -20,24 +20,24 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
TypeLogin = 'o'
|
||||
TypeLoginResp = '1'
|
||||
TypeNewProxy = 'p'
|
||||
TypeNewProxyResp = '2'
|
||||
TypeCloseProxy = 'c'
|
||||
TypeNewWorkConn = 'w'
|
||||
TypeReqWorkConn = 'r'
|
||||
TypeStartWorkConn = 's'
|
||||
TypeNewVisitorConn = 'v'
|
||||
TypeNewVisitorConnResp = '3'
|
||||
TypePing = 'h'
|
||||
TypePong = '4'
|
||||
TypeUDPPacket = 'u'
|
||||
TypeNatHoleVisitor = 'i'
|
||||
TypeNatHoleClient = 'n'
|
||||
TypeNatHoleResp = 'm'
|
||||
TypeNatHoleSid = '5'
|
||||
TypeNatHoleReport = '6'
|
||||
TypeLogin byte = 'o'
|
||||
TypeLoginResp byte = '1'
|
||||
TypeNewProxy byte = 'p'
|
||||
TypeNewProxyResp byte = '2'
|
||||
TypeCloseProxy byte = 'c'
|
||||
TypeNewWorkConn byte = 'w'
|
||||
TypeReqWorkConn byte = 'r'
|
||||
TypeStartWorkConn byte = 's'
|
||||
TypeNewVisitorConn byte = 'v'
|
||||
TypeNewVisitorConnResp byte = '3'
|
||||
TypePing byte = 'h'
|
||||
TypePong byte = '4'
|
||||
TypeUDPPacket byte = 'u'
|
||||
TypeNatHoleVisitor byte = 'i'
|
||||
TypeNatHoleClient byte = 'n'
|
||||
TypeNatHoleResp byte = 'm'
|
||||
TypeNatHoleSid byte = '5'
|
||||
TypeNatHoleReport byte = '6'
|
||||
)
|
||||
|
||||
var msgTypeMap = map[byte]any{
|
||||
|
||||
55
pkg/msg/msg_test.go
Normal file
55
pkg/msg/msg_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package msg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestV1MessageTypeIDsAreStable(t *testing.T) {
|
||||
require.Equal(t, byte('o'), TypeLogin)
|
||||
require.Equal(t, byte('1'), TypeLoginResp)
|
||||
require.Equal(t, byte('p'), TypeNewProxy)
|
||||
require.Equal(t, byte('2'), TypeNewProxyResp)
|
||||
require.Equal(t, byte('c'), TypeCloseProxy)
|
||||
require.Equal(t, byte('w'), TypeNewWorkConn)
|
||||
require.Equal(t, byte('r'), TypeReqWorkConn)
|
||||
require.Equal(t, byte('s'), TypeStartWorkConn)
|
||||
require.Equal(t, byte('v'), TypeNewVisitorConn)
|
||||
require.Equal(t, byte('3'), TypeNewVisitorConnResp)
|
||||
require.Equal(t, byte('h'), TypePing)
|
||||
require.Equal(t, byte('4'), TypePong)
|
||||
require.Equal(t, byte('u'), TypeUDPPacket)
|
||||
require.Equal(t, byte('i'), TypeNatHoleVisitor)
|
||||
require.Equal(t, byte('n'), TypeNatHoleClient)
|
||||
require.Equal(t, byte('m'), TypeNatHoleResp)
|
||||
require.Equal(t, byte('5'), TypeNatHoleSid)
|
||||
require.Equal(t, byte('6'), TypeNatHoleReport)
|
||||
}
|
||||
|
||||
func TestMessageTypeMapIsCompleteAndUnique(t *testing.T) {
|
||||
require.Len(t, msgTypeMap, 18)
|
||||
|
||||
msgTypes := make(map[reflect.Type]struct{}, len(msgTypeMap))
|
||||
|
||||
for _, m := range msgTypeMap {
|
||||
msgType := reflect.TypeOf(m)
|
||||
require.NotContains(t, msgTypes, msgType)
|
||||
msgTypes[msgType] = struct{}{}
|
||||
}
|
||||
}
|
||||
192
pkg/msg/wire_v2.go
Normal file
192
pkg/msg/wire_v2.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package msg
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
const (
|
||||
V2TypeLogin uint16 = 1
|
||||
V2TypeLoginResp uint16 = 2
|
||||
V2TypeNewProxy uint16 = 3
|
||||
V2TypeNewProxyResp uint16 = 4
|
||||
V2TypeCloseProxy uint16 = 5
|
||||
V2TypeNewWorkConn uint16 = 6
|
||||
V2TypeReqWorkConn uint16 = 7
|
||||
V2TypeStartWorkConn uint16 = 8
|
||||
V2TypeNewVisitorConn uint16 = 9
|
||||
V2TypeNewVisitorConnResp uint16 = 10
|
||||
V2TypePing uint16 = 11
|
||||
V2TypePong uint16 = 12
|
||||
V2TypeUDPPacket uint16 = 13
|
||||
V2TypeNatHoleVisitor uint16 = 14
|
||||
V2TypeNatHoleClient uint16 = 15
|
||||
V2TypeNatHoleResp uint16 = 16
|
||||
V2TypeNatHoleSid uint16 = 17
|
||||
V2TypeNatHoleReport uint16 = 18
|
||||
)
|
||||
|
||||
var v2MsgTypeMap = map[uint16]any{
|
||||
V2TypeLogin: Login{},
|
||||
V2TypeLoginResp: LoginResp{},
|
||||
V2TypeNewProxy: NewProxy{},
|
||||
V2TypeNewProxyResp: NewProxyResp{},
|
||||
V2TypeCloseProxy: CloseProxy{},
|
||||
V2TypeNewWorkConn: NewWorkConn{},
|
||||
V2TypeReqWorkConn: ReqWorkConn{},
|
||||
V2TypeStartWorkConn: StartWorkConn{},
|
||||
V2TypeNewVisitorConn: NewVisitorConn{},
|
||||
V2TypeNewVisitorConnResp: NewVisitorConnResp{},
|
||||
V2TypePing: Ping{},
|
||||
V2TypePong: Pong{},
|
||||
V2TypeUDPPacket: UDPPacket{},
|
||||
V2TypeNatHoleVisitor: NatHoleVisitor{},
|
||||
V2TypeNatHoleClient: NatHoleClient{},
|
||||
V2TypeNatHoleResp: NatHoleResp{},
|
||||
V2TypeNatHoleSid: NatHoleSid{},
|
||||
V2TypeNatHoleReport: NatHoleReport{},
|
||||
}
|
||||
|
||||
var v2MsgReflectTypeMap, v2MsgTypeIDMap = buildV2MsgTypeMaps()
|
||||
|
||||
func buildV2MsgTypeMaps() (map[uint16]reflect.Type, map[reflect.Type]uint16) {
|
||||
reflectTypeMap := make(map[uint16]reflect.Type, len(v2MsgTypeMap))
|
||||
typeIDMap := make(map[reflect.Type]uint16, len(v2MsgTypeMap))
|
||||
for typeID, m := range v2MsgTypeMap {
|
||||
t := reflect.TypeOf(m)
|
||||
reflectTypeMap[typeID] = t
|
||||
typeIDMap[t] = typeID
|
||||
}
|
||||
return reflectTypeMap, typeIDMap
|
||||
}
|
||||
|
||||
type V2ReadWriter struct {
|
||||
conn *wire.Conn
|
||||
}
|
||||
|
||||
func NewV2ReadWriter(rw io.ReadWriter) *V2ReadWriter {
|
||||
return NewV2ReadWriterWithConn(wire.NewConn(rw))
|
||||
}
|
||||
|
||||
func NewV2ReadWriterWithConn(conn *wire.Conn) *V2ReadWriter {
|
||||
return &V2ReadWriter{conn: conn}
|
||||
}
|
||||
|
||||
func (rw *V2ReadWriter) WireConn() *wire.Conn {
|
||||
return rw.conn
|
||||
}
|
||||
|
||||
func (rw *V2ReadWriter) ReadMsg() (Message, error) {
|
||||
f, err := rw.conn.ReadFrame()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return DecodeV2MessageFrame(f)
|
||||
}
|
||||
|
||||
func (rw *V2ReadWriter) ReadMsgInto(m Message) error {
|
||||
f, err := rw.conn.ReadFrame()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return DecodeV2MessageFrameInto(f, m)
|
||||
}
|
||||
|
||||
func (rw *V2ReadWriter) WriteMsg(m Message) error {
|
||||
f, err := EncodeV2MessageFrame(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rw.conn.WriteFrame(f)
|
||||
}
|
||||
|
||||
func DecodeV2MessageFrame(f *wire.Frame) (Message, error) {
|
||||
if f.Type != wire.FrameTypeMessage {
|
||||
return nil, fmt.Errorf("unexpected frame type %d, want %d", f.Type, wire.FrameTypeMessage)
|
||||
}
|
||||
if len(f.Payload) < 2 {
|
||||
return nil, fmt.Errorf("message frame payload too short")
|
||||
}
|
||||
typeID := binary.BigEndian.Uint16(f.Payload[:2])
|
||||
t, ok := v2MsgReflectTypeMap[typeID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown v2 message type %d", typeID)
|
||||
}
|
||||
m := reflect.New(t).Interface()
|
||||
if err := json.Unmarshal(f.Payload[2:], m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func DecodeV2MessageFrameInto(f *wire.Frame, out Message) error {
|
||||
if f.Type != wire.FrameTypeMessage {
|
||||
return fmt.Errorf("unexpected frame type %d, want %d", f.Type, wire.FrameTypeMessage)
|
||||
}
|
||||
if len(f.Payload) < 2 {
|
||||
return fmt.Errorf("message frame payload too short")
|
||||
}
|
||||
|
||||
typeID := binary.BigEndian.Uint16(f.Payload[:2])
|
||||
outType := reflect.TypeOf(out)
|
||||
if outType == nil || outType.Kind() != reflect.Pointer {
|
||||
return fmt.Errorf("message target must be a pointer")
|
||||
}
|
||||
elemType := outType.Elem()
|
||||
expectedTypeID, ok := v2MsgTypeIDMap[elemType]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown v2 message type %s", elemType.String())
|
||||
}
|
||||
if typeID != expectedTypeID {
|
||||
actualType, ok := v2MsgReflectTypeMap[typeID]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown v2 message type %d", typeID)
|
||||
}
|
||||
return fmt.Errorf("unexpected message type %s, want %s", actualType.String(), elemType.String())
|
||||
}
|
||||
return json.Unmarshal(f.Payload[2:], out)
|
||||
}
|
||||
|
||||
func EncodeV2MessageFrame(m Message) (*wire.Frame, error) {
|
||||
t := reflect.TypeOf(m)
|
||||
if t == nil {
|
||||
return nil, fmt.Errorf("nil message")
|
||||
}
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
typeID, ok := v2MsgTypeIDMap[t]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown v2 message type %s", t.String())
|
||||
}
|
||||
content, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := make([]byte, 2+len(content))
|
||||
binary.BigEndian.PutUint16(payload[:2], typeID)
|
||||
copy(payload[2:], content)
|
||||
return &wire.Frame{
|
||||
Type: wire.FrameTypeMessage,
|
||||
Payload: payload,
|
||||
}, nil
|
||||
}
|
||||
140
pkg/msg/wire_v2_test.go
Normal file
140
pkg/msg/wire_v2_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package msg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
func TestV2ReadWriterRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
rw := NewV2ReadWriter(&buf)
|
||||
|
||||
in := &Login{
|
||||
Version: "test-version",
|
||||
RunID: "run-id",
|
||||
User: "user",
|
||||
}
|
||||
require.NoError(t, rw.WriteMsg(in))
|
||||
|
||||
out, err := rw.ReadMsg()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, in, out)
|
||||
}
|
||||
|
||||
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)
|
||||
require.Equal(t, uint16(3), V2TypeNewProxy)
|
||||
require.Equal(t, uint16(4), V2TypeNewProxyResp)
|
||||
require.Equal(t, uint16(5), V2TypeCloseProxy)
|
||||
require.Equal(t, uint16(6), V2TypeNewWorkConn)
|
||||
require.Equal(t, uint16(7), V2TypeReqWorkConn)
|
||||
require.Equal(t, uint16(8), V2TypeStartWorkConn)
|
||||
require.Equal(t, uint16(9), V2TypeNewVisitorConn)
|
||||
require.Equal(t, uint16(10), V2TypeNewVisitorConnResp)
|
||||
require.Equal(t, uint16(11), V2TypePing)
|
||||
require.Equal(t, uint16(12), V2TypePong)
|
||||
require.Equal(t, uint16(13), V2TypeUDPPacket)
|
||||
require.Equal(t, uint16(14), V2TypeNatHoleVisitor)
|
||||
require.Equal(t, uint16(15), V2TypeNatHoleClient)
|
||||
require.Equal(t, uint16(16), V2TypeNatHoleResp)
|
||||
require.Equal(t, uint16(17), V2TypeNatHoleSid)
|
||||
require.Equal(t, uint16(18), V2TypeNatHoleReport)
|
||||
}
|
||||
|
||||
func TestV2MessageFrameEncoding(t *testing.T) {
|
||||
frame, err := EncodeV2MessageFrame(&ReqWorkConn{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||
require.Len(t, frame.Payload, 4)
|
||||
require.Equal(t, V2TypeReqWorkConn, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||
|
||||
out, err := DecodeV2MessageFrame(frame)
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &ReqWorkConn{}, out)
|
||||
}
|
||||
|
||||
func TestDecodeV2MessageFrameInto(t *testing.T) {
|
||||
in := &StartWorkConn{ProxyName: "tcp", SrcAddr: "127.0.0.1", SrcPort: 1234}
|
||||
frame, err := EncodeV2MessageFrame(in)
|
||||
require.NoError(t, err)
|
||||
|
||||
var out StartWorkConn
|
||||
require.NoError(t, DecodeV2MessageFrameInto(frame, &out))
|
||||
require.Equal(t, *in, out)
|
||||
}
|
||||
|
||||
func TestDecodeV2MessageFrameRejectsInvalidFrame(t *testing.T) {
|
||||
_, err := DecodeV2MessageFrame(&wire.Frame{Type: wire.FrameTypeClientHello})
|
||||
require.ErrorContains(t, err, "unexpected frame type")
|
||||
|
||||
_, err = DecodeV2MessageFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: []byte{0}})
|
||||
require.ErrorContains(t, err, "payload too short")
|
||||
|
||||
payload := make([]byte, 4)
|
||||
binary.BigEndian.PutUint16(payload[:2], 65535)
|
||||
copy(payload[2:], []byte("{}"))
|
||||
_, err = DecodeV2MessageFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: payload})
|
||||
require.ErrorContains(t, err, "unknown v2 message type")
|
||||
}
|
||||
|
||||
func TestDecodeV2MessageFrameIntoRejectsWrongTarget(t *testing.T) {
|
||||
frame, err := EncodeV2MessageFrame(&ReqWorkConn{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var out StartWorkConn
|
||||
err = DecodeV2MessageFrameInto(frame, &out)
|
||||
require.ErrorContains(t, err, "unexpected message type")
|
||||
|
||||
err = DecodeV2MessageFrameInto(frame, StartWorkConn{})
|
||||
require.ErrorContains(t, err, "must be a pointer")
|
||||
}
|
||||
|
||||
func TestEncodeV2MessageFrameRejectsUnknownMessage(t *testing.T) {
|
||||
_, err := EncodeV2MessageFrame(struct{}{})
|
||||
require.ErrorContains(t, err, "unknown v2 message type")
|
||||
}
|
||||
@@ -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
pkg/nathole/analysis_test.go
Normal file
33
pkg/nathole/analysis_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/pion/stun/v2"
|
||||
"github.com/pion/stun/v3"
|
||||
)
|
||||
|
||||
var responseTimeout = 3 * time.Second
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/fatedier/golib/crypto"
|
||||
"github.com/pion/stun/v2"
|
||||
"github.com/pion/stun/v3"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
pkg/plugin/client/http_common.go
Normal file
126
pkg/plugin/client/http_common.go
Normal 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
|
||||
}
|
||||
@@ -45,6 +45,8 @@ type HTTPProxy struct {
|
||||
s *http.Server
|
||||
}
|
||||
|
||||
const httpProxyReadHeaderTimeout = 60 * time.Second
|
||||
|
||||
func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||
opts := options.(*v1.HTTPProxyPluginOptions)
|
||||
listener := NewProxyListener()
|
||||
@@ -56,7 +58,7 @@ func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin
|
||||
|
||||
hp.s = &http.Server{
|
||||
Handler: hp,
|
||||
ReadHeaderTimeout: 60 * time.Second,
|
||||
ReadHeaderTimeout: httpProxyReadHeaderTimeout,
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -73,16 +75,19 @@ func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
||||
|
||||
sc, rd := libnet.NewSharedConn(wrapConn)
|
||||
firstBytes := make([]byte, 7)
|
||||
_, err := rd.Read(firstBytes)
|
||||
firstBytes := make([]byte, len(http.MethodConnect))
|
||||
_ = wrapConn.SetReadDeadline(time.Now().Add(httpProxyReadHeaderTimeout))
|
||||
_, err := io.ReadFull(rd, firstBytes)
|
||||
if err != nil {
|
||||
_ = wrapConn.SetReadDeadline(time.Time{})
|
||||
wrapConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if strings.ToUpper(string(firstBytes)) == "CONNECT" {
|
||||
if strings.EqualFold(string(firstBytes), http.MethodConnect) {
|
||||
bufRd := bufio.NewReader(sc)
|
||||
request, err := http.ReadRequest(bufRd)
|
||||
_ = wrapConn.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
wrapConn.Close()
|
||||
return
|
||||
@@ -91,6 +96,7 @@ func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = wrapConn.SetReadDeadline(time.Time{})
|
||||
_ = hp.l.PutConn(sc)
|
||||
}
|
||||
|
||||
@@ -107,13 +113,7 @@ func (hp *HTTPProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == http.MethodConnect {
|
||||
// deprecated
|
||||
// Connect request is handled in Handle function.
|
||||
hp.ConnectHandler(rw, req)
|
||||
} else {
|
||||
hp.HTTPHandler(rw, req)
|
||||
}
|
||||
hp.HTTPHandler(rw, req)
|
||||
}
|
||||
|
||||
func (hp *HTTPProxy) HTTPHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -135,33 +135,6 @@ func (hp *HTTPProxy) HTTPHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// deprecated
|
||||
// Hijack needs to SetReadDeadline on the Conn of the request, but if we use stream compression here,
|
||||
// we may always get i/o timeout error.
|
||||
func (hp *HTTPProxy) ConnectHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
hj, ok := rw.(http.Hijacker)
|
||||
if !ok {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
client, _, err := hj.Hijack()
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
remote, err := net.Dial("tcp", req.URL.Host)
|
||||
if err != nil {
|
||||
http.Error(rw, "Failed", http.StatusBadRequest)
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
_, _ = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
|
||||
|
||||
go libio.Join(remote, client)
|
||||
}
|
||||
|
||||
func (hp *HTTPProxy) Auth(req *http.Request) bool {
|
||||
if hp.opts.HTTPUser == "" && hp.opts.HTTPPassword == "" {
|
||||
return true
|
||||
|
||||
107
pkg/plugin/client/http_proxy_test.go
Normal file
107
pkg/plugin/client/http_proxy_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !frps
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestHTTPProxyHandleFragmentedConnectMethod(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(err)
|
||||
defer ln.Close()
|
||||
|
||||
const payload = "ping"
|
||||
echoErr := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
echoErr <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
buf := make([]byte, len(payload))
|
||||
if _, err = io.ReadFull(conn, buf); err != nil {
|
||||
echoErr <- err
|
||||
return
|
||||
}
|
||||
if string(buf) != payload {
|
||||
echoErr <- fmt.Errorf("unexpected payload %q", string(buf))
|
||||
return
|
||||
}
|
||||
_, err = conn.Write([]byte("echo:" + payload))
|
||||
echoErr <- err
|
||||
}()
|
||||
|
||||
hp := &HTTPProxy{
|
||||
opts: &v1.HTTPProxyPluginOptions{},
|
||||
l: NewProxyListener(),
|
||||
}
|
||||
|
||||
clientConn, serverConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
go hp.Handle(context.Background(), &ConnectionInfo{
|
||||
Conn: serverConn,
|
||||
UnderlyingConn: serverConn,
|
||||
})
|
||||
|
||||
require.NoError(clientConn.SetDeadline(time.Now().Add(5 * time.Second)))
|
||||
|
||||
targetAddr := ln.Addr().String()
|
||||
req := "CONNECT " + targetAddr + " HTTP/1.1\r\nHost: " + targetAddr + "\r\n\r\n"
|
||||
_, err = clientConn.Write([]byte("CON"))
|
||||
require.NoError(err)
|
||||
_, err = clientConn.Write([]byte(req[len("CON"):]))
|
||||
require.NoError(err)
|
||||
|
||||
rd := bufio.NewReader(clientConn)
|
||||
status, err := rd.ReadString('\n')
|
||||
require.NoError(err)
|
||||
require.Equal("HTTP/1.1 200 OK\r\n", status)
|
||||
line, err := rd.ReadString('\n')
|
||||
require.NoError(err)
|
||||
require.Equal("\r\n", line)
|
||||
|
||||
_, err = clientConn.Write([]byte(payload))
|
||||
require.NoError(err)
|
||||
|
||||
got := make([]byte, len("echo:"+payload))
|
||||
_, err = io.ReadFull(rd, got)
|
||||
require.NoError(err)
|
||||
require.Equal("echo:"+payload, string(got))
|
||||
|
||||
select {
|
||||
case err := <-echoErr:
|
||||
require.NoError(err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for echo server")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
60
pkg/plugin/client/internal/httpsserver/server.go
Normal file
60
pkg/plugin/client/internal/httpsserver/server.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
pkg/plugin/server/manager_test.go
Normal file
336
pkg/plugin/server/manager_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
197
pkg/proto/wire/crypto.go
Normal file
197
pkg/proto/wire/crypto.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package wire
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash"
|
||||
"runtime"
|
||||
|
||||
"golang.org/x/sys/cpu"
|
||||
)
|
||||
|
||||
const (
|
||||
AEADAlgorithmAES256GCM = "aes-256-gcm"
|
||||
AEADAlgorithmXChaCha20Poly1305 = "xchacha20-poly1305"
|
||||
|
||||
CryptoRandomSize = 32
|
||||
|
||||
cryptoTranscriptLabel = "frp wire v2 crypto transcript"
|
||||
)
|
||||
|
||||
var supportedAEADAlgorithms = []string{
|
||||
AEADAlgorithmAES256GCM,
|
||||
AEADAlgorithmXChaCha20Poly1305,
|
||||
}
|
||||
|
||||
type CryptoContext struct {
|
||||
Algorithm string
|
||||
TranscriptHash []byte
|
||||
}
|
||||
|
||||
func NewClientHello(bootstrap BootstrapInfo) (ClientHello, error) {
|
||||
clientRandom, err := newCryptoRandom()
|
||||
if err != nil {
|
||||
return ClientHello{}, err
|
||||
}
|
||||
return clientHelloWithCryptoRandom(bootstrap, clientRandom), nil
|
||||
}
|
||||
|
||||
func NewServerHello(clientHello ClientHello) (ServerHello, error) {
|
||||
if err := ValidateClientHello(clientHello); err != nil {
|
||||
return ServerHello{}, err
|
||||
}
|
||||
algorithm, ok := SelectAEADAlgorithm(clientHello.Capabilities.Crypto.Algorithms)
|
||||
if !ok {
|
||||
return ServerHello{}, fmt.Errorf("no supported crypto algorithm")
|
||||
}
|
||||
serverRandom, err := newCryptoRandom()
|
||||
if err != nil {
|
||||
return ServerHello{}, err
|
||||
}
|
||||
return ServerHello{
|
||||
Selected: ServerSelection{
|
||||
Message: MessageSelection{
|
||||
Codec: MessageCodecJSON,
|
||||
},
|
||||
Crypto: CryptoSelection{
|
||||
Algorithm: algorithm,
|
||||
ServerRandom: serverRandom,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ValidateCryptoCapabilities(c CryptoCapabilities) error {
|
||||
if len(c.ClientRandom) != CryptoRandomSize {
|
||||
return fmt.Errorf("invalid crypto client random length %d, want %d", len(c.ClientRandom), CryptoRandomSize)
|
||||
}
|
||||
if _, ok := SelectAEADAlgorithm(c.Algorithms); !ok {
|
||||
return fmt.Errorf("no supported crypto algorithm")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHello) error {
|
||||
if serverHello.Selected.Message.Codec != MessageCodecJSON {
|
||||
return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec)
|
||||
}
|
||||
cryptoSelection := serverHello.Selected.Crypto
|
||||
if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) {
|
||||
return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm)
|
||||
}
|
||||
if !Supports(clientHello.Capabilities.Crypto.Algorithms, cryptoSelection.Algorithm) {
|
||||
return fmt.Errorf("selected crypto algorithm was not advertised by client: %s", cryptoSelection.Algorithm)
|
||||
}
|
||||
if len(cryptoSelection.ServerRandom) != CryptoRandomSize {
|
||||
return fmt.Errorf("invalid crypto server random length %d, want %d", len(cryptoSelection.ServerRandom), CryptoRandomSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext {
|
||||
return &CryptoContext{
|
||||
Algorithm: algorithm,
|
||||
TranscriptHash: HashCryptoTranscript(clientHelloPayload, serverHelloPayload),
|
||||
}
|
||||
}
|
||||
|
||||
func NewClientCryptoContext(clientHelloPayload, serverHelloPayload []byte) (*CryptoContext, error) {
|
||||
var clientHello ClientHello
|
||||
if err := json.Unmarshal(clientHelloPayload, &clientHello); err != nil {
|
||||
return nil, fmt.Errorf("decode ClientHello transcript: %w", err)
|
||||
}
|
||||
var serverHello ServerHello
|
||||
if err := json.Unmarshal(serverHelloPayload, &serverHello); err != nil {
|
||||
return nil, fmt.Errorf("decode ServerHello transcript: %w", err)
|
||||
}
|
||||
if err := ValidateServerHelloForClient(clientHello, serverHello); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewCryptoContext(serverHello.Selected.Crypto.Algorithm, clientHelloPayload, serverHelloPayload), nil
|
||||
}
|
||||
|
||||
func HashCryptoTranscript(clientHelloPayload, serverHelloPayload []byte) []byte {
|
||||
h := sha256.New()
|
||||
_, _ = h.Write([]byte(cryptoTranscriptLabel))
|
||||
writeCryptoTranscriptPart(h, "client hello", clientHelloPayload)
|
||||
writeCryptoTranscriptPart(h, "server hello", serverHelloPayload)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func writeCryptoTranscriptPart(h hash.Hash, label string, payload []byte) {
|
||||
var length [8]byte
|
||||
binary.BigEndian.PutUint64(length[:], uint64(len(payload)))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(label))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write(length[:])
|
||||
_, _ = h.Write(payload)
|
||||
}
|
||||
|
||||
func PreferredAEADAlgorithms() []string {
|
||||
if hasFastAESGCM() {
|
||||
return []string{AEADAlgorithmAES256GCM, AEADAlgorithmXChaCha20Poly1305}
|
||||
}
|
||||
return []string{AEADAlgorithmXChaCha20Poly1305, AEADAlgorithmAES256GCM}
|
||||
}
|
||||
|
||||
func SelectAEADAlgorithm(clientAlgorithms []string) (string, bool) {
|
||||
for _, algorithm := range clientAlgorithms {
|
||||
if IsSupportedAEADAlgorithm(algorithm) {
|
||||
return algorithm, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func IsSupportedAEADAlgorithm(algorithm string) bool {
|
||||
return Supports(supportedAEADAlgorithms, algorithm)
|
||||
}
|
||||
|
||||
func newCryptoRandom() ([]byte, error) {
|
||||
b := make([]byte, CryptoRandomSize)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("generate crypto random: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func hasFastAESGCM() bool {
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return cpu.X86.HasAES &&
|
||||
cpu.X86.HasPCLMULQDQ &&
|
||||
cpu.X86.HasSSE41 &&
|
||||
cpu.X86.HasSSSE3
|
||||
case "arm64":
|
||||
return cpu.ARM64.HasAES && cpu.ARM64.HasPMULL
|
||||
case "s390x":
|
||||
return cpu.S390X.HasAES &&
|
||||
cpu.S390X.HasAESCTR &&
|
||||
cpu.S390X.HasGHASH
|
||||
case "ppc64", "ppc64le":
|
||||
// Go's ppc64/ppc64le port targets POWER8+, which has AES instructions;
|
||||
// x/sys/cpu does not expose a PPC64 AES feature flag.
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
246
pkg/proto/wire/wire.go
Normal file
246
pkg/proto/wire/wire.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package wire
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"slices"
|
||||
|
||||
libnet "github.com/fatedier/golib/net"
|
||||
)
|
||||
|
||||
const (
|
||||
ProtocolV1 = "v1"
|
||||
ProtocolV2 = "v2"
|
||||
|
||||
WireVersionV2 = 2
|
||||
|
||||
FrameTypeClientHello uint16 = 1
|
||||
FrameTypeServerHello uint16 = 2
|
||||
FrameTypeMessage uint16 = 16
|
||||
|
||||
MessageCodecJSON = "json"
|
||||
DefaultMaxFramePayloadSize = 64 * 1024
|
||||
|
||||
MagicV2 = "FRP\x00\x02\r\n"
|
||||
)
|
||||
|
||||
type Frame struct {
|
||||
Type uint16
|
||||
Flags uint16
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
rw io.ReadWriter
|
||||
maxFramePayloadSize uint32
|
||||
}
|
||||
|
||||
func NewConn(rw io.ReadWriter) *Conn {
|
||||
return &Conn{
|
||||
rw: rw,
|
||||
maxFramePayloadSize: DefaultMaxFramePayloadSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) ReadFrame() (*Frame, error) {
|
||||
header := make([]byte, 8)
|
||||
if _, err := io.ReadFull(c.rw, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
frameType := binary.BigEndian.Uint16(header[0:2])
|
||||
flags := binary.BigEndian.Uint16(header[2:4])
|
||||
length := binary.BigEndian.Uint32(header[4:8])
|
||||
if flags != 0 {
|
||||
return nil, fmt.Errorf("unsupported frame flags: %d", flags)
|
||||
}
|
||||
if length > c.maxFramePayloadSize {
|
||||
return nil, fmt.Errorf("frame payload length %d exceeds limit %d", length, c.maxFramePayloadSize)
|
||||
}
|
||||
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(c.rw, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Frame{
|
||||
Type: frameType,
|
||||
Flags: flags,
|
||||
Payload: payload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Conn) WriteFrame(f *Frame) error {
|
||||
if f.Flags != 0 {
|
||||
return fmt.Errorf("unsupported frame flags: %d", f.Flags)
|
||||
}
|
||||
if len(f.Payload) > int(c.maxFramePayloadSize) {
|
||||
return fmt.Errorf("frame payload length %d exceeds limit %d", len(f.Payload), c.maxFramePayloadSize)
|
||||
}
|
||||
|
||||
header := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(header[0:2], f.Type)
|
||||
binary.BigEndian.PutUint16(header[2:4], f.Flags)
|
||||
binary.BigEndian.PutUint32(header[4:8], uint32(len(f.Payload)))
|
||||
if _, err := c.rw.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.rw.Write(f.Payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Conn) ReadJSONFrame(frameType uint16, out any) error {
|
||||
f, err := c.ReadFrame()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.Type != frameType {
|
||||
return fmt.Errorf("unexpected frame type %d, want %d", f.Type, frameType)
|
||||
}
|
||||
return c.UnmarshalFrame(f, out)
|
||||
}
|
||||
|
||||
func (c *Conn) UnmarshalFrame(f *Frame, out any) error {
|
||||
return json.Unmarshal(f.Payload, out)
|
||||
}
|
||||
|
||||
func NewJSONFrame(frameType uint16, in any) (*Frame, error) {
|
||||
payload, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Frame{
|
||||
Type: frameType,
|
||||
Payload: payload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Conn) WriteJSONFrame(frameType uint16, in any) error {
|
||||
f, err := NewJSONFrame(frameType, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.WriteFrame(f)
|
||||
}
|
||||
|
||||
func WriteMagic(w io.Writer) error {
|
||||
_, err := io.WriteString(w, MagicV2)
|
||||
return err
|
||||
}
|
||||
|
||||
func WriteMagicIfV2(w io.Writer, wireProtocol string) error {
|
||||
if wireProtocol != ProtocolV2 {
|
||||
return nil
|
||||
}
|
||||
return WriteMagic(w)
|
||||
}
|
||||
|
||||
func CheckMagic(conn net.Conn) (out net.Conn, isV2 bool, err error) {
|
||||
sharedConn, r := libnet.NewSharedConnSize(conn, len(MagicV2))
|
||||
buf := make([]byte, len(MagicV2))
|
||||
if _, err = io.ReadFull(r, buf); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
for i := range MagicV2 {
|
||||
if buf[i] != MagicV2[i] {
|
||||
return sharedConn, false, nil
|
||||
}
|
||||
}
|
||||
return conn, true, nil
|
||||
}
|
||||
|
||||
type BootstrapInfo struct {
|
||||
Transport string `json:"transport,omitempty"`
|
||||
TLS bool `json:"tls,omitempty"`
|
||||
TCPMux bool `json:"tcpMux,omitempty"`
|
||||
}
|
||||
|
||||
type ClientHello struct {
|
||||
Bootstrap BootstrapInfo `json:"bootstrap,omitempty"`
|
||||
Capabilities ClientCapabilities `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
type ClientCapabilities struct {
|
||||
Message MessageCapabilities `json:"message,omitempty"`
|
||||
Crypto CryptoCapabilities `json:"crypto,omitempty"`
|
||||
}
|
||||
|
||||
type MessageCapabilities struct {
|
||||
Codecs []string `json:"codecs,omitempty"`
|
||||
}
|
||||
|
||||
type CryptoCapabilities struct {
|
||||
Algorithms []string `json:"algorithms,omitempty"`
|
||||
ClientRandom []byte `json:"clientRandom,omitempty"`
|
||||
}
|
||||
|
||||
type ServerHello struct {
|
||||
Selected ServerSelection `json:"selected,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ServerSelection struct {
|
||||
Message MessageSelection `json:"message,omitempty"`
|
||||
Crypto CryptoSelection `json:"crypto,omitempty"`
|
||||
}
|
||||
|
||||
type MessageSelection struct {
|
||||
Codec string `json:"codec,omitempty"`
|
||||
}
|
||||
|
||||
type CryptoSelection struct {
|
||||
Algorithm string `json:"algorithm,omitempty"`
|
||||
ServerRandom []byte `json:"serverRandom,omitempty"`
|
||||
}
|
||||
|
||||
func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) ClientHello {
|
||||
return ClientHello{
|
||||
Bootstrap: bootstrap,
|
||||
Capabilities: ClientCapabilities{
|
||||
Message: MessageCapabilities{
|
||||
Codecs: []string{MessageCodecJSON},
|
||||
},
|
||||
Crypto: CryptoCapabilities{
|
||||
Algorithms: PreferredAEADAlgorithms(),
|
||||
ClientRandom: clientRandom,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultServerHello() ServerHello {
|
||||
return ServerHello{
|
||||
Selected: ServerSelection{
|
||||
Message: MessageSelection{
|
||||
Codec: MessageCodecJSON,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Supports(list []string, value string) bool {
|
||||
return slices.Contains(list, value)
|
||||
}
|
||||
|
||||
func ValidateClientHello(h ClientHello) error {
|
||||
if !Supports(h.Capabilities.Message.Codecs, MessageCodecJSON) {
|
||||
return fmt.Errorf("unsupported message codec")
|
||||
}
|
||||
return ValidateCryptoCapabilities(h.Capabilities.Crypto)
|
||||
}
|
||||
233
pkg/proto/wire/wire_test.go
Normal file
233
pkg/proto/wire/wire_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package wire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFrameRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
conn := NewConn(&buf)
|
||||
|
||||
in := mustClientHello(t, BootstrapInfo{
|
||||
Transport: "tcp",
|
||||
TLS: true,
|
||||
TCPMux: true,
|
||||
})
|
||||
require.NoError(t, conn.WriteJSONFrame(FrameTypeClientHello, in))
|
||||
|
||||
var out ClientHello
|
||||
require.NoError(t, conn.ReadJSONFrame(FrameTypeClientHello, &out))
|
||||
require.Equal(t, in, out)
|
||||
}
|
||||
|
||||
func TestReadFrameRejectsUnsupportedFlags(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
header := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(header[0:2], FrameTypeMessage)
|
||||
binary.BigEndian.PutUint16(header[2:4], 1)
|
||||
binary.BigEndian.PutUint32(header[4:8], 0)
|
||||
buf.Write(header)
|
||||
|
||||
_, err := NewConn(&buf).ReadFrame()
|
||||
require.ErrorContains(t, err, "unsupported frame flags")
|
||||
}
|
||||
|
||||
func TestReadFrameRejectsOversizedPayload(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
header := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(header[0:2], FrameTypeMessage)
|
||||
binary.BigEndian.PutUint32(header[4:8], DefaultMaxFramePayloadSize+1)
|
||||
buf.Write(header)
|
||||
|
||||
_, err := NewConn(&buf).ReadFrame()
|
||||
require.ErrorContains(t, err, "exceeds limit")
|
||||
}
|
||||
|
||||
func TestCheckMagicV2ConsumesMagic(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer server.Close()
|
||||
|
||||
want := []byte("payload")
|
||||
go func() {
|
||||
defer client.Close()
|
||||
_, _ = client.Write(append([]byte(MagicV2), want...))
|
||||
}()
|
||||
|
||||
out, isV2, err := CheckMagic(server)
|
||||
require.NoError(t, err)
|
||||
require.True(t, isV2)
|
||||
|
||||
got := make([]byte, len(want))
|
||||
_, err = io.ReadFull(out, got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestWriteMagicIfV2(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, WriteMagicIfV2(&buf, ProtocolV1))
|
||||
require.Empty(t, buf.Bytes())
|
||||
|
||||
require.NoError(t, WriteMagicIfV2(&buf, ProtocolV2))
|
||||
require.Equal(t, []byte(MagicV2), buf.Bytes())
|
||||
}
|
||||
|
||||
func TestCheckMagicV1PreservesReadBytes(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer server.Close()
|
||||
|
||||
want := []byte("legacy payload")
|
||||
go func() {
|
||||
defer client.Close()
|
||||
_, _ = client.Write(want)
|
||||
}()
|
||||
|
||||
out, isV2, err := CheckMagic(server)
|
||||
require.NoError(t, err)
|
||||
require.False(t, isV2)
|
||||
|
||||
got, err := io.ReadAll(out)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestValidateClientHello(t *testing.T) {
|
||||
hello := mustClientHello(t, BootstrapInfo{})
|
||||
require.NoError(t, ValidateClientHello(hello))
|
||||
require.Len(t, hello.Capabilities.Crypto.ClientRandom, CryptoRandomSize)
|
||||
require.ElementsMatch(t, []string{
|
||||
AEADAlgorithmAES256GCM,
|
||||
AEADAlgorithmXChaCha20Poly1305,
|
||||
}, hello.Capabilities.Crypto.Algorithms)
|
||||
|
||||
hello.Capabilities.Message.Codecs = []string{"unknown"}
|
||||
require.ErrorContains(t, ValidateClientHello(hello), "unsupported message codec")
|
||||
}
|
||||
|
||||
func TestValidateClientHelloRejectsInvalidCrypto(t *testing.T) {
|
||||
hello := mustClientHello(t, BootstrapInfo{})
|
||||
hello.Capabilities.Crypto.ClientRandom = hello.Capabilities.Crypto.ClientRandom[:CryptoRandomSize-1]
|
||||
require.ErrorContains(t, ValidateClientHello(hello), "invalid crypto client random length")
|
||||
|
||||
hello = mustClientHello(t, BootstrapInfo{})
|
||||
hello.Capabilities.Crypto.Algorithms = []string{"unknown"}
|
||||
require.ErrorContains(t, ValidateClientHello(hello), "no supported crypto algorithm")
|
||||
}
|
||||
|
||||
func TestPreferredAEADAlgorithms(t *testing.T) {
|
||||
require.ElementsMatch(t, []string{
|
||||
AEADAlgorithmAES256GCM,
|
||||
AEADAlgorithmXChaCha20Poly1305,
|
||||
}, PreferredAEADAlgorithms())
|
||||
}
|
||||
|
||||
func TestNewServerHelloSelectsFirstSupportedAEADAlgorithm(t *testing.T) {
|
||||
hello := mustClientHello(t, BootstrapInfo{})
|
||||
hello.Capabilities.Crypto.Algorithms = []string{"future-aead", AEADAlgorithmXChaCha20Poly1305, AEADAlgorithmAES256GCM}
|
||||
|
||||
serverHello, err := NewServerHello(hello)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec)
|
||||
require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm)
|
||||
require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize)
|
||||
}
|
||||
|
||||
func TestNewClientCryptoContextValidatesServerHello(t *testing.T) {
|
||||
hello := mustClientHello(t, BootstrapInfo{})
|
||||
serverHello, err := NewServerHello(hello)
|
||||
require.NoError(t, err)
|
||||
clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
|
||||
|
||||
ctx, err := NewClientCryptoContext(clientHelloPayload, serverHelloPayload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, serverHello.Selected.Crypto.Algorithm, ctx.Algorithm)
|
||||
require.Len(t, ctx.TranscriptHash, 32)
|
||||
|
||||
tampered := serverHello
|
||||
tampered.Selected.Crypto.ServerRandom = append([]byte(nil), serverHello.Selected.Crypto.ServerRandom...)
|
||||
tampered.Selected.Crypto.ServerRandom[0] ^= 0xff
|
||||
_, tamperedServerHelloPayload := mustCryptoTranscriptPayloads(t, hello, tampered)
|
||||
tamperedCtx, err := NewClientCryptoContext(clientHelloPayload, tamperedServerHelloPayload)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, ctx.TranscriptHash, tamperedCtx.TranscriptHash)
|
||||
}
|
||||
|
||||
func TestNewCryptoContextBindsFullClientHelloPayload(t *testing.T) {
|
||||
hello := mustClientHello(t, BootstrapInfo{
|
||||
Transport: "tcp",
|
||||
TLS: true,
|
||||
TCPMux: true,
|
||||
})
|
||||
serverHello, err := NewServerHello(hello)
|
||||
require.NoError(t, err)
|
||||
clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
|
||||
|
||||
ctx := NewCryptoContext(serverHello.Selected.Crypto.Algorithm, clientHelloPayload, serverHelloPayload)
|
||||
|
||||
tamperedHello := hello
|
||||
tamperedHello.Bootstrap.TLS = false
|
||||
tamperedClientHelloPayload, _ := mustCryptoTranscriptPayloads(t, tamperedHello, serverHello)
|
||||
tamperedCtx := NewCryptoContext(serverHello.Selected.Crypto.Algorithm, tamperedClientHelloPayload, serverHelloPayload)
|
||||
require.NotEqual(t, ctx.TranscriptHash, tamperedCtx.TranscriptHash)
|
||||
}
|
||||
|
||||
func TestNewClientCryptoContextRejectsUnknownServerSelection(t *testing.T) {
|
||||
hello := mustClientHello(t, BootstrapInfo{})
|
||||
serverHello, err := NewServerHello(hello)
|
||||
require.NoError(t, err)
|
||||
|
||||
serverHello.Selected.Crypto.Algorithm = "unknown"
|
||||
clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
|
||||
_, err = NewClientCryptoContext(clientHelloPayload, serverHelloPayload)
|
||||
require.ErrorContains(t, err, "unknown selected crypto algorithm")
|
||||
}
|
||||
|
||||
func TestNewClientCryptoContextRejectsUnadvertisedServerSelection(t *testing.T) {
|
||||
hello := mustClientHello(t, BootstrapInfo{})
|
||||
hello.Capabilities.Crypto.Algorithms = []string{AEADAlgorithmAES256GCM}
|
||||
serverHello, err := NewServerHello(hello)
|
||||
require.NoError(t, err)
|
||||
|
||||
serverHello.Selected.Crypto.Algorithm = AEADAlgorithmXChaCha20Poly1305
|
||||
clientHelloPayload, serverHelloPayload := mustCryptoTranscriptPayloads(t, hello, serverHello)
|
||||
_, err = NewClientCryptoContext(clientHelloPayload, serverHelloPayload)
|
||||
require.ErrorContains(t, err, "selected crypto algorithm was not advertised by client")
|
||||
}
|
||||
|
||||
func mustClientHello(t *testing.T, bootstrap BootstrapInfo) ClientHello {
|
||||
t.Helper()
|
||||
|
||||
hello, err := NewClientHello(bootstrap)
|
||||
require.NoError(t, err)
|
||||
return hello
|
||||
}
|
||||
|
||||
func mustCryptoTranscriptPayloads(t *testing.T, hello ClientHello, serverHello ServerHello) ([]byte, []byte) {
|
||||
t.Helper()
|
||||
|
||||
clientHelloFrame, err := NewJSONFrame(FrameTypeClientHello, hello)
|
||||
require.NoError(t, err)
|
||||
serverHelloFrame, err := NewJSONFrame(FrameTypeServerHello, serverHello)
|
||||
require.NoError(t, err)
|
||||
return clientHelloFrame.Payload, serverHelloFrame.Payload
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -16,14 +16,16 @@ package net
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/crypto"
|
||||
libcrypto "github.com/fatedier/golib/crypto"
|
||||
quic "github.com/quic-go/quic-go"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
)
|
||||
@@ -133,7 +135,7 @@ type CloseNotifyConn struct {
|
||||
net.Conn
|
||||
|
||||
// 1 means closed
|
||||
closeFlag int32
|
||||
closeFlag atomic.Int32
|
||||
|
||||
closeFn func(error)
|
||||
}
|
||||
@@ -147,7 +149,7 @@ func WrapCloseNotifyConn(c net.Conn, closeFn func(error)) *CloseNotifyConn {
|
||||
}
|
||||
|
||||
func (cc *CloseNotifyConn) Close() (err error) {
|
||||
pflag := atomic.SwapInt32(&cc.closeFlag, 1)
|
||||
pflag := cc.closeFlag.Swap(1)
|
||||
if pflag == 0 {
|
||||
err = cc.Conn.Close()
|
||||
if cc.closeFn != nil {
|
||||
@@ -159,7 +161,7 @@ func (cc *CloseNotifyConn) Close() (err error) {
|
||||
|
||||
// CloseWithError closes the connection and passes the error to the close callback.
|
||||
func (cc *CloseNotifyConn) CloseWithError(err error) error {
|
||||
pflag := atomic.SwapInt32(&cc.closeFlag, 1)
|
||||
pflag := cc.closeFlag.Swap(1)
|
||||
if pflag == 0 {
|
||||
closeErr := cc.Conn.Close()
|
||||
if cc.closeFn != nil {
|
||||
@@ -173,7 +175,7 @@ func (cc *CloseNotifyConn) CloseWithError(err error) error {
|
||||
type StatsConn struct {
|
||||
net.Conn
|
||||
|
||||
closed int64 // 1 means closed
|
||||
closed atomic.Int64 // 1 means closed
|
||||
totalRead int64
|
||||
totalWrite int64
|
||||
statsFunc func(totalRead, totalWrite int64)
|
||||
@@ -199,7 +201,7 @@ func (statsConn *StatsConn) Write(p []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
func (statsConn *StatsConn) Close() (err error) {
|
||||
old := atomic.SwapInt64(&statsConn.closed, 1)
|
||||
old := statsConn.closed.Swap(1)
|
||||
if old != 1 {
|
||||
err = statsConn.Conn.Close()
|
||||
if statsConn.statsFunc != nil {
|
||||
@@ -241,8 +243,8 @@ func (conn *wrapQuicStream) Close() error {
|
||||
}
|
||||
|
||||
func NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {
|
||||
encReader := crypto.NewReader(rw, key)
|
||||
encWriter, err := crypto.NewWriter(rw, key)
|
||||
encReader := libcrypto.NewReader(rw, key)
|
||||
encWriter, err := libcrypto.NewWriter(rw, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -254,3 +256,90 @@ func NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {
|
||||
Writer: encWriter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type AEADCryptoRole int
|
||||
|
||||
const (
|
||||
AEADCryptoRoleClient AEADCryptoRole = iota + 1
|
||||
AEADCryptoRoleServer
|
||||
)
|
||||
|
||||
const (
|
||||
aeadControlHKDFInfoPrefix = "frp wire v2 control aead"
|
||||
aeadDirectionClientToServer = "client-to-server"
|
||||
aeadDirectionServerToClient = "server-to-client"
|
||||
)
|
||||
|
||||
// NewAEADCryptoReadWriter wraps rw with framed AEAD encryption for the v2
|
||||
// control channel. Frames and their order are authenticated, but end-of-stream
|
||||
// is not: a clean EOF at a frame boundary is returned as normal EOF by the
|
||||
// underlying AEAD stream. Protocols that need truncation detection for finite
|
||||
// objects must add their own authenticated final message.
|
||||
func NewAEADCryptoReadWriter(
|
||||
rw io.ReadWriter,
|
||||
key []byte,
|
||||
role AEADCryptoRole,
|
||||
algorithm string,
|
||||
transcriptHash []byte,
|
||||
) (io.ReadWriter, error) {
|
||||
clientToServerKey, serverToClientKey, err := deriveAEADControlKeys(key, algorithm, transcriptHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var readKey, writeKey []byte
|
||||
switch role {
|
||||
case AEADCryptoRoleClient:
|
||||
readKey = serverToClientKey
|
||||
writeKey = clientToServerKey
|
||||
case AEADCryptoRoleServer:
|
||||
readKey = clientToServerKey
|
||||
writeKey = serverToClientKey
|
||||
default:
|
||||
return nil, errors.New("invalid aead crypto role")
|
||||
}
|
||||
|
||||
encReader, err := libcrypto.NewAEADStreamReader(rw, libcrypto.AEADStreamOptions{
|
||||
Algorithm: libcrypto.AEADAlgorithm(algorithm),
|
||||
Key: readKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encWriter, err := libcrypto.NewAEADStreamWriter(rw, libcrypto.AEADStreamOptions{
|
||||
Algorithm: libcrypto.AEADAlgorithm(algorithm),
|
||||
Key: writeKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
}{
|
||||
Reader: encReader,
|
||||
Writer: encWriter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func deriveAEADControlKeys(key []byte, algorithm string, transcriptHash []byte) (clientToServerKey, serverToClientKey []byte, err error) {
|
||||
clientToServerKey, err = deriveAEADControlKey(key, algorithm, transcriptHash, aeadDirectionClientToServer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
serverToClientKey, err = deriveAEADControlKey(key, algorithm, transcriptHash, aeadDirectionServerToClient)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return clientToServerKey, serverToClientKey, nil
|
||||
}
|
||||
|
||||
func deriveAEADControlKey(key []byte, algorithm string, transcriptHash []byte, direction string) ([]byte, error) {
|
||||
info := []byte(aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction)
|
||||
reader := hkdf.New(sha256.New, key, transcriptHash, info)
|
||||
out := make([]byte, libcrypto.AEADKeySize)
|
||||
if _, err := io.ReadFull(reader, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
118
pkg/util/net/conn_test.go
Normal file
118
pkg/util/net/conn_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package net
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
stdnet "net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
func TestNewAEADCryptoReadWriterRoundTrip(t *testing.T) {
|
||||
clientConn, serverConn := stdnet.Pipe()
|
||||
defer clientConn.Close()
|
||||
defer serverConn.Close()
|
||||
|
||||
key := []byte("token")
|
||||
transcriptHash := bytes.Repeat([]byte{0x11}, 32)
|
||||
clientRW, err := NewAEADCryptoReadWriter(
|
||||
clientConn,
|
||||
key,
|
||||
AEADCryptoRoleClient,
|
||||
wire.AEADAlgorithmXChaCha20Poly1305,
|
||||
transcriptHash,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
serverRW, err := NewAEADCryptoReadWriter(
|
||||
serverConn,
|
||||
key,
|
||||
AEADCryptoRoleServer,
|
||||
wire.AEADAlgorithmXChaCha20Poly1305,
|
||||
transcriptHash,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
clientErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
if _, err := clientRW.Write([]byte("ping")); err != nil {
|
||||
clientErrCh <- err
|
||||
return
|
||||
}
|
||||
buf := make([]byte, len("pong"))
|
||||
_, err := io.ReadFull(clientRW, buf)
|
||||
clientErrCh <- err
|
||||
}()
|
||||
|
||||
buf := make([]byte, len("ping"))
|
||||
_, err = io.ReadFull(serverRW, buf)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ping", string(buf))
|
||||
_, err = serverRW.Write([]byte("pong"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-clientErrCh)
|
||||
}
|
||||
|
||||
func TestNewAEADCryptoReadWriterRejectsDifferentTranscript(t *testing.T) {
|
||||
clientConn, serverConn := stdnet.Pipe()
|
||||
defer clientConn.Close()
|
||||
defer serverConn.Close()
|
||||
require.NoError(t, clientConn.SetDeadline(time.Now().Add(time.Second)))
|
||||
require.NoError(t, serverConn.SetDeadline(time.Now().Add(time.Second)))
|
||||
|
||||
key := []byte("token")
|
||||
clientRW, err := NewAEADCryptoReadWriter(
|
||||
clientConn,
|
||||
key,
|
||||
AEADCryptoRoleClient,
|
||||
wire.AEADAlgorithmAES256GCM,
|
||||
bytes.Repeat([]byte{0x22}, 32),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
serverRW, err := NewAEADCryptoReadWriter(
|
||||
serverConn,
|
||||
key,
|
||||
AEADCryptoRoleServer,
|
||||
wire.AEADAlgorithmAES256GCM,
|
||||
bytes.Repeat([]byte{0x33}, 32),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := clientRW.Write([]byte("ping"))
|
||||
writeErrCh <- err
|
||||
}()
|
||||
|
||||
buf := make([]byte, len("ping"))
|
||||
_, err = io.ReadFull(serverRW, buf)
|
||||
require.Error(t, err)
|
||||
require.NoError(t, <-writeErrCh)
|
||||
}
|
||||
|
||||
func TestDeriveAEADControlKeysUsesDistinctDirections(t *testing.T) {
|
||||
clientToServerKey, serverToClientKey, err := deriveAEADControlKeys(
|
||||
[]byte("token"),
|
||||
wire.AEADAlgorithmXChaCha20Poly1305,
|
||||
bytes.Repeat([]byte{0x44}, 32),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, clientToServerKey, serverToClientKey)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
package version
|
||||
|
||||
var version = "0.68.0"
|
||||
var version = "0.69.1"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
@@ -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
|
||||
@@ -187,60 +186,25 @@ func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byE
|
||||
return nil, fmt.Errorf("%v: %s %s %s", ErrNoRouteFound, host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
|
||||
}
|
||||
|
||||
func (rp *HTTPReverseProxy) CheckAuth(domain, location, routeByHTTPUser, user, passwd string) bool {
|
||||
vr, ok := rp.getVhost(domain, location, routeByHTTPUser)
|
||||
if ok {
|
||||
checkUser := vr.payload.(*RouteConfig).Username
|
||||
checkPasswd := vr.payload.(*RouteConfig).Password
|
||||
if (checkUser != "" || checkPasswd != "") && (checkUser != user || checkPasswd != passwd) {
|
||||
func checkRouteAuthByRequest(req *http.Request, rc *RouteConfig) bool {
|
||||
if rc == nil {
|
||||
return true
|
||||
}
|
||||
if rc.Username == "" && rc.Password == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
if req.URL.Host != "" {
|
||||
proxyAuth := req.Header.Get("Proxy-Authorization")
|
||||
if proxyAuth == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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
|
||||
user, passwd, ok := httppkg.ParseBasicAuth(proxyAuth)
|
||||
return ok && user == rc.Username && passwd == rc.Password
|
||||
}
|
||||
|
||||
// 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
|
||||
user, passwd, ok := req.BasicAuth()
|
||||
return ok && user == rc.Username && passwd == rc.Password
|
||||
}
|
||||
|
||||
func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -266,18 +230,25 @@ func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Req
|
||||
go libio.Join(remote, client)
|
||||
}
|
||||
|
||||
func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Request {
|
||||
user := ""
|
||||
// If url host isn't empty, it's a proxy request. Get http user from Proxy-Authorization header.
|
||||
func getRequestRouteUser(req *http.Request) string {
|
||||
if req.URL.Host != "" {
|
||||
proxyAuth := req.Header.Get("Proxy-Authorization")
|
||||
if proxyAuth != "" {
|
||||
user, _, _ = httppkg.ParseBasicAuth(proxyAuth)
|
||||
if proxyAuth == "" {
|
||||
// Preserve legacy proxy-mode routing when clients send only Authorization,
|
||||
// so requests still hit the matched route and return 407 instead of 404.
|
||||
// Auth validation intentionally does not share this fallback.
|
||||
user, _, _ := req.BasicAuth()
|
||||
return user
|
||||
}
|
||||
user, _, _ := httppkg.ParseBasicAuth(proxyAuth)
|
||||
return user
|
||||
}
|
||||
if user == "" {
|
||||
user, _, _ = req.BasicAuth()
|
||||
}
|
||||
user, _, _ := req.BasicAuth()
|
||||
return user
|
||||
}
|
||||
|
||||
func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Request {
|
||||
user := getRequestRouteUser(req)
|
||||
|
||||
reqRouteInfo := &RequestRouteInfo{
|
||||
URL: req.URL.Path,
|
||||
@@ -297,16 +268,19 @@ func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Requ
|
||||
}
|
||||
|
||||
func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
domain, _ := httppkg.CanonicalHost(req.Host)
|
||||
location := req.URL.Path
|
||||
user, passwd, _ := req.BasicAuth()
|
||||
if !rp.CheckAuth(domain, location, user, user, passwd) {
|
||||
rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
newreq := rp.injectRequestInfoToCtx(req)
|
||||
rc := newreq.Context().Value(RouteConfigKey).(*RouteConfig)
|
||||
if !checkRouteAuthByRequest(req, rc) {
|
||||
if req.URL.Host != "" {
|
||||
rw.Header().Set("Proxy-Authenticate", `Basic realm="Restricted"`)
|
||||
http.Error(rw, http.StatusText(http.StatusProxyAuthRequired), http.StatusProxyAuthRequired)
|
||||
} else {
|
||||
rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
newreq := rp.injectRequestInfoToCtx(req)
|
||||
if req.Method == http.MethodConnect {
|
||||
rp.connectHandler(rw, newreq)
|
||||
} else {
|
||||
|
||||
102
pkg/util/vhost/http_test.go
Normal file
102
pkg/util/vhost/http_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package vhost
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
)
|
||||
|
||||
func TestCheckRouteAuthByRequest(t *testing.T) {
|
||||
rc := &RouteConfig{
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
}
|
||||
|
||||
t.Run("accepts nil route config", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
require.True(t, checkRouteAuthByRequest(req, nil))
|
||||
})
|
||||
|
||||
t.Run("accepts route without credentials", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
require.True(t, checkRouteAuthByRequest(req, &RouteConfig{}))
|
||||
})
|
||||
|
||||
t.Run("accepts authorization header", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.SetBasicAuth("alice", "secret")
|
||||
require.True(t, checkRouteAuthByRequest(req, rc))
|
||||
})
|
||||
|
||||
t.Run("accepts proxy authorization header", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "secret"))
|
||||
require.True(t, checkRouteAuthByRequest(req, rc))
|
||||
})
|
||||
|
||||
t.Run("rejects authorization fallback for proxy request", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
||||
req.SetBasicAuth("alice", "secret")
|
||||
require.False(t, checkRouteAuthByRequest(req, rc))
|
||||
})
|
||||
|
||||
t.Run("rejects wrong proxy authorization even when authorization matches", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
||||
req.SetBasicAuth("alice", "secret")
|
||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "wrong"))
|
||||
require.False(t, checkRouteAuthByRequest(req, rc))
|
||||
})
|
||||
|
||||
t.Run("rejects when neither header matches", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
||||
req.SetBasicAuth("alice", "wrong")
|
||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "wrong"))
|
||||
require.False(t, checkRouteAuthByRequest(req, rc))
|
||||
})
|
||||
|
||||
t.Run("rejects proxy authorization on direct request", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "secret"))
|
||||
require.False(t, checkRouteAuthByRequest(req, rc))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRequestRouteUser(t *testing.T) {
|
||||
t.Run("proxy request uses proxy authorization username", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
||||
req.Host = "target.example.com"
|
||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("proxy-user", "proxy-pass"))
|
||||
req.SetBasicAuth("direct-user", "direct-pass")
|
||||
|
||||
require.Equal(t, "proxy-user", getRequestRouteUser(req))
|
||||
})
|
||||
|
||||
t.Run("connect request keeps proxy authorization routing", func(t *testing.T) {
|
||||
req := httptest.NewRequest("CONNECT", "http://target.example.com:443", nil)
|
||||
req.Host = "target.example.com:443"
|
||||
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("proxy-user", "proxy-pass"))
|
||||
req.SetBasicAuth("direct-user", "direct-pass")
|
||||
|
||||
require.Equal(t, "proxy-user", getRequestRouteUser(req))
|
||||
})
|
||||
|
||||
t.Run("direct request uses authorization username", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Host = "example.com"
|
||||
req.SetBasicAuth("direct-user", "direct-pass")
|
||||
|
||||
require.Equal(t, "direct-user", getRequestRouteUser(req))
|
||||
})
|
||||
|
||||
t.Run("proxy request does not fall back when proxy authorization is invalid", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
||||
req.Host = "target.example.com"
|
||||
req.Header.Set("Proxy-Authorization", "Basic !!!")
|
||||
req.SetBasicAuth("direct-user", "direct-pass")
|
||||
|
||||
require.Empty(t, getRequestRouteUser(req))
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
pkg/util/vhost/router_test.go
Normal file
257
pkg/util/vhost/router_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -148,41 +148,9 @@ 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)
|
||||
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)
|
||||
vr, ok := v.registryRouter.getByRoute(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 vr.payload.(*Listener), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -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
pkg/util/wait/backoff_test.go
Normal file
27
pkg/util/wait/backoff_test.go
Normal 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)
|
||||
}
|
||||
@@ -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) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -48,6 +48,14 @@ 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/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")
|
||||
subRouter.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(
|
||||
|
||||
@@ -17,7 +17,6 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -32,9 +31,7 @@ import (
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
plugin "github.com/fatedier/frp/pkg/plugin/server"
|
||||
"github.com/fatedier/frp/pkg/transport"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
"github.com/fatedier/frp/pkg/util/util"
|
||||
"github.com/fatedier/frp/pkg/util/version"
|
||||
"github.com/fatedier/frp/pkg/util/wait"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
"github.com/fatedier/frp/server/controller"
|
||||
@@ -108,15 +105,15 @@ type SessionContext struct {
|
||||
// key used for connection encryption
|
||||
EncryptionKey []byte
|
||||
// control connection
|
||||
Conn net.Conn
|
||||
// indicates whether the connection is encrypted
|
||||
ConnEncrypted bool
|
||||
Conn *msg.Conn
|
||||
// login message
|
||||
LoginMsg *msg.Login
|
||||
// server configuration
|
||||
ServerCfg *v1.ServerConfig
|
||||
// client registry
|
||||
ClientRegistry *registry.ClientRegistry
|
||||
// negotiated wire protocol for this client session
|
||||
WireProtocol string
|
||||
}
|
||||
|
||||
type Control struct {
|
||||
@@ -131,7 +128,7 @@ type Control struct {
|
||||
msgDispatcher *msg.Dispatcher
|
||||
|
||||
// work connections
|
||||
workConnCh chan net.Conn
|
||||
workConnCh chan *proxy.WorkConn
|
||||
|
||||
// proxies in one client
|
||||
proxies map[string]proxy.Proxy
|
||||
@@ -161,7 +158,7 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
||||
poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount))
|
||||
ctl := &Control{
|
||||
sessionCtx: sessionCtx,
|
||||
workConnCh: make(chan net.Conn, poolCount+10),
|
||||
workConnCh: make(chan *proxy.WorkConn, poolCount+10),
|
||||
proxies: make(map[string]proxy.Proxy),
|
||||
poolCount: poolCount,
|
||||
portsUsedNum: 0,
|
||||
@@ -172,29 +169,14 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
||||
}
|
||||
ctl.lastPing.Store(time.Now())
|
||||
|
||||
if sessionCtx.ConnEncrypted {
|
||||
cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.EncryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctl.msgDispatcher = msg.NewDispatcher(cryptoRW)
|
||||
} else {
|
||||
ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
|
||||
}
|
||||
ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
|
||||
ctl.registerMsgHandlers()
|
||||
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
|
||||
return ctl, nil
|
||||
}
|
||||
|
||||
// Start send a login success message to client and start working.
|
||||
// Start starts the control session workers after login succeeds.
|
||||
func (ctl *Control) Start() {
|
||||
loginRespMsg := &msg.LoginResp{
|
||||
Version: version.Full(),
|
||||
RunID: ctl.runID,
|
||||
Error: "",
|
||||
}
|
||||
_ = msg.WriteMsg(ctl.sessionCtx.Conn, loginRespMsg)
|
||||
|
||||
go func() {
|
||||
for i := 0; i < ctl.poolCount; i++ {
|
||||
// ignore error here, that means that this control is closed
|
||||
@@ -216,7 +198,7 @@ func (ctl *Control) Replaced(newCtl *Control) {
|
||||
ctl.sessionCtx.Conn.Close()
|
||||
}
|
||||
|
||||
func (ctl *Control) RegisterWorkConn(conn net.Conn) error {
|
||||
func (ctl *Control) RegisterWorkConn(conn *proxy.WorkConn) error {
|
||||
xl := ctl.xl
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
@@ -239,7 +221,7 @@ func (ctl *Control) RegisterWorkConn(conn net.Conn) error {
|
||||
// If no workConn available in the pool, send message to frpc to get one or more
|
||||
// and wait until it is available.
|
||||
// return an error if wait timeout
|
||||
func (ctl *Control) GetWorkConn() (workConn net.Conn, err error) {
|
||||
func (ctl *Control) GetWorkConn() (workConn *proxy.WorkConn, err error) {
|
||||
xl := ctl.xl
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
@@ -472,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
|
||||
|
||||
@@ -57,7 +57,7 @@ type HTTPGroup struct {
|
||||
// CreateConnFuncs indexed by proxy name
|
||||
createFuncs map[string]vhost.CreateConnFunc
|
||||
pxyNames []string
|
||||
index uint64
|
||||
index atomic.Uint64
|
||||
ctl *HTTPGroupController
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func (g *HTTPGroup) UnRegister(proxyName string) {
|
||||
|
||||
func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {
|
||||
var f vhost.CreateConnFunc
|
||||
newIndex := atomic.AddUint64(&g.index, 1)
|
||||
newIndex := g.index.Add(1)
|
||||
|
||||
g.mu.RLock()
|
||||
group := g.group
|
||||
@@ -158,7 +158,7 @@ func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {
|
||||
}
|
||||
|
||||
func (g *HTTPGroup) chooseEndpoint() (string, error) {
|
||||
newIndex := atomic.AddUint64(&g.index, 1)
|
||||
newIndex := g.index.Add(1)
|
||||
name := ""
|
||||
|
||||
g.mu.RLock()
|
||||
|
||||
@@ -287,6 +287,7 @@ func buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp {
|
||||
ClientID: info.ClientID(),
|
||||
RunID: info.RunID,
|
||||
Version: info.Version,
|
||||
WireProtocol: info.WireProtocol,
|
||||
Hostname: info.Hostname,
|
||||
ClientIP: info.IP,
|
||||
FirstConnectedAt: toUnix(info.FirstConnectedAt),
|
||||
|
||||
@@ -17,8 +17,11 @@ package http
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
func TestGetConfFromConfigurerKeepsPluginFields(t *testing.T) {
|
||||
@@ -69,3 +72,24 @@ func TestGetConfFromConfigurerKeepsPluginFields(t *testing.T) {
|
||||
t.Fatalf("plugin httpPassword mismatch, want %q got %#v", "password", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClientInfoRespIncludesWireProtocol(t *testing.T) {
|
||||
info := registry.ClientInfo{
|
||||
Key: "user.client",
|
||||
User: "user",
|
||||
RawClientID: "client",
|
||||
RunID: "run-id",
|
||||
Version: "1.0.0",
|
||||
WireProtocol: wire.ProtocolV2,
|
||||
Hostname: "host",
|
||||
IP: "127.0.0.1",
|
||||
FirstConnectedAt: time.Unix(1, 0),
|
||||
LastConnectedAt: time.Unix(2, 0),
|
||||
Online: true,
|
||||
}
|
||||
|
||||
resp := buildClientInfoResp(info)
|
||||
if resp.WireProtocol != wire.ProtocolV2 {
|
||||
t.Fatalf("wire protocol mismatch, want %q got %q", wire.ProtocolV2, resp.WireProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
438
server/http/controller_v2.go
Normal file
438
server/http/controller_v2.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// 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"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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/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 := ctx.Param("key")
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("missing client key")
|
||||
}
|
||||
decodedKey, err := url.PathUnescape(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid client key %q: %w", key, err)
|
||||
}
|
||||
key = decodedKey
|
||||
|
||||
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.Type, b.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 := ctx.Param("name")
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("missing proxy name")
|
||||
}
|
||||
|
||||
ps := mem.StatsCollector.GetProxyByName(name)
|
||||
if ps == nil {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||
}
|
||||
return c.buildV2ProxyResp(ps), 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 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.Type,
|
||||
item.User,
|
||||
item.ClientID,
|
||||
item.Status.State,
|
||||
}
|
||||
|
||||
switch spec := item.Spec.(type) {
|
||||
case *model.TCPOutConf:
|
||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||
case *model.UDPOutConf:
|
||||
values = append(values, strconv.Itoa(spec.RemotePort))
|
||||
case *model.HTTPOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
case *model.HTTPSOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.SubDomain)
|
||||
case *model.TCPMuxOutConf:
|
||||
values = append(values, spec.CustomDomains...)
|
||||
values = append(values, spec.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 (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 spec any
|
||||
if c.pxyManager != nil {
|
||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||
state = "online"
|
||||
spec = getConfFromConfigurer(pxy.GetConfigurer())
|
||||
}
|
||||
}
|
||||
|
||||
return model.V2ProxyResp{
|
||||
Name: ps.Name,
|
||||
Type: ps.Type,
|
||||
User: ps.User,
|
||||
ClientID: ps.ClientID,
|
||||
Spec: spec,
|
||||
Status: model.V2ProxyStatusResp{
|
||||
State: state,
|
||||
TodayTrafficIn: ps.TodayTrafficIn,
|
||||
TodayTrafficOut: ps.TodayTrafficOut,
|
||||
CurConns: ps.CurConns,
|
||||
LastStartTime: ps.LastStartTime,
|
||||
LastCloseTime: ps.LastCloseTime,
|
||||
},
|
||||
}
|
||||
}
|
||||
429
server/http/controller_v2_test.go
Normal file
429
server/http/controller_v2_test.go
Normal file
@@ -0,0 +1,429 @@
|
||||
// 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"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
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 {
|
||||
proxies map[string]*mem.ProxyStats
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||
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 nil
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||
return 0, len(f.proxies)
|
||||
}
|
||||
|
||||
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.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" {
|
||||
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies/tcp-alice")
|
||||
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)
|
||||
}
|
||||
|
||||
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 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", Type: "tcp", Spec: &model.TCPOutConf{
|
||||
RemotePort: 6000,
|
||||
}},
|
||||
q: "6000",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "udp remote port",
|
||||
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
|
||||
RemotePort: 7000,
|
||||
}},
|
||||
q: "7000",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "remote port does not match colon form",
|
||||
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
||||
RemotePort: 6000,
|
||||
}},
|
||||
q: ":6000",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "http custom domain",
|
||||
item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}},
|
||||
}},
|
||||
q: "app.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "https subdomain",
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||
}},
|
||||
q: "portal",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subdomain does not match expanded host",
|
||||
item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{
|
||||
DomainConfig: v1.DomainConfig{SubDomain: "portal"},
|
||||
}},
|
||||
q: "portal.example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "tcpmux custom domain",
|
||||
item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{
|
||||
DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}},
|
||||
}},
|
||||
q: "mux.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "nil spec does not match spec fields",
|
||||
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
||||
q: "6000",
|
||||
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/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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
"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",
|
||||
},
|
||||
},
|
||||
}
|
||||
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/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)
|
||||
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).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)
|
||||
return router
|
||||
}
|
||||
|
||||
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, 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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user