mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 17:29:16 +08:00
Compare commits
93 Commits
copilot/re
...
7b6e01f04f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6e01f04f | ||
|
|
5396947c7c | ||
|
|
fe61093bc2 | ||
|
|
54aeb2a7b0 | ||
|
|
84be1938e4 | ||
|
|
becee40715 | ||
|
|
17e788d43b | ||
|
|
68509f5d44 | ||
|
|
5cd722b177 | ||
|
|
5876beceac | ||
|
|
7fe152e3aa | ||
|
|
7c343fc6e7 | ||
|
|
a3b3b35b69 | ||
|
|
ae1c0504ec | ||
|
|
393a533744 | ||
|
|
035889c360 | ||
|
|
940bde5c46 | ||
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec | ||
|
|
9bde0b07de | ||
|
|
c6c545289c | ||
|
|
503afe78b7 | ||
|
|
8dd26c6961 | ||
|
|
9ea1d86f03 | ||
|
|
ac3e82db4e | ||
|
|
0773938d70 | ||
|
|
9bacce22a2 | ||
|
|
7f8d68b666 | ||
|
|
c8c1e5116c | ||
|
|
3e19ef9bfd | ||
|
|
e20f974d61 | ||
|
|
a88e0e9a49 | ||
|
|
ad07d27914 | ||
|
|
8666e3643f | ||
|
|
57bb9e80fe | ||
|
|
cef71fb949 | ||
|
|
410c4861c4 | ||
|
|
e9464919d1 | ||
|
|
4ec8de973f | ||
|
|
e8dfd6efcc | ||
|
|
a9a4416ecf | ||
|
|
d667be7a0a | ||
|
|
31c3deb4f7 | ||
|
|
31e271939b | ||
|
|
061c141756 | ||
|
|
98ee1adb13 | ||
|
|
76abeff881 | ||
|
|
c694b1f6a9 | ||
|
|
5bfcea3d0c | ||
|
|
5ed02275da | ||
|
|
60c4f5d4bd | ||
|
|
0a1b4ab21f | ||
|
|
d20e384bf1 | ||
|
|
c95dc9d88a | ||
|
|
38a71a6803 | ||
|
|
6cdef90113 | ||
|
|
85e8e2c830 | ||
|
|
ff4ad2f907 | ||
|
|
94a631fe9c | ||
|
|
6b1be922e1 | ||
|
|
4f584f81d0 | ||
|
|
9669e1ca0c | ||
|
|
48e8901466 | ||
|
|
bcd2424c24 | ||
|
|
c7ac12ea0f | ||
|
|
eeb0dacfc1 | ||
|
|
535eb3db35 | ||
|
|
605f3bdece | ||
|
|
764a626b6e | ||
|
|
c2454e7114 | ||
|
|
017d71717f | ||
|
|
bd200b1a3b | ||
|
|
c70ceff370 | ||
|
|
bb3d0e7140 | ||
|
|
cf396563f8 | ||
|
|
0b4f83cd04 | ||
|
|
e9f7a1a9f2 | ||
|
|
d644593342 | ||
|
|
427c4ca3ae | ||
|
|
f2d1f3739a | ||
|
|
c23894f156 | ||
|
|
cb459b02b6 | ||
|
|
8f633fe363 | ||
|
|
c62a1da161 | ||
|
|
f22f7d539c | ||
|
|
462c987f6d | ||
|
|
5f575b8442 | ||
|
|
a1348cdf00 | ||
|
|
2f5e1f7945 | ||
|
|
22ae8166d3 | ||
|
|
af6bc6369d |
14
.github/workflows/build-and-push-image.yml
vendored
14
.github/workflows/build-and-push-image.yml
vendored
@@ -19,15 +19,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# environment
|
# environment
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: '0'
|
fetch-depth: '0'
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
# get image tag name
|
# get image tag name
|
||||||
- name: Get Image Tag Name
|
- name: Get Image Tag Name
|
||||||
@@ -38,13 +38,13 @@ jobs:
|
|||||||
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to the GPR
|
- name: Login to the GPR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and push frpc
|
- name: Build and push frpc
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./dockerfiles/Dockerfile-for-frpc
|
file: ./dockerfiles/Dockerfile-for-frpc
|
||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
${{ env.TAG_FRPC_GPR }}
|
${{ env.TAG_FRPC_GPR }}
|
||||||
|
|
||||||
- name: Build and push frps
|
- name: Build and push frps
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./dockerfiles/Dockerfile-for-frps
|
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
|
name: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.25'
|
||||||
cache: false
|
cache: false
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Build web assets (frps)
|
- name: Build web assets (frps)
|
||||||
@@ -32,4 +32,4 @@ jobs:
|
|||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
||||||
version: v2.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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.25'
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Build web assets (frps)
|
- name: Build web assets (frps)
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
./package.sh
|
./package.sh
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
args: release --clean --release-notes=./Release.md
|
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
|
actions: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
stale-issue-message: 'Issues go stale after 14d of inactivity. Stale issues rot after an additional 3d of inactivity and eventually close.'
|
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."
|
stale-pr-message: "PRs go stale after 14d of inactivity. Stale PRs rot after an additional 3d of inactivity and eventually close."
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -18,6 +18,7 @@ release/
|
|||||||
test/bin/
|
test/bin/
|
||||||
vendor/
|
vendor/
|
||||||
lastversion/
|
lastversion/
|
||||||
|
.cache/
|
||||||
dist/
|
dist/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -25,10 +26,12 @@ dist/
|
|||||||
client.crt
|
client.crt
|
||||||
client.key
|
client.key
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
CLAUDE.md
|
.claude/
|
||||||
AGENTS.md
|
|
||||||
.sisyphus/
|
.sisyphus/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ linters:
|
|||||||
- lll
|
- lll
|
||||||
- makezero
|
- makezero
|
||||||
- misspell
|
- misspell
|
||||||
|
- modernize
|
||||||
- prealloc
|
- prealloc
|
||||||
- predeclared
|
- predeclared
|
||||||
- revive
|
- revive
|
||||||
@@ -33,7 +34,7 @@ linters:
|
|||||||
disabled-checks:
|
disabled-checks:
|
||||||
- exitAfterDefer
|
- exitAfterDefer
|
||||||
gosec:
|
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
|
severity: low
|
||||||
confidence: low
|
confidence: low
|
||||||
govet:
|
govet:
|
||||||
@@ -47,6 +48,9 @@ linters:
|
|||||||
ignore-rules:
|
ignore-rules:
|
||||||
- cancelled
|
- cancelled
|
||||||
- marshalled
|
- marshalled
|
||||||
|
modernize:
|
||||||
|
disable:
|
||||||
|
- omitzero
|
||||||
unparam:
|
unparam:
|
||||||
check-exported: false
|
check-exported: false
|
||||||
exclusions:
|
exclusions:
|
||||||
@@ -86,6 +90,7 @@ linters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gci
|
- gci
|
||||||
@@ -108,6 +113,7 @@ formatters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
39
AGENTS.md
Normal file
39
AGENTS.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- `make build` - Build both frps and frpc binaries
|
||||||
|
- `make frps` - Build server binary only
|
||||||
|
- `make frpc` - Build client binary only
|
||||||
|
- `make all` - Build everything with formatting
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- `make test` - Run unit tests
|
||||||
|
- `make e2e` - Run end-to-end tests
|
||||||
|
- `make e2e-trace` - Run e2e tests with trace logging
|
||||||
|
- `make alltest` - Run all tests including vet, unit tests, and e2e
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- `make fmt` - Run go fmt
|
||||||
|
- `make fmt-more` - Run gofumpt for more strict formatting
|
||||||
|
- `make gci` - Run gci import organizer
|
||||||
|
- `make vet` - Run go vet
|
||||||
|
- `golangci-lint run` - Run comprehensive linting (configured in .golangci.yml)
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
- `make web` - Build web dashboards (frps and frpc)
|
||||||
|
|
||||||
|
### Cleanup
|
||||||
|
- `make clean` - Remove built binaries and temporary files
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- E2E tests using Ginkgo/Gomega framework
|
||||||
|
- Mock servers in `/test/e2e/mock/`
|
||||||
|
- Run: `make e2e` or `make alltest`
|
||||||
|
|
||||||
|
## Agent Runbooks
|
||||||
|
|
||||||
|
Operational procedures for agents are in `doc/agents/`:
|
||||||
|
- `doc/agents/release.md` - Release process
|
||||||
15
Makefile
15
Makefile
@@ -2,8 +2,10 @@ export PATH := $(PATH):`go env GOPATH`/bin
|
|||||||
export GO111MODULE=on
|
export GO111MODULE=on
|
||||||
LDFLAGS := -s -w
|
LDFLAGS := -s -w
|
||||||
NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
|
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
|
all: env fmt web build
|
||||||
|
|
||||||
@@ -53,6 +55,15 @@ e2e:
|
|||||||
e2e-trace:
|
e2e-trace:
|
||||||
DEBUG=true LOG_LEVEL=trace ./hack/run-e2e.sh
|
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:
|
e2e-compatibility-last-frpc:
|
||||||
if [ ! -d "./lastversion" ]; then \
|
if [ ! -d "./lastversion" ]; then \
|
||||||
TARGET_DIRNAME=lastversion ./hack/download.sh; \
|
TARGET_DIRNAME=lastversion ./hack/download.sh; \
|
||||||
@@ -73,3 +84,5 @@ clean:
|
|||||||
rm -f ./bin/frpc
|
rm -f ./bin/frpc
|
||||||
rm -f ./bin/frps
|
rm -f ./bin/frps
|
||||||
rm -rf ./lastversion
|
rm -rf ./lastversion
|
||||||
|
rm -rf ./.cache
|
||||||
|
rm -rf ./.compat
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
[](https://circleci.com/gh/fatedier/frp)
|
[](https://circleci.com/gh/fatedier/frp)
|
||||||
[](https://github.com/fatedier/frp/releases)
|
[](https://github.com/fatedier/frp/releases)
|
||||||
[](https://goreportcard.com/report/github.com/fatedier/frp)
|
|
||||||
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||||
|
|
||||||
[README](README.md) | [中文文档](README_zh.md)
|
[README](README.md) | [中文文档](README_zh.md)
|
||||||
@@ -23,16 +22,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
|
||||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
|
||||||
<br>
|
|
||||||
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
|
||||||
<br>
|
|
||||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
@@ -81,6 +70,7 @@ frp also offers a P2P connect mode.
|
|||||||
* [Split Configures Into Different Files](#split-configures-into-different-files)
|
* [Split Configures Into Different Files](#split-configures-into-different-files)
|
||||||
* [Server Dashboard](#server-dashboard)
|
* [Server Dashboard](#server-dashboard)
|
||||||
* [Client Admin UI](#client-admin-ui)
|
* [Client Admin UI](#client-admin-ui)
|
||||||
|
* [Dynamic Proxy Management (Store)](#dynamic-proxy-management-store)
|
||||||
* [Monitor](#monitor)
|
* [Monitor](#monitor)
|
||||||
* [Prometheus](#prometheus)
|
* [Prometheus](#prometheus)
|
||||||
* [Authenticating the Client](#authenticating-the-client)
|
* [Authenticating the Client](#authenticating-the-client)
|
||||||
@@ -149,7 +139,9 @@ We sincerely appreciate your support for frp.
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||

|
<p align="center">
|
||||||
|
<img src="/doc/pic/architecture.jpg" alt="architecture" width="760">
|
||||||
|
</p>
|
||||||
|
|
||||||
## Example Usage
|
## Example Usage
|
||||||
|
|
||||||
@@ -593,7 +585,7 @@ Then visit `https://[serverAddr]:7500` to see the dashboard in secure HTTPS conn
|
|||||||
|
|
||||||
### Client Admin UI
|
### 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:
|
Configure an address for admin UI to enable this feature:
|
||||||
|
|
||||||
@@ -606,6 +598,19 @@ webServer.password = "admin"
|
|||||||
|
|
||||||
Then visit `http://127.0.0.1:7400` to see admin UI, with username and password both being `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
|
### Monitor
|
||||||
|
|
||||||
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
|
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
|
||||||
|
|||||||
10
README_zh.md
10
README_zh.md
@@ -25,16 +25,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
|
||||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
|
||||||
<br>
|
|
||||||
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
|
||||||
<br>
|
|
||||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
|
|||||||
10
Release.md
10
Release.md
@@ -1,9 +1,9 @@
|
|||||||
## Features
|
## 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.
|
* Expanded the frps dashboard API v2 migration across Clients, Proxies, Server Overview, Client Detail, and Proxy Detail, covering paginated users/clients/proxies, detail data, proxy traffic history, server system info, offline proxy statistics pruning, server-side pagination, search, and proxy type filtering.
|
||||||
|
|
||||||
## Improvements
|
## Fixes
|
||||||
|
|
||||||
* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.
|
* WebSocket and WSS tunnel payloads are now sent as binary frames, avoiding disconnects through RFC-compliant intermediaries that validate text frames as UTF-8.
|
||||||
* 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.
|
* The `tls2raw` client plugin now writes the proxy protocol header to the local raw connection when proxy protocol is enabled.
|
||||||
* 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.
|
* frpc now rejects duplicate proxy and visitor names in config files instead of silently overwriting earlier entries.
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
|
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
|
||||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
|
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
|
||||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
|
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
|
||||||
|
subRouter.HandleFunc("/api/proxy/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetProxyConfig)).Methods(http.MethodGet)
|
||||||
|
subRouter.HandleFunc("/api/visitor/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetVisitorConfig)).Methods(http.MethodGet)
|
||||||
|
|
||||||
if svr.storeSource != nil {
|
if svr.storeSource != nil {
|
||||||
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
|
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
|
||||||
|
|||||||
@@ -80,6 +80,48 @@ func (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
|
|||||||
return m.svr.getAllProxyStatus()
|
return m.svr.getAllProxyStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *serviceConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
|
||||||
|
// Try running proxy manager first
|
||||||
|
ws, ok := m.svr.getProxyStatus(name)
|
||||||
|
if ok {
|
||||||
|
return ws.Cfg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to store
|
||||||
|
m.svr.reloadMu.Lock()
|
||||||
|
storeSource := m.svr.storeSource
|
||||||
|
m.svr.reloadMu.Unlock()
|
||||||
|
|
||||||
|
if storeSource != nil {
|
||||||
|
cfg := storeSource.GetProxy(name)
|
||||||
|
if cfg != nil {
|
||||||
|
return cfg, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *serviceConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
// Try running visitor manager first
|
||||||
|
cfg, ok := m.svr.getVisitorCfg(name)
|
||||||
|
if ok {
|
||||||
|
return cfg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to store
|
||||||
|
m.svr.reloadMu.Lock()
|
||||||
|
storeSource := m.svr.storeSource
|
||||||
|
m.svr.reloadMu.Unlock()
|
||||||
|
|
||||||
|
if storeSource != nil {
|
||||||
|
vcfg := storeSource.GetVisitor(name)
|
||||||
|
if vcfg != nil {
|
||||||
|
return vcfg, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool {
|
func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type ConfigManager interface {
|
|||||||
IsStoreProxyEnabled(name string) bool
|
IsStoreProxyEnabled(name string) bool
|
||||||
StoreEnabled() bool
|
StoreEnabled() bool
|
||||||
|
|
||||||
|
GetProxyConfig(name string) (v1.ProxyConfigurer, bool)
|
||||||
|
GetVisitorConfig(name string) (v1.VisitorConfigurer, bool)
|
||||||
|
|
||||||
ListStoreProxies() ([]v1.ProxyConfigurer, error)
|
ListStoreProxies() ([]v1.ProxyConfigurer, error)
|
||||||
GetStoreProxy(name string) (v1.ProxyConfigurer, error)
|
GetStoreProxy(name string) (v1.ProxyConfigurer, error)
|
||||||
CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
|
CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import (
|
|||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/proto/wire"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
@@ -41,6 +43,39 @@ type Connector interface {
|
|||||||
Close() error
|
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.
|
// defaultConnectorImpl is the default implementation of Connector for normal frpc.
|
||||||
type defaultConnectorImpl struct {
|
type defaultConnectorImpl struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -119,6 +154,7 @@ func (c *defaultConnectorImpl) Open() error {
|
|||||||
fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
|
fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
|
||||||
session, err := fmux.Client(conn, fmuxCfg)
|
session, err := fmux.Client(conn, fmuxCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.muxSession = session
|
c.muxSession = session
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/naming"
|
"github.com/fatedier/frp/pkg/naming"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"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/wait"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/pkg/vnet"
|
"github.com/fatedier/frp/pkg/vnet"
|
||||||
@@ -41,13 +40,11 @@ type SessionContext struct {
|
|||||||
// It should be attached to the login message when reconnecting.
|
// It should be attached to the login message when reconnecting.
|
||||||
RunID string
|
RunID string
|
||||||
// Underlying control connection. Once conn is closed, the msgDispatcher and the entire Control will exit.
|
// Underlying control connection. Once conn is closed, the msgDispatcher and the entire Control will exit.
|
||||||
Conn net.Conn
|
Conn *msg.Conn
|
||||||
// Indicates whether the connection is encrypted.
|
|
||||||
ConnEncrypted bool
|
|
||||||
// Auth runtime used for login, heartbeats, and encryption.
|
// Auth runtime used for login, heartbeats, and encryption.
|
||||||
Auth *auth.ClientAuth
|
Auth *auth.ClientAuth
|
||||||
// Connector is used to create new connections, which could be real TCP connections or virtual streams.
|
// Connector is used to create message connections to frps.
|
||||||
Connector Connector
|
Connector MessageConnector
|
||||||
// Virtual net controller
|
// Virtual net controller
|
||||||
VnetController *vnet.Controller
|
VnetController *vnet.Controller
|
||||||
}
|
}
|
||||||
@@ -91,15 +88,7 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
|
|||||||
}
|
}
|
||||||
ctl.lastPong.Store(time.Now())
|
ctl.lastPong.Store(time.Now())
|
||||||
|
|
||||||
if sessionCtx.ConnEncrypted {
|
ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
|
||||||
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.registerMsgHandlers()
|
ctl.registerMsgHandlers()
|
||||||
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
|
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
|
||||||
|
|
||||||
@@ -139,14 +128,14 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
|
|||||||
workConn.Close()
|
workConn.Close()
|
||||||
return
|
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)
|
xl.Warnf("work connection write to server error: %v", err)
|
||||||
workConn.Close()
|
workConn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var startMsg msg.StartWorkConn
|
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)
|
xl.Tracef("work connection closed before response StartWorkConn message: %v", err)
|
||||||
workConn.Close()
|
workConn.Close()
|
||||||
return
|
return
|
||||||
@@ -227,7 +216,7 @@ func (ctl *Control) Done() <-chan struct{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// connectServer return a new connection to frps
|
// 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()
|
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)
|
||||||
|
}
|
||||||
@@ -162,6 +162,44 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.Pro
|
|||||||
return psr
|
return psr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProxyConfig handles GET /api/proxy/{name}/config
|
||||||
|
func (c *Controller) GetProxyConfig(ctx *httppkg.Context) (any, error) {
|
||||||
|
name := ctx.Param("name")
|
||||||
|
if name == "" {
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, ok := c.manager.GetProxyConfig(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := model.ProxyDefinitionFromConfigurer(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVisitorConfig handles GET /api/visitor/{name}/config
|
||||||
|
func (c *Controller) GetVisitorConfig(ctx *httppkg.Context) (any, error) {
|
||||||
|
name := ctx.Param("name")
|
||||||
|
if name == "" {
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, ok := c.manager.GetVisitorConfig(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := model.VisitorDefinitionFromConfigurer(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
|
func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
|
||||||
proxies, err := c.manager.ListStoreProxies()
|
proxies, err := c.manager.ListStoreProxies()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ type fakeConfigManager struct {
|
|||||||
getProxyStatusFn func() []*proxy.WorkingStatus
|
getProxyStatusFn func() []*proxy.WorkingStatus
|
||||||
isStoreProxyEnabledFn func(name string) bool
|
isStoreProxyEnabledFn func(name string) bool
|
||||||
storeEnabledFn func() bool
|
storeEnabledFn func() bool
|
||||||
|
getProxyConfigFn func(name string) (v1.ProxyConfigurer, bool)
|
||||||
|
getVisitorConfigFn func(name string) (v1.VisitorConfigurer, bool)
|
||||||
|
|
||||||
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
|
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
|
||||||
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
|
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
|
||||||
@@ -82,6 +84,20 @@ func (m *fakeConfigManager) StoreEnabled() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *fakeConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
|
||||||
|
if m.getProxyConfigFn != nil {
|
||||||
|
return m.getProxyConfigFn(name)
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
if m.getVisitorConfigFn != nil {
|
||||||
|
return m.getVisitorConfigFn(name)
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
|
func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
|
||||||
if m.listStoreProxiesFn != nil {
|
if m.listStoreProxiesFn != nil {
|
||||||
return m.listStoreProxiesFn()
|
return m.listStoreProxiesFn()
|
||||||
@@ -529,3 +545,118 @@ func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {
|
|||||||
t.Fatalf("unexpected response payload: %#v", payload)
|
t.Fatalf("unexpected response payload: %#v", payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetProxyConfigFromManager(t *testing.T) {
|
||||||
|
controller := &Controller{
|
||||||
|
manager: &fakeConfigManager{
|
||||||
|
getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
|
||||||
|
if name == "ssh" {
|
||||||
|
cfg := &v1.TCPProxyConfig{
|
||||||
|
ProxyBaseConfig: v1.ProxyBaseConfig{
|
||||||
|
Name: "ssh",
|
||||||
|
Type: "tcp",
|
||||||
|
ProxyBackend: v1.ProxyBackend{
|
||||||
|
LocalPort: 22,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cfg, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/proxy/ssh/config", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "ssh"})
|
||||||
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
resp, err := controller.GetProxyConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get proxy config: %v", err)
|
||||||
|
}
|
||||||
|
payload, ok := resp.(model.ProxyDefinition)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected response type: %T", resp)
|
||||||
|
}
|
||||||
|
if payload.Name != "ssh" || payload.Type != "tcp" || payload.TCP == nil {
|
||||||
|
t.Fatalf("unexpected payload: %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProxyConfigNotFound(t *testing.T) {
|
||||||
|
controller := &Controller{
|
||||||
|
manager: &fakeConfigManager{
|
||||||
|
getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/proxy/missing/config", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "missing"})
|
||||||
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
_, err := controller.GetProxyConfig(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
assertHTTPCode(t, err, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVisitorConfigFromManager(t *testing.T) {
|
||||||
|
controller := &Controller{
|
||||||
|
manager: &fakeConfigManager{
|
||||||
|
getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
if name == "my-stcp" {
|
||||||
|
cfg := &v1.STCPVisitorConfig{
|
||||||
|
VisitorBaseConfig: v1.VisitorBaseConfig{
|
||||||
|
Name: "my-stcp",
|
||||||
|
Type: "stcp",
|
||||||
|
ServerName: "server1",
|
||||||
|
BindPort: 9000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cfg, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/visitor/my-stcp/config", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "my-stcp"})
|
||||||
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
resp, err := controller.GetVisitorConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get visitor config: %v", err)
|
||||||
|
}
|
||||||
|
payload, ok := resp.(model.VisitorDefinition)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected response type: %T", resp)
|
||||||
|
}
|
||||||
|
if payload.Name != "my-stcp" || payload.Type != "stcp" || payload.STCP == nil {
|
||||||
|
t.Fatalf("unexpected payload: %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVisitorConfigNotFound(t *testing.T) {
|
||||||
|
controller := &Controller{
|
||||||
|
manager: &fakeConfigManager{
|
||||||
|
getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
return nil, false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/visitor/missing/config", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"name": "missing"})
|
||||||
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
_, err := controller.GetVisitorConfig(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
assertHTTPCode(t, err, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -122,6 +123,33 @@ func (pxy *BaseProxy) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wrapWorkConn applies rate limiting, encryption, and compression
|
||||||
|
// to a work connection based on the proxy's transport configuration.
|
||||||
|
// The returned recycle function should be called when the stream is no longer in use
|
||||||
|
// to return compression resources to the pool. It is safe to not call recycle,
|
||||||
|
// in which case resources will be garbage collected normally.
|
||||||
|
func (pxy *BaseProxy) wrapWorkConn(conn net.Conn, encKey []byte) (io.ReadWriteCloser, func(), error) {
|
||||||
|
var rwc io.ReadWriteCloser = conn
|
||||||
|
if pxy.limiter != nil {
|
||||||
|
rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {
|
||||||
|
return conn.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if pxy.baseCfg.Transport.UseEncryption {
|
||||||
|
var err error
|
||||||
|
rwc, err = libio.WithEncryption(rwc, encKey)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, nil, fmt.Errorf("create encryption stream error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var recycleFn func()
|
||||||
|
if pxy.baseCfg.Transport.UseCompression {
|
||||||
|
rwc, recycleFn = libio.WithCompressionFromPool(rwc)
|
||||||
|
}
|
||||||
|
return rwc, recycleFn, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (pxy *BaseProxy) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) {
|
func (pxy *BaseProxy) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) {
|
||||||
pxy.inWorkConnCallback = cb
|
pxy.inWorkConnCallback = cb
|
||||||
}
|
}
|
||||||
@@ -139,30 +167,14 @@ func (pxy *BaseProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) {
|
|||||||
func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWorkConn, encKey []byte) {
|
func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWorkConn, encKey []byte) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
baseCfg := pxy.baseCfg
|
baseCfg := pxy.baseCfg
|
||||||
var (
|
|
||||||
remote io.ReadWriteCloser
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
remote = workConn
|
|
||||||
if pxy.limiter != nil {
|
|
||||||
remote = libio.WrapReadWriteCloser(limit.NewReader(workConn, pxy.limiter), limit.NewWriter(workConn, pxy.limiter), func() error {
|
|
||||||
return workConn.Close()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
|
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
|
||||||
baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)
|
baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)
|
||||||
if baseCfg.Transport.UseEncryption {
|
|
||||||
remote, err = libio.WithEncryption(remote, encKey)
|
remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
workConn.Close()
|
xl.Errorf("wrap work connection: %v", err)
|
||||||
xl.Errorf("create encryption stream error: %v", err)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var compressionResourceRecycleFn func()
|
|
||||||
if baseCfg.Transport.UseCompression {
|
|
||||||
remote, compressionResourceRecycleFn = libio.WithCompressionFromPool(remote)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if we need to send proxy protocol info
|
// check if we need to send proxy protocol info
|
||||||
@@ -178,7 +190,6 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
|
|||||||
}
|
}
|
||||||
|
|
||||||
if baseCfg.Transport.ProxyProtocolVersion != "" && m.SrcAddr != "" && m.SrcPort != 0 {
|
if baseCfg.Transport.ProxyProtocolVersion != "" && m.SrcAddr != "" && m.SrcPort != 0 {
|
||||||
// Use the common proxy protocol builder function
|
|
||||||
header := netpkg.BuildProxyProtocolHeaderStruct(connInfo.SrcAddr, connInfo.DstAddr, baseCfg.Transport.ProxyProtocolVersion)
|
header := netpkg.BuildProxyProtocolHeaderStruct(connInfo.SrcAddr, connInfo.DstAddr, baseCfg.Transport.ProxyProtocolVersion)
|
||||||
connInfo.ProxyProtocolHeader = header
|
connInfo.ProxyProtocolHeader = header
|
||||||
}
|
}
|
||||||
@@ -187,12 +198,18 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
|
|||||||
|
|
||||||
if pxy.proxyPlugin != nil {
|
if pxy.proxyPlugin != nil {
|
||||||
// if plugin is set, let plugin handle connection first
|
// if plugin is set, let plugin handle connection first
|
||||||
|
// Don't recycle compression resources here because plugins may
|
||||||
|
// retain the connection after Handle returns.
|
||||||
xl.Debugf("handle by plugin: %s", pxy.proxyPlugin.Name())
|
xl.Debugf("handle by plugin: %s", pxy.proxyPlugin.Name())
|
||||||
pxy.proxyPlugin.Handle(pxy.ctx, &connInfo)
|
pxy.proxyPlugin.Handle(pxy.ctx, &connInfo)
|
||||||
xl.Debugf("handle by plugin finished")
|
xl.Debugf("handle by plugin finished")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if recycleFn != nil {
|
||||||
|
defer recycleFn()
|
||||||
|
}
|
||||||
|
|
||||||
localConn, err := libnet.Dial(
|
localConn, err := libnet.Dial(
|
||||||
net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort)),
|
net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort)),
|
||||||
libnet.WithTimeout(10*time.Second),
|
libnet.WithTimeout(10*time.Second),
|
||||||
@@ -209,6 +226,7 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
|
|||||||
if connInfo.ProxyProtocolHeader != nil {
|
if connInfo.ProxyProtocolHeader != nil {
|
||||||
if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {
|
if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {
|
||||||
workConn.Close()
|
workConn.Close()
|
||||||
|
localConn.Close()
|
||||||
xl.Errorf("write proxy protocol header to local conn error: %v", err)
|
xl.Errorf("write proxy protocol header to local conn error: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -219,7 +237,4 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
|
|||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
xl.Tracef("join connections errors: %v", errs)
|
xl.Tracef("join connections errors: %v", errs)
|
||||||
}
|
}
|
||||||
if compressionResourceRecycleFn != nil {
|
|
||||||
compressionResourceRecycleFn()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -25,17 +24,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/errors"
|
"github.com/fatedier/golib/errors"
|
||||||
libio "github.com/fatedier/golib/io"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/proto/udp"
|
"github.com/fatedier/frp/pkg/proto/udp"
|
||||||
"github.com/fatedier/frp/pkg/util/limit"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
RegisterProxyFactory(reflect.TypeOf(&v1.SUDPProxyConfig{}), NewSUDPProxy)
|
RegisterProxyFactory(reflect.TypeFor[*v1.SUDPProxyConfig](), NewSUDPProxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SUDPProxy struct {
|
type SUDPProxy struct {
|
||||||
@@ -83,27 +80,14 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
xl.Infof("incoming a new work connection for sudp proxy, %s", conn.RemoteAddr().String())
|
xl.Infof("incoming a new work connection for sudp proxy, %s", conn.RemoteAddr().String())
|
||||||
|
|
||||||
var rwc io.ReadWriteCloser = conn
|
remote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey)
|
||||||
var err error
|
if err != nil {
|
||||||
if pxy.limiter != nil {
|
xl.Errorf("wrap work connection: %v", err)
|
||||||
rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {
|
return
|
||||||
return conn.Close()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if pxy.cfg.Transport.UseEncryption {
|
|
||||||
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
xl.Errorf("create encryption stream error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pxy.cfg.Transport.UseCompression {
|
|
||||||
rwc = libio.WithCompression(rwc)
|
|
||||||
}
|
|
||||||
conn = netpkg.WrapReadWriteCloserToConn(rwc, conn)
|
|
||||||
|
|
||||||
workConn := conn
|
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||||
|
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
|
||||||
readCh := make(chan *msg.UDPPacket, 1024)
|
readCh := make(chan *msg.UDPPacket, 1024)
|
||||||
sendCh := make(chan msg.Message, 1024)
|
sendCh := make(chan msg.Message, 1024)
|
||||||
isClose := false
|
isClose := false
|
||||||
@@ -126,7 +110,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
// 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()
|
defer closeFn()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -139,7 +123,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var udpMsg msg.UDPPacket
|
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)
|
xl.Warnf("read from workConn for sudp error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -154,7 +138,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
// 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() {
|
defer func() {
|
||||||
closeFn()
|
closeFn()
|
||||||
xl.Infof("writer goroutine for sudp work connection closed")
|
xl.Infof("writer goroutine for sudp work connection closed")
|
||||||
@@ -165,12 +149,12 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
switch m := rawMsg.(type) {
|
switch m := rawMsg.(type) {
|
||||||
case *msg.UDPPacket:
|
case *msg.UDPPacket:
|
||||||
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
|
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:
|
case *msg.Ping:
|
||||||
xl.Tracef("frpc send ping message to frpc visitor")
|
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)
|
xl.Errorf("sudp work write error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -201,8 +185,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnSenderFn(workConn, sendCh)
|
go workConnSenderFn(payloadConn, sendCh)
|
||||||
go workConnReaderFn(workConn, readCh)
|
go workConnReaderFn(payloadConn, readCh)
|
||||||
go heartbeatFn(sendCh)
|
go heartbeatFn(sendCh)
|
||||||
|
|
||||||
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
||||||
|
|||||||
@@ -17,24 +17,21 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/errors"
|
"github.com/fatedier/golib/errors"
|
||||||
libio "github.com/fatedier/golib/io"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/proto/udp"
|
"github.com/fatedier/frp/pkg/proto/udp"
|
||||||
"github.com/fatedier/frp/pkg/util/limit"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
RegisterProxyFactory(reflect.TypeOf(&v1.UDPProxyConfig{}), NewUDPProxy)
|
RegisterProxyFactory(reflect.TypeFor[*v1.UDPProxyConfig](), NewUDPProxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UDPProxy struct {
|
type UDPProxy struct {
|
||||||
@@ -94,42 +91,30 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
// close resources related with old workConn
|
// close resources related with old workConn
|
||||||
pxy.Close()
|
pxy.Close()
|
||||||
|
|
||||||
var rwc io.ReadWriteCloser = conn
|
remote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey)
|
||||||
var err error
|
if err != nil {
|
||||||
if pxy.limiter != nil {
|
xl.Errorf("wrap work connection: %v", err)
|
||||||
rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {
|
return
|
||||||
return conn.Close()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if pxy.cfg.Transport.UseEncryption {
|
|
||||||
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
xl.Errorf("create encryption stream error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pxy.cfg.Transport.UseCompression {
|
|
||||||
rwc = libio.WithCompression(rwc)
|
|
||||||
}
|
|
||||||
conn = netpkg.WrapReadWriteCloserToConn(rwc, conn)
|
|
||||||
|
|
||||||
pxy.mu.Lock()
|
pxy.mu.Lock()
|
||||||
pxy.workConn = conn
|
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.readCh = make(chan *msg.UDPPacket, 1024)
|
||||||
pxy.sendCh = make(chan msg.Message, 1024)
|
pxy.sendCh = make(chan msg.Message, 1024)
|
||||||
pxy.closed = false
|
pxy.closed = false
|
||||||
pxy.mu.Unlock()
|
pxy.mu.Unlock()
|
||||||
|
|
||||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
|
||||||
for {
|
for {
|
||||||
var udpMsg msg.UDPPacket
|
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)
|
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errRet := errors.PanicToError(func() {
|
if errRet := errors.PanicToError(func() {
|
||||||
xl.Tracef("get udp package from workConn: %s", udpMsg.Content)
|
xl.Tracef("get udp package from workConn, len: %d", len(udpMsg.Content))
|
||||||
readCh <- &udpMsg
|
readCh <- &udpMsg
|
||||||
}); errRet != nil {
|
}); errRet != nil {
|
||||||
xl.Infof("reader goroutine for udp work connection closed: %v", errRet)
|
xl.Infof("reader goroutine for udp work connection closed: %v", errRet)
|
||||||
@@ -137,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() {
|
defer func() {
|
||||||
xl.Infof("writer goroutine for udp work connection closed")
|
xl.Infof("writer goroutine for udp work connection closed")
|
||||||
}()
|
}()
|
||||||
@@ -145,11 +130,11 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
for rawMsg := range sendCh {
|
for rawMsg := range sendCh {
|
||||||
switch m := rawMsg.(type) {
|
switch m := rawMsg.(type) {
|
||||||
case *msg.UDPPacket:
|
case *msg.UDPPacket:
|
||||||
xl.Tracef("send udp package to workConn: %s", m.Content)
|
xl.Tracef("send udp package to workConn, len: %d", len(m.Content))
|
||||||
case *msg.Ping:
|
case *msg.Ping:
|
||||||
xl.Tracef("send ping message to udp workConn")
|
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)
|
xl.Errorf("udp work write error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -168,8 +153,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnSenderFn(pxy.workConn, pxy.sendCh)
|
go workConnSenderFn(payloadRW, pxy.sendCh)
|
||||||
go workConnReaderFn(pxy.workConn, pxy.readCh)
|
go workConnReaderFn(payloadRW, pxy.readCh)
|
||||||
go heartbeatFn(pxy.sendCh)
|
go heartbeatFn(pxy.sendCh)
|
||||||
|
|
||||||
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
RegisterProxyFactory(reflect.TypeOf(&v1.XTCPProxyConfig{}), NewXTCPProxy)
|
RegisterProxyFactory(reflect.TypeFor[*v1.XTCPProxyConfig](), NewXTCPProxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
type XTCPProxy struct {
|
type XTCPProxy struct {
|
||||||
@@ -57,8 +57,7 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
|
|||||||
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
var natHoleSidMsg msg.NatHoleSid
|
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
|
||||||
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Errorf("xtcp read from workConn error: %v", err)
|
xl.Errorf("xtcp read from workConn error: %v", err)
|
||||||
return
|
return
|
||||||
@@ -131,6 +130,15 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
|
|||||||
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
|
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) {
|
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
|
||||||
xl := pxy.xl
|
xl := pxy.xl
|
||||||
listenConn.Close()
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,7 +37,6 @@ import (
|
|||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
"github.com/fatedier/frp/pkg/util/version"
|
|
||||||
"github.com/fatedier/frp/pkg/util/wait"
|
"github.com/fatedier/frp/pkg/util/wait"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/pkg/vnet"
|
"github.com/fatedier/frp/pkg/vnet"
|
||||||
@@ -162,15 +161,6 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var webServer *httppkg.Server
|
|
||||||
if options.Common.WebServer.Port > 0 {
|
|
||||||
ws, err := httppkg.NewServer(options.Common.WebServer)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
webServer = ws
|
|
||||||
}
|
|
||||||
|
|
||||||
authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
|
authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -191,6 +181,17 @@ func NewService(options ServiceOptions) (*Service, error) {
|
|||||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
||||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
||||||
|
|
||||||
|
// Create the web server after all fallible steps so its listener is not
|
||||||
|
// leaked when an earlier error causes NewService to return.
|
||||||
|
var webServer *httppkg.Server
|
||||||
|
if options.Common.WebServer.Port > 0 {
|
||||||
|
ws, err := httppkg.NewServer(options.Common.WebServer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webServer = ws
|
||||||
|
}
|
||||||
|
|
||||||
s := &Service{
|
s := &Service{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
auth: authRuntime,
|
auth: authRuntime,
|
||||||
@@ -229,22 +230,25 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if svr.vnetController != nil {
|
if svr.vnetController != nil {
|
||||||
|
vnetController := svr.vnetController
|
||||||
if err := svr.vnetController.Init(); err != nil {
|
if err := svr.vnetController.Init(); err != nil {
|
||||||
log.Errorf("init virtual network controller error: %v", err)
|
log.Errorf("init virtual network controller error: %v", err)
|
||||||
|
svr.stop()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
log.Infof("virtual network controller start...")
|
log.Infof("virtual network controller start...")
|
||||||
if err := svr.vnetController.Run(); err != nil {
|
if err := vnetController.Run(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||||
log.Warnf("virtual network controller exit with error: %v", err)
|
log.Warnf("virtual network controller exit with error: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if svr.webServer != nil {
|
if svr.webServer != nil {
|
||||||
|
webServer := svr.webServer
|
||||||
go func() {
|
go func() {
|
||||||
log.Infof("admin server listen on %s", svr.webServer.Address())
|
log.Infof("admin server listen on %s", webServer.Address())
|
||||||
if err := svr.webServer.Run(); err != nil {
|
if err := webServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Warnf("admin server exit with error: %v", err)
|
log.Warnf("admin server exit with error: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -255,6 +259,7 @@ func (svr *Service) Run(ctx context.Context) error {
|
|||||||
if svr.ctl == nil {
|
if svr.ctl == nil {
|
||||||
cancelCause := cancelErr{}
|
cancelCause := cancelErr{}
|
||||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
||||||
|
svr.stop()
|
||||||
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,80 +301,20 @@ func (svr *Service) keepControllerWorking() {
|
|||||||
), true, svr.ctx.Done())
|
), 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) {
|
func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginExit bool) {
|
||||||
xl := xlog.FromContextSafe(svr.ctx)
|
xl := xlog.FromContextSafe(svr.ctx)
|
||||||
|
|
||||||
loginFunc := func() (bool, error) {
|
loginFunc := func() (bool, error) {
|
||||||
xl.Infof("try to connect to server...")
|
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 {
|
if err != nil {
|
||||||
xl.Warnf("connect to server error: %v", err)
|
xl.Warnf("connect to server error: %v", err)
|
||||||
if firstLoginExit {
|
if firstLoginExit {
|
||||||
@@ -378,25 +323,19 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
|
|||||||
return false, err
|
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()
|
svr.cfgMu.RLock()
|
||||||
proxyCfgs := svr.proxyCfgs
|
proxyCfgs := svr.proxyCfgs
|
||||||
visitorCfgs := svr.visitorCfgs
|
visitorCfgs := svr.visitorCfgs
|
||||||
svr.cfgMu.RUnlock()
|
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)
|
ctl, err := NewControl(svr.ctx, sessionCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
sessionCtx.Conn.Close()
|
||||||
|
sessionCtx.Connector.Close()
|
||||||
xl.Errorf("new control error: %v", err)
|
xl.Errorf("new control error: %v", err)
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -497,6 +436,10 @@ func (svr *Service) stop() {
|
|||||||
svr.webServer.Close()
|
svr.webServer.Close()
|
||||||
svr.webServer = nil
|
svr.webServer = nil
|
||||||
}
|
}
|
||||||
|
if svr.vnetController != nil {
|
||||||
|
_ = svr.vnetController.Stop()
|
||||||
|
svr.vnetController = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
||||||
@@ -510,6 +453,17 @@ func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
|||||||
return ctl.pm.GetProxyStatus(name)
|
return ctl.pm.GetProxyStatus(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svr *Service) getVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
svr.ctlMu.RLock()
|
||||||
|
ctl := svr.ctl
|
||||||
|
svr.ctlMu.RUnlock()
|
||||||
|
|
||||||
|
if ctl == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return ctl.vm.GetVisitorCfg(name)
|
||||||
|
}
|
||||||
|
|
||||||
func (svr *Service) StatusExporter() StatusExporter {
|
func (svr *Service) StatusExporter() StatusExporter {
|
||||||
return &statusExporterImpl{
|
return &statusExporterImpl{
|
||||||
getProxyStatusFunc: svr.getProxyStatus,
|
getProxyStatusFunc: svr.getProxyStatus,
|
||||||
|
|||||||
@@ -1,14 +1,120 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/samber/lo"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/source"
|
"github.com/fatedier/frp/pkg/config/source"
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type failingConnector struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *failingConnector) Open() error {
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *failingConnector) Connect() (net.Conn, error) {
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *failingConnector) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFreeTCPPort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen on ephemeral port: %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
return ln.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
|
||||||
|
port := getFreeTCPPort(t)
|
||||||
|
agg := source.NewAggregator(source.NewConfigSource())
|
||||||
|
|
||||||
|
svr, err := NewService(ServiceOptions{
|
||||||
|
Common: &v1.ClientCommonConfig{
|
||||||
|
LoginFailExit: lo.ToPtr(true),
|
||||||
|
WebServer: v1.WebServerConfig{
|
||||||
|
Addr: "127.0.0.1",
|
||||||
|
Port: port,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ConfigSourceAggregator: agg,
|
||||||
|
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||||
|
return &failingConnector{err: errors.New("login boom")}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = svr.Run(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected run error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "login boom") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if svr.webServer != nil {
|
||||||
|
t.Fatal("expected web server to be cleaned up after initial login failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected admin port to be released: %v", err)
|
||||||
|
}
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServiceDoesNotLeakAdminListenerOnAuthBuildFailure(t *testing.T) {
|
||||||
|
port := getFreeTCPPort(t)
|
||||||
|
agg := source.NewAggregator(source.NewConfigSource())
|
||||||
|
|
||||||
|
_, err := NewService(ServiceOptions{
|
||||||
|
Common: &v1.ClientCommonConfig{
|
||||||
|
Auth: v1.AuthClientConfig{
|
||||||
|
Method: v1.AuthMethodOIDC,
|
||||||
|
OIDC: v1.AuthOIDCClientConfig{
|
||||||
|
TokenEndpointURL: "://bad",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
WebServer: v1.WebServerConfig{
|
||||||
|
Addr: "127.0.0.1",
|
||||||
|
Port: port,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ConfigSourceAggregator: agg,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected new service error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "auth.oidc.tokenEndpointURL") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected admin port to remain free: %v", err)
|
||||||
|
}
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {
|
func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {
|
||||||
prevCommon := &v1.ClientCommonConfig{User: "old-user"}
|
prevCommon := &v1.ClientCommonConfig{User: "old-user"}
|
||||||
newCommon := &v1.ClientCommonConfig{User: "new-user"}
|
newCommon := &v1.ClientCommonConfig{User: "new-user"}
|
||||||
|
|||||||
@@ -15,18 +15,12 @@
|
|||||||
package visitor
|
package visitor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
libio "github.com/fatedier/golib/io"
|
libio "github.com/fatedier/golib/io"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
|
||||||
"github.com/fatedier/frp/pkg/naming"
|
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,10 +36,10 @@ func (sv *STCPVisitor) Run() (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go sv.worker()
|
go sv.acceptLoop(sv.l, "stcp local", sv.handleConn)
|
||||||
}
|
}
|
||||||
|
|
||||||
go sv.internalConnWorker()
|
go sv.acceptLoop(sv.internalLn, "stcp internal", sv.handleConn)
|
||||||
|
|
||||||
if sv.plugin != nil {
|
if sv.plugin != nil {
|
||||||
sv.plugin.Start()
|
sv.plugin.Start()
|
||||||
@@ -57,35 +51,10 @@ func (sv *STCPVisitor) Close() {
|
|||||||
sv.BaseVisitor.Close()
|
sv.BaseVisitor.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sv *STCPVisitor) worker() {
|
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
|
||||||
for {
|
|
||||||
conn, err := sv.l.Accept()
|
|
||||||
if err != nil {
|
|
||||||
xl.Warnf("stcp local listener closed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go sv.handleConn(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sv *STCPVisitor) internalConnWorker() {
|
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
|
||||||
for {
|
|
||||||
conn, err := sv.internalLn.Accept()
|
|
||||||
if err != nil {
|
|
||||||
xl.Warnf("stcp internal listener closed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go sv.handleConn(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sv *STCPVisitor) handleConn(userConn net.Conn) {
|
func (sv *STCPVisitor) handleConn(userConn net.Conn) {
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
xl := xlog.FromContextSafe(sv.ctx)
|
||||||
var tunnelErr error
|
var tunnelErr error
|
||||||
defer func() {
|
defer func() {
|
||||||
// If there was an error and connection supports CloseWithError, use it
|
|
||||||
if tunnelErr != nil {
|
if tunnelErr != nil {
|
||||||
if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok {
|
if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok {
|
||||||
_ = eConn.CloseWithError(tunnelErr)
|
_ = eConn.CloseWithError(tunnelErr)
|
||||||
@@ -96,62 +65,21 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
xl.Debugf("get a new stcp user connection")
|
xl.Debugf("get a new stcp user connection")
|
||||||
visitorConn, err := sv.helper.ConnectServer()
|
visitorConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
xl.Warnf("dialRawVisitorConn error: %v", err)
|
||||||
tunnelErr = err
|
tunnelErr = err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer visitorConn.Close()
|
defer visitorConn.Close()
|
||||||
|
|
||||||
now := time.Now().Unix()
|
remote, recycleFn, err := wrapVisitorConn(visitorConn, sv.cfg.GetBaseConfig())
|
||||||
targetProxyName := naming.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
|
|
||||||
newVisitorConnMsg := &msg.NewVisitorConn{
|
|
||||||
RunID: sv.helper.RunID(),
|
|
||||||
ProxyName: targetProxyName,
|
|
||||||
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
|
||||||
Timestamp: now,
|
|
||||||
UseEncryption: sv.cfg.Transport.UseEncryption,
|
|
||||||
UseCompression: sv.cfg.Transport.UseCompression,
|
|
||||||
}
|
|
||||||
err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("send newVisitorConnMsg to server error: %v", err)
|
xl.Warnf("wrapVisitorConn error: %v", err)
|
||||||
tunnelErr = err
|
tunnelErr = err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer recycleFn()
|
||||||
var newVisitorConnRespMsg msg.NewVisitorConnResp
|
|
||||||
_ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
||||||
err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
|
|
||||||
if err != nil {
|
|
||||||
xl.Warnf("get newVisitorConnRespMsg error: %v", err)
|
|
||||||
tunnelErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = visitorConn.SetReadDeadline(time.Time{})
|
|
||||||
|
|
||||||
if newVisitorConnRespMsg.Error != "" {
|
|
||||||
xl.Warnf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
|
|
||||||
tunnelErr = fmt.Errorf("%s", newVisitorConnRespMsg.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var remote io.ReadWriteCloser
|
|
||||||
remote = visitorConn
|
|
||||||
if sv.cfg.Transport.UseEncryption {
|
|
||||||
remote, err = libio.WithEncryption(remote, []byte(sv.cfg.SecretKey))
|
|
||||||
if err != nil {
|
|
||||||
xl.Errorf("create encryption stream error: %v", err)
|
|
||||||
tunnelErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sv.cfg.Transport.UseCompression {
|
|
||||||
var recycleFn func()
|
|
||||||
remote, recycleFn = libio.WithCompressionFromPool(remote)
|
|
||||||
defer recycleFn()
|
|
||||||
}
|
|
||||||
|
|
||||||
libio.Join(userConn, remote)
|
libio.Join(userConn, remote)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,21 +16,17 @@ package visitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatedier/golib/errors"
|
"github.com/fatedier/golib/errors"
|
||||||
libio "github.com/fatedier/golib/io"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/naming"
|
|
||||||
"github.com/fatedier/frp/pkg/proto/udp"
|
"github.com/fatedier/frp/pkg/proto/udp"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
"github.com/fatedier/frp/pkg/util/util"
|
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,6 +72,7 @@ func (sv *SUDPVisitor) dispatcher() {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
visitorConn net.Conn
|
visitorConn net.Conn
|
||||||
|
recycleFn func()
|
||||||
err error
|
err error
|
||||||
|
|
||||||
firstPacket *msg.UDPPacket
|
firstPacket *msg.UDPPacket
|
||||||
@@ -93,14 +90,17 @@ func (sv *SUDPVisitor) dispatcher() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
visitorConn, err = sv.getNewVisitorConn()
|
visitorConn, recycleFn, err = sv.getNewVisitorConn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("newVisitorConn to frps error: %v, try to reconnect", err)
|
xl.Warnf("newVisitorConn to frps error: %v, try to reconnect", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// visitorConn always be closed when worker done.
|
// visitorConn always be closed when worker done.
|
||||||
sv.worker(visitorConn, firstPacket)
|
func() {
|
||||||
|
defer recycleFn()
|
||||||
|
sv.worker(visitorConn, firstPacket)
|
||||||
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-sv.checkCloseCh:
|
case <-sv.checkCloseCh:
|
||||||
@@ -113,15 +113,16 @@ func (sv *SUDPVisitor) dispatcher() {
|
|||||||
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
xl := xlog.FromContextSafe(sv.ctx)
|
||||||
xl.Debugf("starting sudp proxy worker")
|
xl.Debugf("starting sudp proxy worker")
|
||||||
|
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
|
||||||
|
|
||||||
wg := &sync.WaitGroup{}
|
wg := &sync.WaitGroup{}
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
closeCh := make(chan struct{})
|
closeCh := make(chan struct{})
|
||||||
|
|
||||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||||
workConnReaderFn := func(conn net.Conn) {
|
workConnReaderFn := func(payloadConn *msg.Conn) {
|
||||||
defer func() {
|
defer func() {
|
||||||
conn.Close()
|
payloadConn.Close()
|
||||||
close(closeCh)
|
close(closeCh)
|
||||||
wg.Done()
|
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
|
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||||
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = conn.SetReadDeadline(time.Time{})
|
_ = payloadConn.SetReadDeadline(time.Time{})
|
||||||
switch m := rawMsg.(type) {
|
switch m := rawMsg.(type) {
|
||||||
case *msg.Ping:
|
case *msg.Ping:
|
||||||
xl.Debugf("frpc visitor get ping message from frpc")
|
xl.Debugf("frpc visitor get ping message from frpc")
|
||||||
@@ -147,7 +148,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
case *msg.UDPPacket:
|
case *msg.UDPPacket:
|
||||||
if errRet := errors.PanicToError(func() {
|
if errRet := errors.PanicToError(func() {
|
||||||
sv.readCh <- m
|
sv.readCh <- m
|
||||||
xl.Tracef("frpc visitor get udp packet from workConn: %s", m.Content)
|
xl.Tracef("frpc visitor get udp packet from workConn, len: %d", len(m.Content))
|
||||||
}); errRet != nil {
|
}); errRet != nil {
|
||||||
xl.Infof("reader goroutine for udp work connection closed")
|
xl.Infof("reader goroutine for udp work connection closed")
|
||||||
return
|
return
|
||||||
@@ -157,19 +158,19 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||||
workConnSenderFn := func(conn net.Conn) {
|
workConnSenderFn := func(payloadConn *msg.Conn) {
|
||||||
defer func() {
|
defer func() {
|
||||||
conn.Close()
|
payloadConn.Close()
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var errRet error
|
var errRet error
|
||||||
if firstPacket != nil {
|
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)
|
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
xl.Tracef("send udp package to workConn: %s", firstPacket.Content)
|
xl.Tracef("send udp package to workConn, len: %d", len(firstPacket.Content))
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -180,71 +181,35 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
|||||||
return
|
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)
|
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
xl.Tracef("send udp package to workConn: %s", udpMsg.Content)
|
xl.Tracef("send udp package to workConn, len: %d", len(udpMsg.Content))
|
||||||
case <-closeCh:
|
case <-closeCh:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go workConnReaderFn(workConn)
|
go workConnReaderFn(payloadConn)
|
||||||
go workConnSenderFn(workConn)
|
go workConnSenderFn(payloadConn)
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
xl.Infof("sudp worker is closed")
|
xl.Infof("sudp worker is closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) {
|
func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, func(), error) {
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
rawConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig())
|
||||||
visitorConn, err := sv.helper.ConnectServer()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("frpc connect frps error: %v", err)
|
return nil, func() {}, err
|
||||||
}
|
}
|
||||||
|
rwc, recycleFn, err := wrapVisitorConn(rawConn, sv.cfg.GetBaseConfig())
|
||||||
now := time.Now().Unix()
|
|
||||||
targetProxyName := naming.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
|
|
||||||
newVisitorConnMsg := &msg.NewVisitorConn{
|
|
||||||
RunID: sv.helper.RunID(),
|
|
||||||
ProxyName: targetProxyName,
|
|
||||||
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
|
||||||
Timestamp: now,
|
|
||||||
UseEncryption: sv.cfg.Transport.UseEncryption,
|
|
||||||
UseCompression: sv.cfg.Transport.UseCompression,
|
|
||||||
}
|
|
||||||
err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("frpc send newVisitorConnMsg to frps error: %v", err)
|
rawConn.Close()
|
||||||
|
return nil, func() {}, err
|
||||||
}
|
}
|
||||||
|
return netpkg.WrapReadWriteCloserToConn(rwc, rawConn), recycleFn, nil
|
||||||
var newVisitorConnRespMsg msg.NewVisitorConnResp
|
|
||||||
_ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
||||||
err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("frpc read newVisitorConnRespMsg error: %v", err)
|
|
||||||
}
|
|
||||||
_ = visitorConn.SetReadDeadline(time.Time{})
|
|
||||||
|
|
||||||
if newVisitorConnRespMsg.Error != "" {
|
|
||||||
return nil, fmt.Errorf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
var remote io.ReadWriteCloser
|
|
||||||
remote = visitorConn
|
|
||||||
if sv.cfg.Transport.UseEncryption {
|
|
||||||
remote, err = libio.WithEncryption(remote, []byte(sv.cfg.SecretKey))
|
|
||||||
if err != nil {
|
|
||||||
xl.Errorf("create encryption stream error: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sv.cfg.Transport.UseCompression {
|
|
||||||
remote = libio.WithCompression(remote)
|
|
||||||
}
|
|
||||||
return netpkg.WrapReadWriteCloserToConn(remote, visitorConn), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sv *SUDPVisitor) Close() {
|
func (sv *SUDPVisitor) Close() {
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ package visitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
libio "github.com/fatedier/golib/io"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
|
"github.com/fatedier/frp/pkg/naming"
|
||||||
plugin "github.com/fatedier/frp/pkg/plugin/visitor"
|
plugin "github.com/fatedier/frp/pkg/plugin/visitor"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
|
"github.com/fatedier/frp/pkg/util/util"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/pkg/vnet"
|
"github.com/fatedier/frp/pkg/vnet"
|
||||||
)
|
)
|
||||||
@@ -30,7 +38,7 @@ import (
|
|||||||
// Helper wraps some functions for visitor to use.
|
// Helper wraps some functions for visitor to use.
|
||||||
type Helper interface {
|
type Helper interface {
|
||||||
// ConnectServer directly connects to the frp server.
|
// ConnectServer directly connects to the frp server.
|
||||||
ConnectServer() (net.Conn, error)
|
ConnectServer() (*msg.Conn, error)
|
||||||
// TransferConn transfers the connection to another visitor.
|
// TransferConn transfers the connection to another visitor.
|
||||||
TransferConn(string, net.Conn) error
|
TransferConn(string, net.Conn) error
|
||||||
// MsgTransporter returns the message transporter that is used to send and receive messages
|
// MsgTransporter returns the message transporter that is used to send and receive messages
|
||||||
@@ -119,6 +127,18 @@ func (v *BaseVisitor) AcceptConn(conn net.Conn) error {
|
|||||||
return v.internalLn.PutConn(conn)
|
return v.internalLn.PutConn(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *BaseVisitor) acceptLoop(l net.Listener, name string, handleConn func(net.Conn)) {
|
||||||
|
xl := xlog.FromContextSafe(v.ctx)
|
||||||
|
for {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
xl.Warnf("%s listener closed", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go handleConn(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (v *BaseVisitor) Close() {
|
func (v *BaseVisitor) Close() {
|
||||||
if v.l != nil {
|
if v.l != nil {
|
||||||
v.l.Close()
|
v.l.Close()
|
||||||
@@ -130,3 +150,57 @@ func (v *BaseVisitor) Close() {
|
|||||||
v.plugin.Close()
|
v.plugin.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *BaseVisitor) dialRawVisitorConn(cfg *v1.VisitorBaseConfig) (net.Conn, error) {
|
||||||
|
visitorConn, err := v.helper.ConnectServer()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to server error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
targetProxyName := naming.BuildTargetServerProxyName(v.clientCfg.User, cfg.ServerUser, cfg.ServerName)
|
||||||
|
newVisitorConnMsg := &msg.NewVisitorConn{
|
||||||
|
RunID: v.helper.RunID(),
|
||||||
|
ProxyName: targetProxyName,
|
||||||
|
SignKey: util.GetAuthKey(cfg.SecretKey, now),
|
||||||
|
Timestamp: now,
|
||||||
|
UseEncryption: cfg.Transport.UseEncryption,
|
||||||
|
UseCompression: cfg.Transport.UseCompression,
|
||||||
|
}
|
||||||
|
err = visitorConn.WriteMsg(newVisitorConnMsg)
|
||||||
|
if err != nil {
|
||||||
|
visitorConn.Close()
|
||||||
|
return nil, fmt.Errorf("send newVisitorConnMsg to server error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
var newVisitorConnRespMsg msg.NewVisitorConnResp
|
||||||
|
err = visitorConn.ReadMsgInto(&newVisitorConnRespMsg)
|
||||||
|
if err != nil {
|
||||||
|
visitorConn.Close()
|
||||||
|
return nil, fmt.Errorf("read newVisitorConnRespMsg error: %v", err)
|
||||||
|
}
|
||||||
|
_ = visitorConn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
if newVisitorConnRespMsg.Error != "" {
|
||||||
|
visitorConn.Close()
|
||||||
|
return nil, fmt.Errorf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
|
||||||
|
}
|
||||||
|
return visitorConn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapVisitorConn(conn io.ReadWriteCloser, cfg *v1.VisitorBaseConfig) (io.ReadWriteCloser, func(), error) {
|
||||||
|
rwc := conn
|
||||||
|
if cfg.Transport.UseEncryption {
|
||||||
|
var err error
|
||||||
|
rwc, err = libio.WithEncryption(rwc, []byte(cfg.SecretKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, fmt.Errorf("create encryption stream error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recycleFn := func() {}
|
||||||
|
if cfg.Transport.UseCompression {
|
||||||
|
rwc, recycleFn = libio.WithCompressionFromPool(rwc)
|
||||||
|
}
|
||||||
|
return rwc, recycleFn, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
"github.com/fatedier/frp/pkg/vnet"
|
"github.com/fatedier/frp/pkg/vnet"
|
||||||
@@ -49,7 +50,7 @@ func NewManager(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
runID string,
|
runID string,
|
||||||
clientCfg *v1.ClientCommonConfig,
|
clientCfg *v1.ClientCommonConfig,
|
||||||
connectServer func() (net.Conn, error),
|
connectServer func() (*msg.Conn, error),
|
||||||
msgTransporter transport.MessageTransporter,
|
msgTransporter transport.MessageTransporter,
|
||||||
vnetController *vnet.Controller,
|
vnetController *vnet.Controller,
|
||||||
) *Manager {
|
) *Manager {
|
||||||
@@ -191,15 +192,22 @@ func (vm *Manager) TransferConn(name string, conn net.Conn) error {
|
|||||||
return v.AcceptConn(conn)
|
return v.AcceptConn(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
|
||||||
|
vm.mu.RLock()
|
||||||
|
defer vm.mu.RUnlock()
|
||||||
|
cfg, ok := vm.cfgs[name]
|
||||||
|
return cfg, ok
|
||||||
|
}
|
||||||
|
|
||||||
type visitorHelperImpl struct {
|
type visitorHelperImpl struct {
|
||||||
connectServerFn func() (net.Conn, error)
|
connectServerFn func() (*msg.Conn, error)
|
||||||
msgTransporter transport.MessageTransporter
|
msgTransporter transport.MessageTransporter
|
||||||
vnetController *vnet.Controller
|
vnetController *vnet.Controller
|
||||||
transferConnFn func(name string, conn net.Conn) error
|
transferConnFn func(name string, conn net.Conn) error
|
||||||
runID string
|
runID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitorHelperImpl) ConnectServer() (net.Conn, error) {
|
func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
|
||||||
return v.connectServerFn()
|
return v.connectServerFn()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ func (sv *XTCPVisitor) Run() (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go sv.worker()
|
go sv.acceptLoop(sv.l, "xtcp local", sv.handleConn)
|
||||||
}
|
}
|
||||||
|
|
||||||
go sv.internalConnWorker()
|
go sv.acceptLoop(sv.internalLn, "xtcp internal", sv.handleConn)
|
||||||
go sv.processTunnelStartEvents()
|
go sv.processTunnelStartEvents()
|
||||||
if sv.cfg.KeepTunnelOpen {
|
if sv.cfg.KeepTunnelOpen {
|
||||||
sv.retryLimiter = rate.NewLimiter(rate.Every(time.Hour/time.Duration(sv.cfg.MaxRetriesAnHour)), sv.cfg.MaxRetriesAnHour)
|
sv.retryLimiter = rate.NewLimiter(rate.Every(time.Hour/time.Duration(sv.cfg.MaxRetriesAnHour)), sv.cfg.MaxRetriesAnHour)
|
||||||
@@ -93,30 +93,6 @@ func (sv *XTCPVisitor) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sv *XTCPVisitor) worker() {
|
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
|
||||||
for {
|
|
||||||
conn, err := sv.l.Accept()
|
|
||||||
if err != nil {
|
|
||||||
xl.Warnf("xtcp local listener closed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go sv.handleConn(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sv *XTCPVisitor) internalConnWorker() {
|
|
||||||
xl := xlog.FromContextSafe(sv.ctx)
|
|
||||||
for {
|
|
||||||
conn, err := sv.internalLn.Accept()
|
|
||||||
if err != nil {
|
|
||||||
xl.Warnf("xtcp internal listener closed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go sv.handleConn(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sv *XTCPVisitor) processTunnelStartEvents() {
|
func (sv *XTCPVisitor) processTunnelStartEvents() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -206,20 +182,14 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var muxConnRWCloser io.ReadWriteCloser = tunnelConn
|
muxConnRWCloser, recycleFn, err := wrapVisitorConn(tunnelConn, sv.cfg.GetBaseConfig())
|
||||||
if sv.cfg.Transport.UseEncryption {
|
if err != nil {
|
||||||
muxConnRWCloser, err = libio.WithEncryption(muxConnRWCloser, []byte(sv.cfg.SecretKey))
|
xl.Errorf("%v", err)
|
||||||
if err != nil {
|
tunnelConn.Close()
|
||||||
xl.Errorf("create encryption stream error: %v", err)
|
tunnelErr = err
|
||||||
tunnelErr = err
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sv.cfg.Transport.UseCompression {
|
|
||||||
var recycleFn func()
|
|
||||||
muxConnRWCloser, recycleFn = libio.WithCompressionFromPool(muxConnRWCloser)
|
|
||||||
defer recycleFn()
|
|
||||||
}
|
}
|
||||||
|
defer recycleFn()
|
||||||
|
|
||||||
_, _, errs := libio.Join(userConn, muxConnRWCloser)
|
_, _, errs := libio.Join(userConn, muxConnRWCloser)
|
||||||
xl.Debugf("join connections closed")
|
xl.Debugf("join connections closed")
|
||||||
@@ -373,6 +343,7 @@ func (ks *KCPTunnelSession) Init(listenConn *net.UDPConn, raddr *net.UDPAddr) er
|
|||||||
}
|
}
|
||||||
remote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String())
|
remote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
lConn.Close()
|
||||||
return fmt.Errorf("create kcp connection from udp connection error: %v", err)
|
return fmt.Errorf("create kcp connection from udp connection error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ var natholeDiscoveryCmd = &cobra.Command{
|
|||||||
Use: "discover",
|
Use: "discover",
|
||||||
Short: "Discover nathole information from stun server",
|
Short: "Discover nathole information from stun server",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// ignore error here, because we can use command line pameters
|
// ignore error here, because we can use command line parameters
|
||||||
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cfg = &v1.ClientCommonConfig{}
|
cfg = &v1.ClientCommonConfig{}
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ transport.poolCount = 5
|
|||||||
# supports tcp, kcp, quic, websocket and wss now, default is tcp
|
# supports tcp, kcp, quic, websocket and wss now, default is tcp
|
||||||
transport.protocol = "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.
|
# set client binding ip when connect server, default is empty.
|
||||||
# only when protocol = tcp or websocket, the value will be used.
|
# only when protocol = tcp or websocket, the value will be used.
|
||||||
transport.connectServerLocalIP = "0.0.0.0"
|
transport.connectServerLocalIP = "0.0.0.0"
|
||||||
|
|||||||
124
doc/agents/release.md
Normal file
124
doc/agents/release.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Release Process
|
||||||
|
|
||||||
|
## 1. Update Release Notes
|
||||||
|
|
||||||
|
Edit `Release.md` in the project root with the changes for this version:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Features
|
||||||
|
* ...
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
* ...
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
* ...
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is used by GoReleaser as the GitHub Release body.
|
||||||
|
|
||||||
|
## 2. Bump Version
|
||||||
|
|
||||||
|
Update the version string in `pkg/util/version/version.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var version = "0.X.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit and push to `dev`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pkg/util/version/version.go Release.md
|
||||||
|
git commit -m "bump version to vX.Y.Z"
|
||||||
|
git push origin dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --base master --head dev --title "bump version"
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait for CI to pass, then merge using **merge commit** (not squash).
|
||||||
|
|
||||||
|
## 5. Tag the Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git pull origin master
|
||||||
|
git tag -a vX.Y.Z -m "bump version"
|
||||||
|
git push origin vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Trigger GoReleaser
|
||||||
|
|
||||||
|
Manually trigger the `goreleaser` workflow in GitHub Actions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh workflow run goreleaser --ref master
|
||||||
|
```
|
||||||
|
|
||||||
|
GoReleaser will:
|
||||||
|
1. Run `package.sh` to cross-compile all platforms and create archives
|
||||||
|
2. Create a GitHub Release with all packages, using `Release.md` as release notes
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `pkg/util/version/version.go` | Version string |
|
||||||
|
| `Release.md` | Release notes (read by GoReleaser) |
|
||||||
|
| `.goreleaser.yml` | GoReleaser config |
|
||||||
|
| `package.sh` | Cross-compile and packaging script |
|
||||||
|
| `.github/workflows/goreleaser.yml` | GitHub Actions workflow (manual trigger) |
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- Minor release: `v0.X.0`
|
||||||
|
- Patch release: `v0.X.Y` (e.g., `v0.62.1`)
|
||||||
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
|
FROM node:22 AS web-builder
|
||||||
|
|
||||||
WORKDIR /web/frpc
|
COPY web/package.json /web/package.json
|
||||||
COPY web/frpc/ ./
|
COPY web/shared/ /web/shared/
|
||||||
|
COPY web/frpc/ /web/frpc/
|
||||||
|
WORKDIR /web
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
WORKDIR /web/frpc
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM golang:1.25 AS building
|
FROM golang:1.25 AS building
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
FROM node:22 AS web-builder
|
FROM node:22 AS web-builder
|
||||||
|
|
||||||
WORKDIR /web/frps
|
COPY web/package.json /web/package.json
|
||||||
COPY web/frps/ ./
|
COPY web/shared/ /web/shared/
|
||||||
|
COPY web/frps/ /web/frps/
|
||||||
|
WORKDIR /web
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
WORKDIR /web/frps
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM golang:1.25 AS building
|
FROM golang:1.25 AS building
|
||||||
|
|||||||
34
go.mod
34
go.mod
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/coreos/go-oidc/v3 v3.14.1
|
||||||
github.com/fatedier/golib v0.5.1
|
github.com/fatedier/golib v0.7.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
github.com/onsi/ginkgo/v2 v2.23.4
|
github.com/onsi/ginkgo/v2 v2.23.4
|
||||||
github.com/onsi/gomega v1.36.3
|
github.com/onsi/gomega v1.36.3
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0
|
github.com/pelletier/go-toml/v2 v2.2.0
|
||||||
github.com/pion/stun/v2 v2.0.0
|
github.com/pion/stun/v3 v3.1.1
|
||||||
github.com/pires/go-proxyproto v0.7.0
|
github.com/pires/go-proxyproto v0.7.0
|
||||||
github.com/prometheus/client_golang v1.19.1
|
github.com/prometheus/client_golang v1.19.1
|
||||||
github.com/quic-go/quic-go v0.55.0
|
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/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||||
github.com/spf13/cobra v1.8.0
|
github.com/spf13/cobra v1.8.0
|
||||||
github.com/spf13/pflag v1.0.5
|
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/tidwall/gjson v1.17.1
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13
|
github.com/xtaci/kcp-go/v5 v5.6.13
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/net v0.43.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/oauth2 v0.28.0
|
golang.org/x/oauth2 v0.28.0
|
||||||
golang.org/x/sync v0.16.0
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/time v0.5.0
|
golang.org/x/sys v0.42.0
|
||||||
|
golang.org/x/time v0.10.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
k8s.io/apimachinery v0.28.8
|
k8s.io/apimachinery v0.28.8
|
||||||
k8s.io/client-go v0.28.8
|
k8s.io/client-go v0.28.8
|
||||||
|
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
@@ -51,10 +53,9 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||||
github.com/pion/dtls/v2 v2.2.7 // indirect
|
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||||
github.com/pion/logging v0.2.2 // indirect
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
github.com/pion/transport/v2 v2.2.1 // indirect
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
github.com/pion/transport/v3 v3.0.1 // indirect
|
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
github.com/prometheus/client_model v0.5.0 // indirect
|
||||||
@@ -66,16 +67,15 @@ require (
|
|||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
golang.org/x/mod v0.27.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect
|
|
||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
105
go.sum
105
go.sum
@@ -1,6 +1,6 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M=
|
github.com/fatedier/golib v0.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
||||||
github.com/fatedier/golib v0.5.1/go.mod h1:W6kIYkIFxHsTzbgqg5piCxIiDo4LzwgTY6R5W8l9NFQ=
|
github.com/fatedier/golib v0.7.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
@@ -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/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 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
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/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
|
||||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||||
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
|
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||||
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
|
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||||
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
|
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||||
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/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
|
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/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
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/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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.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.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.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
|
||||||
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
||||||
github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
|
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/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 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
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 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
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 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=
|
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=
|
||||||
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 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
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/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
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/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-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-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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
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/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
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/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-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
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/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
|||||||
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
|
||||||
111
pkg/auth/oidc.go
111
pkg/auth/oidc.go
@@ -23,12 +23,14 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/clientcredentials"
|
"golang.org/x/oauth2/clientcredentials"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,14 +76,64 @@ func createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyUR
|
|||||||
return &http.Client{Transport: transport}, nil
|
return &http.Client{Transport: transport}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nonCachingTokenSource wraps a clientcredentials.Config to fetch a fresh
|
||||||
|
// token on every call. This is used as a fallback when the OIDC provider
|
||||||
|
// does not return expires_in, which would cause a caching TokenSource to
|
||||||
|
// hold onto a stale token forever.
|
||||||
|
type nonCachingTokenSource struct {
|
||||||
|
cfg *clientcredentials.Config
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nonCachingTokenSource) Token() (*oauth2.Token, error) {
|
||||||
|
return s.cfg.Token(s.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidcTokenSource wraps a caching oauth2.TokenSource and, on the first
|
||||||
|
// successful Token() call, checks whether the provider returns an expiry.
|
||||||
|
// If not, it permanently switches to nonCachingTokenSource so that a fresh
|
||||||
|
// token is fetched every time. This avoids an eager network call at
|
||||||
|
// construction time, letting the login retry loop handle transient IdP
|
||||||
|
// outages.
|
||||||
|
type oidcTokenSource struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
initialized bool
|
||||||
|
source oauth2.TokenSource
|
||||||
|
fallbackCfg *clientcredentials.Config
|
||||||
|
fallbackCtx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *oidcTokenSource) Token() (*oauth2.Token, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.initialized {
|
||||||
|
token, err := s.source.Token()
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if token.Expiry.IsZero() {
|
||||||
|
s.source = &nonCachingTokenSource{cfg: s.fallbackCfg, ctx: s.fallbackCtx}
|
||||||
|
}
|
||||||
|
s.initialized = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
source := s.source
|
||||||
|
s.mu.Unlock()
|
||||||
|
return source.Token()
|
||||||
|
}
|
||||||
|
|
||||||
type OidcAuthProvider struct {
|
type OidcAuthProvider struct {
|
||||||
additionalAuthScopes []v1.AuthScope
|
additionalAuthScopes []v1.AuthScope
|
||||||
|
|
||||||
tokenGenerator *clientcredentials.Config
|
tokenSource oauth2.TokenSource
|
||||||
httpClient *http.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
|
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
|
||||||
|
if err := validation.ValidateOIDCClientCredentialsConfig(&cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
eps := make(map[string][]string)
|
eps := make(map[string][]string)
|
||||||
for k, v := range cfg.AdditionalEndpointParams {
|
for k, v := range cfg.AdditionalEndpointParams {
|
||||||
eps[k] = []string{v}
|
eps[k] = []string{v}
|
||||||
@@ -99,30 +151,42 @@ func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClien
|
|||||||
EndpointParams: eps,
|
EndpointParams: eps,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create custom HTTP client if needed
|
// Build the context that TokenSource will use for all future HTTP requests.
|
||||||
var httpClient *http.Client
|
// context.Background() is appropriate here because the token source is
|
||||||
|
// long-lived and outlives any single request.
|
||||||
|
ctx := context.Background()
|
||||||
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
|
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
|
||||||
var err error
|
httpClient, err := createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
|
||||||
httpClient, err = createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
|
return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a persistent TokenSource that caches the token and refreshes
|
||||||
|
// it before expiry. This avoids making a new HTTP request to the OIDC
|
||||||
|
// provider on every heartbeat/ping.
|
||||||
|
//
|
||||||
|
// We wrap it in an oidcTokenSource so that the first Token() call
|
||||||
|
// (deferred to SetLogin inside the login retry loop) probes whether the
|
||||||
|
// provider returns expires_in. If not, it switches to a non-caching
|
||||||
|
// source. This avoids an eager network call at construction time, which
|
||||||
|
// would prevent loopLoginUntilSuccess from retrying on transient IdP
|
||||||
|
// outages.
|
||||||
|
cachingSource := tokenGenerator.TokenSource(ctx)
|
||||||
|
|
||||||
return &OidcAuthProvider{
|
return &OidcAuthProvider{
|
||||||
additionalAuthScopes: additionalAuthScopes,
|
additionalAuthScopes: additionalAuthScopes,
|
||||||
tokenGenerator: tokenGenerator,
|
tokenSource: &oidcTokenSource{
|
||||||
httpClient: httpClient,
|
source: cachingSource,
|
||||||
|
fallbackCfg: tokenGenerator,
|
||||||
|
fallbackCtx: ctx,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
|
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
|
||||||
ctx := context.Background()
|
tokenObj, err := auth.tokenSource.Token()
|
||||||
if auth.httpClient != nil {
|
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, auth.httpClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenObj, err := auth.tokenGenerator.Token(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
|
return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -205,7 +269,8 @@ type OidcAuthConsumer struct {
|
|||||||
additionalAuthScopes []v1.AuthScope
|
additionalAuthScopes []v1.AuthScope
|
||||||
|
|
||||||
verifier TokenVerifier
|
verifier TokenVerifier
|
||||||
subjectsFromLogin []string
|
mu sync.RWMutex
|
||||||
|
subjectsFromLogin map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTokenVerifier(cfg v1.AuthOIDCServerConfig) TokenVerifier {
|
func NewTokenVerifier(cfg v1.AuthOIDCServerConfig) TokenVerifier {
|
||||||
@@ -226,7 +291,7 @@ func NewOidcAuthVerifier(additionalAuthScopes []v1.AuthScope, verifier TokenVeri
|
|||||||
return &OidcAuthConsumer{
|
return &OidcAuthConsumer{
|
||||||
additionalAuthScopes: additionalAuthScopes,
|
additionalAuthScopes: additionalAuthScopes,
|
||||||
verifier: verifier,
|
verifier: verifier,
|
||||||
subjectsFromLogin: []string{},
|
subjectsFromLogin: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,9 +300,9 @@ func (auth *OidcAuthConsumer) VerifyLogin(loginMsg *msg.Login) (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid OIDC token in login: %v", err)
|
return fmt.Errorf("invalid OIDC token in login: %v", err)
|
||||||
}
|
}
|
||||||
if !slices.Contains(auth.subjectsFromLogin, token.Subject) {
|
auth.mu.Lock()
|
||||||
auth.subjectsFromLogin = append(auth.subjectsFromLogin, token.Subject)
|
auth.subjectsFromLogin[token.Subject] = struct{}{}
|
||||||
}
|
auth.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,11 +311,13 @@ func (auth *OidcAuthConsumer) verifyPostLoginToken(privilegeKey string) (err err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid OIDC token in ping: %v", err)
|
return fmt.Errorf("invalid OIDC token in ping: %v", err)
|
||||||
}
|
}
|
||||||
if !slices.Contains(auth.subjectsFromLogin, token.Subject) {
|
auth.mu.RLock()
|
||||||
|
_, ok := auth.subjectsFromLogin[token.Subject]
|
||||||
|
auth.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
return fmt.Errorf("received different OIDC subject in login and ping. "+
|
return fmt.Errorf("received different OIDC subject in login and ping. "+
|
||||||
"original subjects: %s, "+
|
|
||||||
"new subject: %s",
|
"new subject: %s",
|
||||||
auth.subjectsFromLogin, token.Subject)
|
token.Subject)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package auth_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -62,3 +66,188 @@ func TestPingAfterLoginWithDifferentSubjectFails(t *testing.T) {
|
|||||||
r.Error(err)
|
r.Error(err)
|
||||||
r.Contains(err.Error(), "received different OIDC subject in login and ping")
|
r.Contains(err.Error(), "received different OIDC subject in login and ping")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOidcAuthProviderFallsBackWhenNoExpiry(t *testing.T) {
|
||||||
|
r := require.New(t)
|
||||||
|
|
||||||
|
var requestCount atomic.Int32
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
requestCount.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
|
||||||
|
"access_token": "fresh-test-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
provider, err := auth.NewOidcAuthSetter(
|
||||||
|
[]v1.AuthScope{v1.AuthScopeHeartBeats},
|
||||||
|
v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.NoError(err)
|
||||||
|
|
||||||
|
// Constructor no longer fetches a token eagerly.
|
||||||
|
// The first SetLogin triggers the adaptive probe.
|
||||||
|
r.Equal(int32(0), requestCount.Load())
|
||||||
|
|
||||||
|
loginMsg := &msg.Login{}
|
||||||
|
err = provider.SetLogin(loginMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("fresh-test-token", loginMsg.PrivilegeKey)
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
pingMsg := &msg.Ping{}
|
||||||
|
err = provider.SetPing(pingMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("fresh-test-token", pingMsg.PrivilegeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 probe (login) + 3 pings = 4 requests (probe doubles as the login token fetch)
|
||||||
|
r.Equal(int32(4), requestCount.Load(), "each call should fetch a fresh token when expires_in is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOidcAuthProviderCachesToken(t *testing.T) {
|
||||||
|
r := require.New(t)
|
||||||
|
|
||||||
|
var requestCount atomic.Int32
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
requestCount.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
|
||||||
|
"access_token": "cached-test-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
provider, err := auth.NewOidcAuthSetter(
|
||||||
|
[]v1.AuthScope{v1.AuthScopeHeartBeats},
|
||||||
|
v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.NoError(err)
|
||||||
|
|
||||||
|
// Constructor no longer fetches eagerly; first SetLogin triggers the probe.
|
||||||
|
r.Equal(int32(0), requestCount.Load())
|
||||||
|
|
||||||
|
// SetLogin triggers the adaptive probe and caches the token.
|
||||||
|
loginMsg := &msg.Login{}
|
||||||
|
err = provider.SetLogin(loginMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("cached-test-token", loginMsg.PrivilegeKey)
|
||||||
|
r.Equal(int32(1), requestCount.Load())
|
||||||
|
|
||||||
|
// Subsequent calls should also reuse the cached token
|
||||||
|
for range 5 {
|
||||||
|
pingMsg := &msg.Ping{}
|
||||||
|
err = provider.SetPing(pingMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("cached-test-token", pingMsg.PrivilegeKey)
|
||||||
|
}
|
||||||
|
r.Equal(int32(1), requestCount.Load(), "token endpoint should only be called once; cached token should be reused")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOidcAuthProviderRetriesOnInitialFailure(t *testing.T) {
|
||||||
|
r := require.New(t)
|
||||||
|
|
||||||
|
var requestCount atomic.Int32
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
n := requestCount.Add(1)
|
||||||
|
// The oauth2 library retries once internally, so we need two
|
||||||
|
// consecutive failures to surface an error to the caller.
|
||||||
|
if n <= 2 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"error": "temporarily_unavailable",
|
||||||
|
"error_description": "service is starting up",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
|
||||||
|
"access_token": "retry-test-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
// Constructor succeeds even though the IdP is "down".
|
||||||
|
provider, err := auth.NewOidcAuthSetter(
|
||||||
|
[]v1.AuthScope{v1.AuthScopeHeartBeats},
|
||||||
|
v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal(int32(0), requestCount.Load())
|
||||||
|
|
||||||
|
// First SetLogin hits the IdP, which returns an error (after internal retry).
|
||||||
|
loginMsg := &msg.Login{}
|
||||||
|
err = provider.SetLogin(loginMsg)
|
||||||
|
r.Error(err)
|
||||||
|
r.Equal(int32(2), requestCount.Load())
|
||||||
|
|
||||||
|
// Second SetLogin retries and succeeds.
|
||||||
|
err = provider.SetLogin(loginMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("retry-test-token", loginMsg.PrivilegeKey)
|
||||||
|
r.Equal(int32(3), requestCount.Load())
|
||||||
|
|
||||||
|
// Subsequent calls use cached token.
|
||||||
|
pingMsg := &msg.Ping{}
|
||||||
|
err = provider.SetPing(pingMsg)
|
||||||
|
r.NoError(err)
|
||||||
|
r.Equal("retry-test-token", pingMsg.PrivilegeKey)
|
||||||
|
r.Equal(int32(3), requestCount.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOidcAuthSetterRejectsInvalidStaticConfig(t *testing.T) {
|
||||||
|
r := require.New(t)
|
||||||
|
tokenServer := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
_, err := auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: "://bad",
|
||||||
|
})
|
||||||
|
r.Error(err)
|
||||||
|
r.Contains(err.Error(), "auth.oidc.tokenEndpointURL")
|
||||||
|
|
||||||
|
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
})
|
||||||
|
r.Error(err)
|
||||||
|
r.Contains(err.Error(), "auth.oidc.clientID is required")
|
||||||
|
|
||||||
|
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
AdditionalEndpointParams: map[string]string{
|
||||||
|
"scope": "profile",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
r.Error(err)
|
||||||
|
r.Contains(err.Error(), "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||||
|
|
||||||
|
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
Audience: "api",
|
||||||
|
AdditionalEndpointParams: map[string]string{"audience": "override"},
|
||||||
|
})
|
||||||
|
r.Error(err)
|
||||||
|
r.Contains(err.Error(), "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||||
|
}
|
||||||
|
|||||||
@@ -171,15 +171,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
|
|||||||
func transformHeadersFromPluginParams(params map[string]string) v1.HeaderOperations {
|
func transformHeadersFromPluginParams(params map[string]string) v1.HeaderOperations {
|
||||||
out := v1.HeaderOperations{}
|
out := v1.HeaderOperations{}
|
||||||
for k, v := range params {
|
for k, v := range params {
|
||||||
if !strings.HasPrefix(k, "plugin_header_") {
|
k, ok := strings.CutPrefix(k, "plugin_header_")
|
||||||
|
if !ok || k == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if k = strings.TrimPrefix(k, "plugin_header_"); k != "" {
|
if out.Set == nil {
|
||||||
if out.Set == nil {
|
out.Set = make(map[string]string)
|
||||||
out.Set = make(map[string]string)
|
|
||||||
}
|
|
||||||
out.Set[k] = v
|
|
||||||
}
|
}
|
||||||
|
out.Set[k] = v
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ const (
|
|||||||
// Proxy
|
// Proxy
|
||||||
var (
|
var (
|
||||||
proxyConfTypeMap = map[ProxyType]reflect.Type{
|
proxyConfTypeMap = map[ProxyType]reflect.Type{
|
||||||
ProxyTypeTCP: reflect.TypeOf(TCPProxyConf{}),
|
ProxyTypeTCP: reflect.TypeFor[TCPProxyConf](),
|
||||||
ProxyTypeUDP: reflect.TypeOf(UDPProxyConf{}),
|
ProxyTypeUDP: reflect.TypeFor[UDPProxyConf](),
|
||||||
ProxyTypeTCPMUX: reflect.TypeOf(TCPMuxProxyConf{}),
|
ProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConf](),
|
||||||
ProxyTypeHTTP: reflect.TypeOf(HTTPProxyConf{}),
|
ProxyTypeHTTP: reflect.TypeFor[HTTPProxyConf](),
|
||||||
ProxyTypeHTTPS: reflect.TypeOf(HTTPSProxyConf{}),
|
ProxyTypeHTTPS: reflect.TypeFor[HTTPSProxyConf](),
|
||||||
ProxyTypeSTCP: reflect.TypeOf(STCPProxyConf{}),
|
ProxyTypeSTCP: reflect.TypeFor[STCPProxyConf](),
|
||||||
ProxyTypeXTCP: reflect.TypeOf(XTCPProxyConf{}),
|
ProxyTypeXTCP: reflect.TypeFor[XTCPProxyConf](),
|
||||||
ProxyTypeSUDP: reflect.TypeOf(SUDPProxyConf{}),
|
ProxyTypeSUDP: reflect.TypeFor[SUDPProxyConf](),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ func GetMapWithoutPrefix(set map[string]string, prefix string) map[string]string
|
|||||||
m := make(map[string]string)
|
m := make(map[string]string)
|
||||||
|
|
||||||
for key, value := range set {
|
for key, value := range set {
|
||||||
if strings.HasPrefix(key, prefix) {
|
if trimmed, ok := strings.CutPrefix(key, prefix); ok {
|
||||||
m[strings.TrimPrefix(key, prefix)] = value
|
m[trimmed] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ const (
|
|||||||
// Visitor
|
// Visitor
|
||||||
var (
|
var (
|
||||||
visitorConfTypeMap = map[VisitorType]reflect.Type{
|
visitorConfTypeMap = map[VisitorType]reflect.Type{
|
||||||
VisitorTypeSTCP: reflect.TypeOf(STCPVisitorConf{}),
|
VisitorTypeSTCP: reflect.TypeFor[STCPVisitorConf](),
|
||||||
VisitorTypeXTCP: reflect.TypeOf(XTCPVisitorConf{}),
|
VisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConf](),
|
||||||
VisitorTypeSUDP: reflect.TypeOf(SUDPVisitorConf{}),
|
VisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConf](),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +421,31 @@ func LoadClientConfig(path string, strict bool) (
|
|||||||
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
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 {
|
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
||||||
proxyCfgs := proxies
|
proxyCfgs := proxies
|
||||||
for _, c := range proxyCfgs {
|
for _, c := range proxyCfgs {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -462,6 +464,111 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
|
|||||||
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
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
|
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
||||||
func TestYAMLEdgeCases(t *testing.T) {
|
func TestYAMLEdgeCases(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"maps"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
@@ -97,21 +99,11 @@ func (a *Aggregator) mapsToSortedSlices(
|
|||||||
proxyMap map[string]v1.ProxyConfigurer,
|
proxyMap map[string]v1.ProxyConfigurer,
|
||||||
visitorMap map[string]v1.VisitorConfigurer,
|
visitorMap map[string]v1.VisitorConfigurer,
|
||||||
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
|
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
|
||||||
proxies := make([]v1.ProxyConfigurer, 0, len(proxyMap))
|
proxies := slices.SortedFunc(maps.Values(proxyMap), func(x, y v1.ProxyConfigurer) int {
|
||||||
for _, p := range proxyMap {
|
return cmp.Compare(x.GetBaseConfig().Name, y.GetBaseConfig().Name)
|
||||||
proxies = append(proxies, p)
|
|
||||||
}
|
|
||||||
sort.Slice(proxies, func(i, j int) bool {
|
|
||||||
return proxies[i].GetBaseConfig().Name < proxies[j].GetBaseConfig().Name
|
|
||||||
})
|
})
|
||||||
|
visitors := slices.SortedFunc(maps.Values(visitorMap), func(x, y v1.VisitorConfigurer) int {
|
||||||
visitors := make([]v1.VisitorConfigurer, 0, len(visitorMap))
|
return cmp.Compare(x.GetBaseConfig().Name, y.GetBaseConfig().Name)
|
||||||
for _, v := range visitorMap {
|
|
||||||
visitors = append(visitors, v)
|
|
||||||
}
|
|
||||||
sort.Slice(visitors, func(i, j int) bool {
|
|
||||||
return visitors[i].GetBaseConfig().Name < visitors[j].GetBaseConfig().Name
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return proxies, visitors
|
return proxies, visitors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,27 @@ func TestAggregator_VisitorMerge(t *testing.T) {
|
|||||||
require.Len(visitors, 2)
|
require.Len(visitors, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAggregator_Load_ReturnsSortedByName(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
agg := newTestAggregator(t, nil)
|
||||||
|
err := agg.ConfigSource().ReplaceAll(
|
||||||
|
[]v1.ProxyConfigurer{mockProxy("charlie"), mockProxy("alice"), mockProxy("bob")},
|
||||||
|
[]v1.VisitorConfigurer{mockVisitor("zulu"), mockVisitor("alpha")},
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
proxies, visitors, err := agg.Load()
|
||||||
|
require.NoError(err)
|
||||||
|
require.Len(proxies, 3)
|
||||||
|
require.Equal("alice", proxies[0].GetBaseConfig().Name)
|
||||||
|
require.Equal("bob", proxies[1].GetBaseConfig().Name)
|
||||||
|
require.Equal("charlie", proxies[2].GetBaseConfig().Name)
|
||||||
|
require.Len(visitors, 2)
|
||||||
|
require.Equal("alpha", visitors[0].GetBaseConfig().Name)
|
||||||
|
require.Equal("zulu", visitors[1].GetBaseConfig().Name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAggregator_Load_ReturnsDefensiveCopies(t *testing.T) {
|
func TestAggregator_Load_ReturnsDefensiveCopies(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,7 @@
|
|||||||
|
|
||||||
package source
|
package source
|
||||||
|
|
||||||
import (
|
import v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConfigSource implements Source for in-memory configuration.
|
// ConfigSource implements Source for in-memory configuration.
|
||||||
// All operations are thread-safe.
|
// All operations are thread-safe.
|
||||||
@@ -39,23 +35,17 @@ func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.Vi
|
|||||||
|
|
||||||
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
|
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
|
||||||
for _, p := range proxies {
|
for _, p := range proxies {
|
||||||
if p == nil {
|
name, err := validateProxyName(p)
|
||||||
return fmt.Errorf("proxy cannot be nil")
|
if err != nil {
|
||||||
}
|
return err
|
||||||
name := p.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
}
|
||||||
nextProxies[name] = p
|
nextProxies[name] = p
|
||||||
}
|
}
|
||||||
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
|
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
|
||||||
for _, v := range visitors {
|
for _, v := range visitors {
|
||||||
if v == nil {
|
name, err := validateVisitorName(v)
|
||||||
return fmt.Errorf("visitor cannot be nil")
|
if err != nil {
|
||||||
}
|
return err
|
||||||
name := v.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
}
|
||||||
nextVisitors[name] = v
|
nextVisitors[name] = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ var (
|
|||||||
ErrNotFound = errors.New("not found")
|
ErrNotFound = errors.New("not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
storeKindProxy = "proxy"
|
||||||
|
storeKindVisitor = "visitor"
|
||||||
|
)
|
||||||
|
|
||||||
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
|
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
|
||||||
if cfg.Path == "" {
|
if cfg.Path == "" {
|
||||||
return nil, fmt.Errorf("path is required")
|
return nil, fmt.Errorf("path is required")
|
||||||
@@ -172,79 +177,111 @@ func (s *StoreSource) saveToFileUnlocked() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
|
func (s *StoreSource) persistOrRollbackUnlocked(rollback func()) error {
|
||||||
if proxy == nil {
|
if err := s.saveToFileUnlocked(); err != nil {
|
||||||
return fmt.Errorf("proxy cannot be nil")
|
rollback()
|
||||||
|
return fmt.Errorf("failed to persist: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
return fmt.Errorf("%s name cannot be empty", kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := s.proxies[name]; exists {
|
entries := entriesFn(s)
|
||||||
return fmt.Errorf("%w: proxy %q", ErrAlreadyExists, name)
|
old, exists := entries[name]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("%w: %s %q", ErrNotFound, kind, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.proxies[name] = proxy
|
delete(entries, name)
|
||||||
|
return s.persistOrRollbackUnlocked(func() {
|
||||||
|
entries[name] = old
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
|
||||||
delete(s.proxies, name)
|
name, err := validateProxyName(proxy)
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return addStoreEntry(s, proxyStoreEntries, storeKindProxy, name, proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
|
func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
|
||||||
if proxy == nil {
|
name, err := validateProxyName(proxy)
|
||||||
return fmt.Errorf("proxy cannot be nil")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return updateStoreEntry(s, proxyStoreEntries, storeKindProxy, name, proxy)
|
||||||
name := proxy.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldProxy, exists := s.proxies[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.proxies[name] = proxy
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.proxies[name] = oldProxy
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) RemoveProxy(name string) error {
|
func (s *StoreSource) RemoveProxy(name string) error {
|
||||||
if name == "" {
|
return removeStoreEntry(s, proxyStoreEntries, storeKindProxy, name)
|
||||||
return fmt.Errorf("proxy name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldProxy, exists := s.proxies[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(s.proxies, name)
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.proxies[name] = oldProxy
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
|
func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
|
||||||
@@ -259,78 +296,23 @@ func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
|
func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
|
||||||
if visitor == nil {
|
name, err := validateVisitorName(visitor)
|
||||||
return fmt.Errorf("visitor cannot be nil")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return addStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
|
||||||
name := visitor.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := s.visitors[name]; exists {
|
|
||||||
return fmt.Errorf("%w: visitor %q", ErrAlreadyExists, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.visitors[name] = visitor
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
delete(s.visitors, name)
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
|
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
|
||||||
if visitor == nil {
|
name, err := validateVisitorName(visitor)
|
||||||
return fmt.Errorf("visitor cannot be nil")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return updateStoreEntry(s, visitorStoreEntries, storeKindVisitor, name, visitor)
|
||||||
name := visitor.GetBaseConfig().Name
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldVisitor, exists := s.visitors[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.visitors[name] = visitor
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.visitors[name] = oldVisitor
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) RemoveVisitor(name string) error {
|
func (s *StoreSource) RemoveVisitor(name string) error {
|
||||||
if name == "" {
|
return removeStoreEntry(s, visitorStoreEntries, storeKindVisitor, name)
|
||||||
return fmt.Errorf("visitor name cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
oldVisitor, exists := s.visitors[name]
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(s.visitors, name)
|
|
||||||
|
|
||||||
if err := s.saveToFileUnlocked(); err != nil {
|
|
||||||
s.visitors[name] = oldVisitor
|
|
||||||
return fmt.Errorf("failed to persist: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
|
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ package source
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -59,6 +60,101 @@ func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T
|
|||||||
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
|
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStoreSource_UpdateAndRemoveProxyAndVisitor(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
storeSource := newTestStoreSource(t)
|
||||||
|
|
||||||
|
proxyCfg := mockProxy("proxy1")
|
||||||
|
visitorCfg := mockVisitor("visitor1")
|
||||||
|
|
||||||
|
require.NoError(storeSource.AddProxy(proxyCfg))
|
||||||
|
require.NoError(storeSource.AddVisitor(visitorCfg))
|
||||||
|
require.ErrorIs(storeSource.AddProxy(proxyCfg), ErrAlreadyExists)
|
||||||
|
require.ErrorIs(storeSource.AddVisitor(visitorCfg), ErrAlreadyExists)
|
||||||
|
require.ErrorContains(storeSource.RemoveProxy(""), "proxy name cannot be empty")
|
||||||
|
require.ErrorContains(storeSource.RemoveVisitor(""), "visitor name cannot be empty")
|
||||||
|
|
||||||
|
updatedProxy := mockProxy("proxy1").(*v1.TCPProxyConfig)
|
||||||
|
updatedProxy.RemotePort = 19090
|
||||||
|
require.NoError(storeSource.UpdateProxy(updatedProxy))
|
||||||
|
require.Equal(19090, storeSource.GetProxy("proxy1").(*v1.TCPProxyConfig).RemotePort)
|
||||||
|
|
||||||
|
updatedVisitor := mockVisitor("visitor1").(*v1.STCPVisitorConfig)
|
||||||
|
updatedVisitor.ServerName = "updated-server"
|
||||||
|
require.NoError(storeSource.UpdateVisitor(updatedVisitor))
|
||||||
|
require.Equal("updated-server", storeSource.GetVisitor("visitor1").(*v1.STCPVisitorConfig).ServerName)
|
||||||
|
|
||||||
|
require.NoError(storeSource.RemoveProxy("proxy1"))
|
||||||
|
require.Nil(storeSource.GetProxy("proxy1"))
|
||||||
|
require.ErrorIs(storeSource.RemoveProxy("proxy1"), ErrNotFound)
|
||||||
|
|
||||||
|
require.NoError(storeSource.RemoveVisitor("visitor1"))
|
||||||
|
require.Nil(storeSource.GetVisitor("visitor1"))
|
||||||
|
require.ErrorIs(storeSource.RemoveVisitor("visitor1"), ErrNotFound)
|
||||||
|
|
||||||
|
require.ErrorIs(storeSource.UpdateProxy(updatedProxy), ErrNotFound)
|
||||||
|
require.ErrorIs(storeSource.UpdateVisitor(updatedVisitor), ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreSource_MutationRollsBackOnPersistFailure(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("chmod does not make directories unwritable on Windows")
|
||||||
|
}
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("chmod does not block writes for uid 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "store.json")
|
||||||
|
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
proxyCfg := mockProxy("proxy1")
|
||||||
|
visitorCfg := mockVisitor("visitor1")
|
||||||
|
originalRemotePort := proxyCfg.(*v1.TCPProxyConfig).RemotePort
|
||||||
|
originalServerName := visitorCfg.(*v1.STCPVisitorConfig).ServerName
|
||||||
|
require.NoError(storeSource.AddProxy(proxyCfg))
|
||||||
|
require.NoError(storeSource.AddVisitor(visitorCfg))
|
||||||
|
|
||||||
|
require.NoError(os.Chmod(dir, 0o500))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chmod(dir, 0o700)
|
||||||
|
})
|
||||||
|
|
||||||
|
requirePersistError := func(err error) {
|
||||||
|
t.Helper()
|
||||||
|
require.Error(err)
|
||||||
|
require.ErrorContains(err, "failed to persist")
|
||||||
|
require.NotErrorIs(err, ErrAlreadyExists)
|
||||||
|
require.NotErrorIs(err, ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
requirePersistError(storeSource.AddProxy(mockProxy("proxy2")))
|
||||||
|
require.Nil(storeSource.GetProxy("proxy2"))
|
||||||
|
|
||||||
|
updatedProxy := mockProxy("proxy1").(*v1.TCPProxyConfig)
|
||||||
|
updatedProxy.RemotePort = 19090
|
||||||
|
requirePersistError(storeSource.UpdateProxy(updatedProxy))
|
||||||
|
require.Equal(originalRemotePort, storeSource.GetProxy("proxy1").(*v1.TCPProxyConfig).RemotePort)
|
||||||
|
|
||||||
|
requirePersistError(storeSource.RemoveProxy("proxy1"))
|
||||||
|
require.NotNil(storeSource.GetProxy("proxy1"))
|
||||||
|
|
||||||
|
requirePersistError(storeSource.AddVisitor(mockVisitor("visitor2")))
|
||||||
|
require.Nil(storeSource.GetVisitor("visitor2"))
|
||||||
|
|
||||||
|
updatedVisitor := mockVisitor("visitor1").(*v1.STCPVisitorConfig)
|
||||||
|
updatedVisitor.ServerName = "updated-server"
|
||||||
|
requirePersistError(storeSource.UpdateVisitor(updatedVisitor))
|
||||||
|
require.Equal(originalServerName, storeSource.GetVisitor("visitor1").(*v1.STCPVisitorConfig).ServerName)
|
||||||
|
|
||||||
|
requirePersistError(storeSource.RemoveVisitor("visitor1"))
|
||||||
|
require.NotNil(storeSource.GetVisitor("visitor1"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
|
func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ func parseNumberRangePair(firstRangeStr, secondRangeStr string) ([]NumberPair, e
|
|||||||
return nil, fmt.Errorf("first and second range numbers are not in pairs")
|
return nil, fmt.Errorf("first and second range numbers are not in pairs")
|
||||||
}
|
}
|
||||||
pairs := make([]NumberPair, 0, len(firstRangeNumbers))
|
pairs := make([]NumberPair, 0, len(firstRangeNumbers))
|
||||||
for i := 0; i < len(firstRangeNumbers); i++ {
|
for i := range firstRangeNumbers {
|
||||||
pairs = append(pairs, NumberPair{
|
pairs = append(pairs, NumberPair{
|
||||||
First: firstRangeNumbers[i],
|
First: firstRangeNumbers[i],
|
||||||
Second: secondRangeNumbers[i],
|
Second: secondRangeNumbers[i],
|
||||||
|
|||||||
@@ -70,24 +70,18 @@ func (q *BandwidthQuantity) UnmarshalString(s string) error {
|
|||||||
f float64
|
f float64
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
switch {
|
if fstr, ok := strings.CutSuffix(s, "MB"); ok {
|
||||||
case strings.HasSuffix(s, "MB"):
|
|
||||||
base = MB
|
base = MB
|
||||||
fstr := strings.TrimSuffix(s, "MB")
|
|
||||||
f, err = strconv.ParseFloat(fstr, 64)
|
f, err = strconv.ParseFloat(fstr, 64)
|
||||||
if err != nil {
|
} else if fstr, ok := strings.CutSuffix(s, "KB"); ok {
|
||||||
return err
|
|
||||||
}
|
|
||||||
case strings.HasSuffix(s, "KB"):
|
|
||||||
base = KB
|
base = KB
|
||||||
fstr := strings.TrimSuffix(s, "KB")
|
|
||||||
f, err = strconv.ParseFloat(fstr, 64)
|
f, err = strconv.ParseFloat(fstr, 64)
|
||||||
if err != nil {
|
} else {
|
||||||
return err
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return errors.New("unit not support")
|
return errors.New("unit not support")
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
q.s = s
|
q.s = s
|
||||||
q.i = int64(f * float64(base))
|
q.i = int64(f * float64(base))
|
||||||
@@ -143,8 +137,8 @@ func (p PortsRangeSlice) String() string {
|
|||||||
func NewPortsRangeSliceFromString(str string) ([]PortsRange, error) {
|
func NewPortsRangeSliceFromString(str string) ([]PortsRange, error) {
|
||||||
str = strings.TrimSpace(str)
|
str = strings.TrimSpace(str)
|
||||||
out := []PortsRange{}
|
out := []PortsRange{}
|
||||||
numRanges := strings.Split(str, ",")
|
numRanges := strings.SplitSeq(str, ",")
|
||||||
for _, numRangeStr := range numRanges {
|
for numRangeStr := range numRanges {
|
||||||
// 1000-2000 or 2001
|
// 1000-2000 or 2001
|
||||||
numArray := strings.Split(numRangeStr, "-")
|
numArray := strings.Split(numRangeStr, "-")
|
||||||
// length: only 1 or 2 is correct
|
// length: only 1 or 2 is correct
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ func TestBandwidthQuantity(t *testing.T) {
|
|||||||
require.Equal(`{"b":"1KB","int":5}`, string(buf))
|
require.Equal(`{"b":"1KB","int":5}`, string(buf))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBandwidthQuantity_MB(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
var w Wrap
|
||||||
|
err := json.Unmarshal([]byte(`{"b":"2MB","int":1}`), &w)
|
||||||
|
require.NoError(err)
|
||||||
|
require.EqualValues(2*MB, w.B.Bytes())
|
||||||
|
|
||||||
|
buf, err := json.Marshal(&w)
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(`{"b":"2MB","int":1}`, string(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBandwidthQuantity_InvalidUnit(t *testing.T) {
|
||||||
|
var w Wrap
|
||||||
|
err := json.Unmarshal([]byte(`{"b":"1GB","int":1}`), &w)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBandwidthQuantity_InvalidNumber(t *testing.T) {
|
||||||
|
var w Wrap
|
||||||
|
err := json.Unmarshal([]byte(`{"b":"abcKB","int":1}`), &w)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPortsRangeSlice2String(t *testing.T) {
|
func TestPortsRangeSlice2String(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ type ClientTransportConfig struct {
|
|||||||
// Valid values are "tcp", "kcp", "quic", "websocket" and "wss". By default, this value
|
// Valid values are "tcp", "kcp", "quic", "websocket" and "wss". By default, this value
|
||||||
// is "tcp".
|
// is "tcp".
|
||||||
Protocol string `json:"protocol,omitempty"`
|
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.
|
// The maximum amount of time a dial to server will wait for a connect to complete.
|
||||||
DialServerTimeout int64 `json:"dialServerTimeout,omitempty"`
|
DialServerTimeout int64 `json:"dialServerTimeout,omitempty"`
|
||||||
// DialServerKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps.
|
// 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() {
|
func (c *ClientTransportConfig) Complete() {
|
||||||
c.Protocol = util.EmptyOr(c.Protocol, "tcp")
|
c.Protocol = util.EmptyOr(c.Protocol, "tcp")
|
||||||
|
c.WireProtocol = util.EmptyOr(c.WireProtocol, "v1")
|
||||||
c.DialServerTimeout = util.EmptyOr(c.DialServerTimeout, 10)
|
c.DialServerTimeout = util.EmptyOr(c.DialServerTimeout, 10)
|
||||||
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
|
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
|
||||||
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
|
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func TestClientConfigComplete(t *testing.T) {
|
|||||||
|
|
||||||
require.EqualValues("token", c.Auth.Method)
|
require.EqualValues("token", c.Auth.Method)
|
||||||
require.Equal(true, lo.FromPtr(c.Transport.TCPMux))
|
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.LoginFailExit))
|
||||||
require.Equal(true, lo.FromPtr(c.Transport.TLS.Enable))
|
require.Equal(true, lo.FromPtr(c.Transport.TLS.Enable))
|
||||||
require.Equal(true, lo.FromPtr(c.Transport.TLS.DisableCustomTLSFirstByte))
|
require.Equal(true, lo.FromPtr(c.Transport.TLS.DisableCustomTLSFirstByte))
|
||||||
|
|||||||
@@ -239,14 +239,14 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var proxyConfigTypeMap = map[ProxyType]reflect.Type{
|
var proxyConfigTypeMap = map[ProxyType]reflect.Type{
|
||||||
ProxyTypeTCP: reflect.TypeOf(TCPProxyConfig{}),
|
ProxyTypeTCP: reflect.TypeFor[TCPProxyConfig](),
|
||||||
ProxyTypeUDP: reflect.TypeOf(UDPProxyConfig{}),
|
ProxyTypeUDP: reflect.TypeFor[UDPProxyConfig](),
|
||||||
ProxyTypeHTTP: reflect.TypeOf(HTTPProxyConfig{}),
|
ProxyTypeHTTP: reflect.TypeFor[HTTPProxyConfig](),
|
||||||
ProxyTypeHTTPS: reflect.TypeOf(HTTPSProxyConfig{}),
|
ProxyTypeHTTPS: reflect.TypeFor[HTTPSProxyConfig](),
|
||||||
ProxyTypeTCPMUX: reflect.TypeOf(TCPMuxProxyConfig{}),
|
ProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConfig](),
|
||||||
ProxyTypeSTCP: reflect.TypeOf(STCPProxyConfig{}),
|
ProxyTypeSTCP: reflect.TypeFor[STCPProxyConfig](),
|
||||||
ProxyTypeXTCP: reflect.TypeOf(XTCPProxyConfig{}),
|
ProxyTypeXTCP: reflect.TypeFor[XTCPProxyConfig](),
|
||||||
ProxyTypeSUDP: reflect.TypeOf(SUDPProxyConfig{}),
|
ProxyTypeSUDP: reflect.TypeFor[SUDPProxyConfig](),
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProxyConfigurerByType(proxyType ProxyType) ProxyConfigurer {
|
func NewProxyConfigurerByType(proxyType ProxyType) ProxyConfigurer {
|
||||||
|
|||||||
@@ -37,16 +37,16 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
||||||
PluginHTTP2HTTPS: reflect.TypeOf(HTTP2HTTPSPluginOptions{}),
|
PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
|
||||||
PluginHTTPProxy: reflect.TypeOf(HTTPProxyPluginOptions{}),
|
PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
|
||||||
PluginHTTPS2HTTP: reflect.TypeOf(HTTPS2HTTPPluginOptions{}),
|
PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
|
||||||
PluginHTTPS2HTTPS: reflect.TypeOf(HTTPS2HTTPSPluginOptions{}),
|
PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
|
||||||
PluginHTTP2HTTP: reflect.TypeOf(HTTP2HTTPPluginOptions{}),
|
PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
|
||||||
PluginSocks5: reflect.TypeOf(Socks5PluginOptions{}),
|
PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
|
||||||
PluginStaticFile: reflect.TypeOf(StaticFilePluginOptions{}),
|
PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
|
||||||
PluginUnixDomainSocket: reflect.TypeOf(UnixDomainSocketPluginOptions{}),
|
PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
|
||||||
PluginTLS2Raw: reflect.TypeOf(TLS2RawPluginOptions{}),
|
PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
|
||||||
PluginVirtualNet: reflect.TypeOf(VirtualNetPluginOptions{}),
|
PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientPluginOptions interface {
|
type ClientPluginOptions interface {
|
||||||
|
|||||||
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,26 +68,16 @@ func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, e
|
|||||||
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate token/tokenSource mutual exclusivity
|
errs = AppendError(errs, v.validateAuthTokenSource(c.Token, c.TokenSource))
|
||||||
if c.Token != "" && c.TokenSource != nil {
|
|
||||||
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate tokenSource if specified
|
|
||||||
if c.TokenSource != nil {
|
|
||||||
if c.TokenSource.Type == "exec" {
|
|
||||||
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
|
|
||||||
errs = AppendError(errs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := c.TokenSource.Validate(); err != nil {
|
|
||||||
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
|
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
|
||||||
errs = AppendError(errs, err)
|
errs = AppendError(errs, err)
|
||||||
}
|
}
|
||||||
|
if c.Method == v1.AuthMethodOIDC && c.OIDC.TokenSource == nil {
|
||||||
|
if err := ValidateOIDCClientCredentialsConfig(&c.OIDC); err != nil {
|
||||||
|
errs = AppendError(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil, errs
|
return nil, errs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +131,9 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
|
|||||||
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
|
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
|
||||||
errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols))
|
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
|
return warnings, errs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
pkg/config/v1/validation/oidc.go
Normal file
57
pkg/config/v1/validation/oidc.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// 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 (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateOIDCClientCredentialsConfig(c *v1.AuthOIDCClientConfig) error {
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
if c.ClientID == "" {
|
||||||
|
errs = append(errs, "auth.oidc.clientID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.TokenEndpointURL == "" {
|
||||||
|
errs = append(errs, "auth.oidc.tokenEndpointURL is required")
|
||||||
|
} else {
|
||||||
|
tokenURL, err := url.Parse(c.TokenEndpointURL)
|
||||||
|
if err != nil || !tokenURL.IsAbs() || tokenURL.Host == "" {
|
||||||
|
errs = append(errs, "auth.oidc.tokenEndpointURL must be an absolute http or https URL")
|
||||||
|
} else if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" {
|
||||||
|
errs = append(errs, "auth.oidc.tokenEndpointURL must use http or https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := c.AdditionalEndpointParams["scope"]; ok {
|
||||||
|
errs = append(errs, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Audience != "" {
|
||||||
|
if _, ok := c.AdditionalEndpointParams["audience"]; ok {
|
||||||
|
errs = append(errs, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New(strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
78
pkg/config/v1/validation/oidc_test.go
Normal file
78
pkg/config/v1/validation/oidc_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// 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 (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateOIDCClientCredentialsConfig(t *testing.T) {
|
||||||
|
tokenServer := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
t.Run("valid", func(t *testing.T) {
|
||||||
|
require.NoError(t, ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
AdditionalEndpointParams: map[string]string{
|
||||||
|
"resource": "api",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid token endpoint url", func(t *testing.T) {
|
||||||
|
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: "://bad",
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "auth.oidc.tokenEndpointURL")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing client id", func(t *testing.T) {
|
||||||
|
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "auth.oidc.clientID is required")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scope endpoint param is not allowed", func(t *testing.T) {
|
||||||
|
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
AdditionalEndpointParams: map[string]string{
|
||||||
|
"scope": "email",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("audience conflict", func(t *testing.T) {
|
||||||
|
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||||
|
ClientID: "test-client",
|
||||||
|
TokenEndpointURL: tokenServer.URL,
|
||||||
|
Audience: "api",
|
||||||
|
AdditionalEndpointParams: map[string]string{
|
||||||
|
"audience": "override",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/policy/security"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
|
func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
|
||||||
@@ -36,22 +35,7 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
|
|||||||
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate token/tokenSource mutual exclusivity
|
errs = AppendError(errs, v.validateAuthTokenSource(c.Auth.Token, c.Auth.TokenSource))
|
||||||
if c.Auth.Token != "" && c.Auth.TokenSource != nil {
|
|
||||||
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate tokenSource if specified
|
|
||||||
if c.Auth.TokenSource != nil {
|
|
||||||
if c.Auth.TokenSource.Type == "exec" {
|
|
||||||
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
|
|
||||||
errs = AppendError(errs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := c.Auth.TokenSource.Validate(); err != nil {
|
|
||||||
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateLogConfig(&c.Log); err != nil {
|
if err := validateLogConfig(&c.Log); err != nil {
|
||||||
errs = AppendError(errs, err)
|
errs = AppendError(errs, err)
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ var (
|
|||||||
"websocket",
|
"websocket",
|
||||||
"wss",
|
"wss",
|
||||||
}
|
}
|
||||||
|
SupportedWireProtocols = []string{
|
||||||
|
"v1",
|
||||||
|
"v2",
|
||||||
|
}
|
||||||
|
|
||||||
SupportedAuthMethods = []v1.AuthMethod{
|
SupportedAuthMethods = []v1.AuthMethod{
|
||||||
"token",
|
"token",
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var visitorConfigTypeMap = map[VisitorType]reflect.Type{
|
var visitorConfigTypeMap = map[VisitorType]reflect.Type{
|
||||||
VisitorTypeSTCP: reflect.TypeOf(STCPVisitorConfig{}),
|
VisitorTypeSTCP: reflect.TypeFor[STCPVisitorConfig](),
|
||||||
VisitorTypeXTCP: reflect.TypeOf(XTCPVisitorConfig{}),
|
VisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConfig](),
|
||||||
VisitorTypeSUDP: reflect.TypeOf(SUDPVisitorConfig{}),
|
VisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConfig](),
|
||||||
}
|
}
|
||||||
|
|
||||||
type TypedVisitorConfig struct {
|
type TypedVisitorConfig struct {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var visitorPluginOptionsTypeMap = map[string]reflect.Type{
|
var visitorPluginOptionsTypeMap = map[string]reflect.Type{
|
||||||
VisitorPluginVirtualNet: reflect.TypeOf(VirtualNetVisitorPluginOptions{}),
|
VisitorPluginVirtualNet: reflect.TypeFor[VirtualNetVisitorPluginOptions](),
|
||||||
}
|
}
|
||||||
|
|
||||||
type VisitorPluginOptions interface {
|
type VisitorPluginOptions interface {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/utils/clock"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
"github.com/fatedier/frp/pkg/util/metric"
|
"github.com/fatedier/frp/pkg/util/metric"
|
||||||
server "github.com/fatedier/frp/server/metrics"
|
server "github.com/fatedier/frp/server/metrics"
|
||||||
@@ -37,12 +39,21 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type serverMetrics struct {
|
type serverMetrics struct {
|
||||||
info *ServerStatistics
|
info *ServerStatistics
|
||||||
mu sync.Mutex
|
clock clock.WithTicker
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServerMetrics() *serverMetrics {
|
func newServerMetrics() *serverMetrics {
|
||||||
|
return newServerMetricsWithClock(clock.RealClock{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServerMetricsWithClock(clk clock.WithTicker) *serverMetrics {
|
||||||
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
return &serverMetrics{
|
return &serverMetrics{
|
||||||
|
clock: clk,
|
||||||
info: &ServerStatistics{
|
info: &ServerStatistics{
|
||||||
TotalTrafficIn: metric.NewDateCounter(ReserveDays),
|
TotalTrafficIn: metric.NewDateCounter(ReserveDays),
|
||||||
TotalTrafficOut: metric.NewDateCounter(ReserveDays),
|
TotalTrafficOut: metric.NewDateCounter(ReserveDays),
|
||||||
@@ -57,14 +68,23 @@ func newServerMetrics() *serverMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) run() {
|
func (m *serverMetrics) run() {
|
||||||
go func() {
|
go m.runUntil(nil)
|
||||||
for {
|
}
|
||||||
time.Sleep(12 * time.Hour)
|
|
||||||
start := time.Now()
|
func (m *serverMetrics) runUntil(stopCh <-chan struct{}) {
|
||||||
|
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)
|
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) {
|
func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration) (int, int) {
|
||||||
@@ -75,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
total = len(m.info.ProxyStatistics)
|
total = len(m.info.ProxyStatistics)
|
||||||
for name, data := range m.info.ProxyStatistics {
|
for name, data := range m.info.ProxyStatistics {
|
||||||
if !data.LastCloseTime.IsZero() &&
|
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
||||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
|
||||||
time.Since(data.LastCloseTime) > continuousOfflineDuration {
|
|
||||||
delete(m.info.ProxyStatistics, name)
|
delete(m.info.ProxyStatistics, name)
|
||||||
count++
|
count++
|
||||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||||
@@ -86,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
|||||||
return count, total
|
return count, total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
|
||||||
|
return !data.LastCloseTime.IsZero() &&
|
||||||
|
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||||
|
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
||||||
return m.clearUselessInfo(0)
|
return m.clearUselessInfo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
|
||||||
|
return m.clearUselessInfo(0)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) NewClient() {
|
func (m *serverMetrics) NewClient() {
|
||||||
m.info.ClientCounts.Inc(1)
|
m.info.ClientCounts.Inc(1)
|
||||||
}
|
}
|
||||||
@@ -121,7 +149,7 @@ func (m *serverMetrics) NewProxy(name string, proxyType string, user string, cli
|
|||||||
}
|
}
|
||||||
proxyStats.User = user
|
proxyStats.User = user
|
||||||
proxyStats.ClientID = clientID
|
proxyStats.ClientID = clientID
|
||||||
proxyStats.LastStartTime = time.Now()
|
proxyStats.LastStartTime = m.clock.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) CloseProxy(name string, proxyType string) {
|
func (m *serverMetrics) CloseProxy(name string, proxyType string) {
|
||||||
@@ -131,7 +159,7 @@ func (m *serverMetrics) CloseProxy(name string, proxyType string) {
|
|||||||
counter.Dec(1)
|
counter.Dec(1)
|
||||||
}
|
}
|
||||||
if proxyStats, ok := m.info.ProxyStatistics[name]; ok {
|
if proxyStats, ok := m.info.ProxyStatistics[name]; ok {
|
||||||
proxyStats.LastCloseTime = time.Now()
|
proxyStats.LastCloseTime = m.clock.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +171,6 @@ func (m *serverMetrics) OpenConnection(name string, _ string) {
|
|||||||
proxyStats, ok := m.info.ProxyStatistics[name]
|
proxyStats, ok := m.info.ProxyStatistics[name]
|
||||||
if ok {
|
if ok {
|
||||||
proxyStats.CurConns.Inc(1)
|
proxyStats.CurConns.Inc(1)
|
||||||
m.info.ProxyStatistics[name] = proxyStats
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +182,6 @@ func (m *serverMetrics) CloseConnection(name string, _ string) {
|
|||||||
proxyStats, ok := m.info.ProxyStatistics[name]
|
proxyStats, ok := m.info.ProxyStatistics[name]
|
||||||
if ok {
|
if ok {
|
||||||
proxyStats.CurConns.Dec(1)
|
proxyStats.CurConns.Dec(1)
|
||||||
m.info.ProxyStatistics[name] = proxyStats
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +194,6 @@ func (m *serverMetrics) AddTrafficIn(name string, _ string, trafficBytes int64)
|
|||||||
proxyStats, ok := m.info.ProxyStatistics[name]
|
proxyStats, ok := m.info.ProxyStatistics[name]
|
||||||
if ok {
|
if ok {
|
||||||
proxyStats.TrafficIn.Inc(trafficBytes)
|
proxyStats.TrafficIn.Inc(trafficBytes)
|
||||||
m.info.ProxyStatistics[name] = proxyStats
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +206,6 @@ func (m *serverMetrics) AddTrafficOut(name string, _ string, trafficBytes int64)
|
|||||||
proxyStats, ok := m.info.ProxyStatistics[name]
|
proxyStats, ok := m.info.ProxyStatistics[name]
|
||||||
if ok {
|
if ok {
|
||||||
proxyStats.TrafficOut.Inc(trafficBytes)
|
proxyStats.TrafficOut.Inc(trafficBytes)
|
||||||
m.info.ProxyStatistics[name] = proxyStats
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +227,27 @@ func (m *serverMetrics) GetServer() *ServerStats {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
|
||||||
|
ps := &ProxyStats{
|
||||||
|
Name: name,
|
||||||
|
Type: proxyStats.ProxyType,
|
||||||
|
User: proxyStats.User,
|
||||||
|
ClientID: proxyStats.ClientID,
|
||||||
|
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
||||||
|
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
||||||
|
CurConns: int64(proxyStats.CurConns.Count()),
|
||||||
|
}
|
||||||
|
if !proxyStats.LastStartTime.IsZero() {
|
||||||
|
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||||
|
ps.LastStartAt = proxyStats.LastStartTime.Unix()
|
||||||
|
}
|
||||||
|
if !proxyStats.LastCloseTime.IsZero() {
|
||||||
|
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||||
|
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
|
||||||
|
}
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
||||||
res := make([]*ProxyStats, 0)
|
res := make([]*ProxyStats, 0)
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -212,23 +257,7 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
|||||||
if proxyStats.ProxyType != proxyType {
|
if proxyStats.ProxyType != proxyType {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
res = append(res, toProxyStats(name, proxyStats))
|
||||||
ps := &ProxyStats{
|
|
||||||
Name: name,
|
|
||||||
Type: proxyStats.ProxyType,
|
|
||||||
User: proxyStats.User,
|
|
||||||
ClientID: proxyStats.ClientID,
|
|
||||||
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
|
||||||
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
|
||||||
CurConns: int64(proxyStats.CurConns.Count()),
|
|
||||||
}
|
|
||||||
if !proxyStats.LastStartTime.IsZero() {
|
|
||||||
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
|
||||||
}
|
|
||||||
if !proxyStats.LastCloseTime.IsZero() {
|
|
||||||
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
|
||||||
}
|
|
||||||
res = append(res, ps)
|
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
@@ -237,31 +266,9 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
for name, proxyStats := range m.info.ProxyStatistics {
|
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
||||||
if proxyStats.ProxyType != proxyType {
|
if ok && proxyStats.ProxyType == proxyType {
|
||||||
continue
|
res = toProxyStats(proxyName, proxyStats)
|
||||||
}
|
|
||||||
|
|
||||||
if name != proxyName {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
res = &ProxyStats{
|
|
||||||
Name: name,
|
|
||||||
Type: proxyStats.ProxyType,
|
|
||||||
User: proxyStats.User,
|
|
||||||
ClientID: proxyStats.ClientID,
|
|
||||||
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
|
||||||
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
|
||||||
CurConns: int64(proxyStats.CurConns.Count()),
|
|
||||||
}
|
|
||||||
if !proxyStats.LastStartTime.IsZero() {
|
|
||||||
res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
|
||||||
}
|
|
||||||
if !proxyStats.LastCloseTime.IsZero() {
|
|
||||||
res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -272,21 +279,7 @@ func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) {
|
|||||||
|
|
||||||
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
||||||
if ok {
|
if ok {
|
||||||
res = &ProxyStats{
|
res = toProxyStats(proxyName, proxyStats)
|
||||||
Name: proxyName,
|
|
||||||
Type: proxyStats.ProxyType,
|
|
||||||
User: proxyStats.User,
|
|
||||||
ClientID: proxyStats.ClientID,
|
|
||||||
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
|
||||||
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
|
||||||
CurConns: int64(proxyStats.CurConns.Count()),
|
|
||||||
}
|
|
||||||
if !proxyStats.LastStartTime.IsZero() {
|
|
||||||
res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
|
||||||
}
|
|
||||||
if !proxyStats.LastCloseTime.IsZero() {
|
|
||||||
res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
153
pkg/metrics/mem/server_test.go
Normal file
153
pkg/metrics/mem/server_test.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package mem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
clocktesting "k8s.io/utils/clock/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start)
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
|
||||||
|
metrics.NewProxy("proxy", "tcp", "user", "client-id")
|
||||||
|
require.Equal(start, metrics.info.ProxyStatistics["proxy"].LastStartTime)
|
||||||
|
|
||||||
|
closedAt := start.Add(time.Minute)
|
||||||
|
clk.SetTime(closedAt)
|
||||||
|
metrics.CloseProxy("proxy", "tcp")
|
||||||
|
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
||||||
|
|
||||||
|
stats := metrics.GetProxyByName("proxy")
|
||||||
|
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
|
||||||
|
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
|
||||||
|
require.Equal(start.Unix(), stats.LastStartAt)
|
||||||
|
require.Equal(closedAt.Unix(), stats.LastCloseAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start.Add(25 * time.Hour))
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
|
||||||
|
Name: "proxy",
|
||||||
|
LastStartTime: start.Add(-time.Hour),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
|
||||||
|
count, total := metrics.clearUselessInfo(24 * time.Hour)
|
||||||
|
|
||||||
|
require.Equal(1, count)
|
||||||
|
require.Equal(1, total)
|
||||||
|
require.Empty(metrics.info.ProxyStatistics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||||
|
Name: "offline",
|
||||||
|
LastStartTime: start.Add(-time.Hour),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||||
|
Name: "online",
|
||||||
|
LastStartTime: start,
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared, total := metrics.ClearOfflineProxies()
|
||||||
|
|
||||||
|
require.Equal(1, cleared)
|
||||||
|
require.Equal(2, total)
|
||||||
|
require.False(metrics.hasProxyStatistics("offline"))
|
||||||
|
require.True(metrics.hasProxyStatistics("online"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||||
|
Name: "offline",
|
||||||
|
LastStartTime: start.Add(-time.Hour),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||||
|
Name: "online",
|
||||||
|
LastStartTime: start,
|
||||||
|
}
|
||||||
|
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
|
||||||
|
Name: "restarted",
|
||||||
|
LastStartTime: start.Add(30 * time.Second),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
|
||||||
|
Name: "same-time",
|
||||||
|
LastStartTime: start,
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared, total := metrics.PruneOfflineProxies()
|
||||||
|
|
||||||
|
require.Equal(1, cleared)
|
||||||
|
require.Equal(4, total)
|
||||||
|
require.False(metrics.hasProxyStatistics("offline"))
|
||||||
|
require.True(metrics.hasProxyStatistics("online"))
|
||||||
|
require.True(metrics.hasProxyStatistics("restarted"))
|
||||||
|
require.True(metrics.hasProxyStatistics("same-time"))
|
||||||
|
|
||||||
|
cleared, total = metrics.PruneOfflineProxies()
|
||||||
|
require.Equal(0, cleared)
|
||||||
|
require.Equal(3, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||||
|
clk := clocktesting.NewFakeClock(start)
|
||||||
|
metrics := newServerMetricsWithClock(clk)
|
||||||
|
metrics.info.ProxyStatistics["proxy"] = &ProxyStatistics{
|
||||||
|
Name: "proxy",
|
||||||
|
LastStartTime: start.Add(-time.Hour),
|
||||||
|
LastCloseTime: start,
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCh := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
metrics.runUntil(stopCh)
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(stopCh)
|
||||||
|
<-done
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Eventually(clk.HasWaiters, time.Second, time.Millisecond)
|
||||||
|
clk.Step(8 * 24 * time.Hour)
|
||||||
|
|
||||||
|
require.Eventually(func() bool {
|
||||||
|
return !metrics.hasProxyStatistics("proxy")
|
||||||
|
}, time.Second, time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *serverMetrics) hasProxyStatistics(name string) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
_, ok := m.info.ProxyStatistics[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ type ProxyStats struct {
|
|||||||
TodayTrafficOut int64
|
TodayTrafficOut int64
|
||||||
LastStartTime string
|
LastStartTime string
|
||||||
LastCloseTime string
|
LastCloseTime string
|
||||||
|
LastStartAt int64
|
||||||
|
LastCloseAt int64
|
||||||
CurConns int64
|
CurConns int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +87,5 @@ type Collector interface {
|
|||||||
GetProxyByName(proxyName string) *ProxyStats
|
GetProxyByName(proxyName string) *ProxyStats
|
||||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||||
ClearOfflineProxies() (int, int)
|
ClearOfflineProxies() (int, int)
|
||||||
|
PruneOfflineProxies() (int, int)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
package msg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"reflect"
|
"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) {
|
func AsyncHandler(f func(Message)) func(Message) {
|
||||||
return func(m Message) {
|
return func(m Message) {
|
||||||
go f(m)
|
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.
|
// Dispatcher is used to send messages to net.Conn or register handlers for messages read from net.Conn.
|
||||||
type Dispatcher struct {
|
type Dispatcher struct {
|
||||||
rw io.ReadWriter
|
rw ReadWriter
|
||||||
|
|
||||||
sendCh chan Message
|
sendCh chan Message
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
msgHandlers map[reflect.Type]func(Message)
|
msgHandlers map[reflect.Type]func(Message)
|
||||||
defaultHandler func(Message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDispatcher(rw io.ReadWriter) *Dispatcher {
|
func NewDispatcher(rw ReadWriter) *Dispatcher {
|
||||||
return &Dispatcher{
|
return &Dispatcher{
|
||||||
rw: rw,
|
rw: rw,
|
||||||
sendCh: make(chan Message, 100),
|
sendCh: make(chan Message, 100),
|
||||||
@@ -56,14 +135,14 @@ func (d *Dispatcher) sendLoop() {
|
|||||||
case <-d.doneCh:
|
case <-d.doneCh:
|
||||||
return
|
return
|
||||||
case m := <-d.sendCh:
|
case m := <-d.sendCh:
|
||||||
_ = WriteMsg(d.rw, m)
|
_ = d.rw.WriteMsg(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) readLoop() {
|
func (d *Dispatcher) readLoop() {
|
||||||
for {
|
for {
|
||||||
m, err := ReadMsg(d.rw)
|
m, err := d.rw.ReadMsg()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
close(d.doneCh)
|
close(d.doneCh)
|
||||||
return
|
return
|
||||||
@@ -71,8 +150,6 @@ func (d *Dispatcher) readLoop() {
|
|||||||
|
|
||||||
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
|
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
|
||||||
handler(m)
|
handler(m)
|
||||||
} else if d.defaultHandler != nil {
|
|
||||||
d.defaultHandler(m)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,10 +167,6 @@ func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {
|
|||||||
d.msgHandlers[reflect.TypeOf(msg)] = handler
|
d.msgHandlers[reflect.TypeOf(msg)] = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) RegisterDefaultHandler(handler func(Message)) {
|
|
||||||
d.defaultHandler = handler
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Dispatcher) Done() chan struct{} {
|
func (d *Dispatcher) Done() chan struct{} {
|
||||||
return d.doneCh
|
return d.doneCh
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,24 +20,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TypeLogin = 'o'
|
TypeLogin byte = 'o'
|
||||||
TypeLoginResp = '1'
|
TypeLoginResp byte = '1'
|
||||||
TypeNewProxy = 'p'
|
TypeNewProxy byte = 'p'
|
||||||
TypeNewProxyResp = '2'
|
TypeNewProxyResp byte = '2'
|
||||||
TypeCloseProxy = 'c'
|
TypeCloseProxy byte = 'c'
|
||||||
TypeNewWorkConn = 'w'
|
TypeNewWorkConn byte = 'w'
|
||||||
TypeReqWorkConn = 'r'
|
TypeReqWorkConn byte = 'r'
|
||||||
TypeStartWorkConn = 's'
|
TypeStartWorkConn byte = 's'
|
||||||
TypeNewVisitorConn = 'v'
|
TypeNewVisitorConn byte = 'v'
|
||||||
TypeNewVisitorConnResp = '3'
|
TypeNewVisitorConnResp byte = '3'
|
||||||
TypePing = 'h'
|
TypePing byte = 'h'
|
||||||
TypePong = '4'
|
TypePong byte = '4'
|
||||||
TypeUDPPacket = 'u'
|
TypeUDPPacket byte = 'u'
|
||||||
TypeNatHoleVisitor = 'i'
|
TypeNatHoleVisitor byte = 'i'
|
||||||
TypeNatHoleClient = 'n'
|
TypeNatHoleClient byte = 'n'
|
||||||
TypeNatHoleResp = 'm'
|
TypeNatHoleResp byte = 'm'
|
||||||
TypeNatHoleSid = '5'
|
TypeNatHoleSid byte = '5'
|
||||||
TypeNatHoleReport = '6'
|
TypeNatHoleReport byte = '6'
|
||||||
)
|
)
|
||||||
|
|
||||||
var msgTypeMap = map[byte]any{
|
var msgTypeMap = map[byte]any{
|
||||||
@@ -61,7 +61,7 @@ var msgTypeMap = map[byte]any{
|
|||||||
TypeNatHoleReport: NatHoleReport{},
|
TypeNatHoleReport: NatHoleReport{},
|
||||||
}
|
}
|
||||||
|
|
||||||
var TypeNameNatHoleResp = reflect.TypeOf(&NatHoleResp{}).Elem().Name()
|
var TypeNameNatHoleResp = reflect.TypeFor[NatHoleResp]().Name()
|
||||||
|
|
||||||
type ClientSpec struct {
|
type ClientSpec struct {
|
||||||
// Due to the support of VirtualClient, frps needs to know the client type in order to
|
// Due to the support of VirtualClient, frps needs to know the client type in order to
|
||||||
@@ -184,7 +184,7 @@ type Pong struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UDPPacket struct {
|
type UDPPacket struct {
|
||||||
Content string `json:"c,omitempty"`
|
Content []byte `json:"c,omitempty"`
|
||||||
LocalAddr *net.UDPAddr `json:"l,omitempty"`
|
LocalAddr *net.UDPAddr `json:"l,omitempty"`
|
||||||
RemoteAddr *net.UDPAddr `json:"r,omitempty"`
|
RemoteAddr *net.UDPAddr `json:"r,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
@@ -16,9 +16,8 @@ func StripUserPrefix(user, name string) string {
|
|||||||
if user == "" {
|
if user == "" {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
prefix := user + "."
|
if trimmed, ok := strings.CutPrefix(name, user+"."); ok {
|
||||||
if strings.HasPrefix(name, prefix) {
|
return trimmed
|
||||||
return strings.TrimPrefix(name, prefix)
|
|
||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
"k8s.io/utils/clock"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -144,19 +145,19 @@ func getBehaviorByModeAndIndex(mode int, index int) (RecommandBehavior, Recomman
|
|||||||
return behaviors[index].A, behaviors[index].B
|
return behaviors[index].A, behaviors[index].B
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBehaviorScoresByMode(mode int, defaultScore int) []*BehaviorScore {
|
func getBehaviorScoresByMode(mode int, defaultScore int) []*behaviorScore {
|
||||||
return getBehaviorScoresByMode2(mode, defaultScore, defaultScore)
|
return getBehaviorScoresByMode2(mode, defaultScore, defaultScore)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*BehaviorScore {
|
func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*behaviorScore {
|
||||||
behaviors := getBehaviorByMode(mode)
|
behaviors := getBehaviorByMode(mode)
|
||||||
scores := make([]*BehaviorScore, 0, len(behaviors))
|
scores := make([]*behaviorScore, 0, len(behaviors))
|
||||||
for i := 0; i < len(behaviors); i++ {
|
for i := range behaviors {
|
||||||
score := receiverScore
|
score := receiverScore
|
||||||
if behaviors[i].A.Role == DetectRoleSender {
|
if behaviors[i].A.Role == DetectRoleSender {
|
||||||
score = senderScore
|
score = senderScore
|
||||||
}
|
}
|
||||||
scores = append(scores, &BehaviorScore{Mode: mode, Index: i, Score: score})
|
scores = append(scores, &behaviorScore{Mode: mode, Index: i, Score: score})
|
||||||
}
|
}
|
||||||
return scores
|
return scores
|
||||||
}
|
}
|
||||||
@@ -170,14 +171,18 @@ type RecommandBehavior struct {
|
|||||||
ListenRandomPorts int
|
ListenRandomPorts int
|
||||||
}
|
}
|
||||||
|
|
||||||
type MakeHoleRecords struct {
|
type makeHoleRecords struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
scores []*BehaviorScore
|
scores []*behaviorScore
|
||||||
LastUpdateTime time.Time
|
clock clock.PassiveClock
|
||||||
|
lastUpdateTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {
|
func newMakeHoleRecordsWithClock(c, v *NatFeature, clk clock.PassiveClock) *makeHoleRecords {
|
||||||
scores := []*BehaviorScore{}
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
|
scores := []*behaviorScore{}
|
||||||
easyCount, hardCount, portsChangedRegularCount := ClassifyFeatureCount([]*NatFeature{c, v})
|
easyCount, hardCount, portsChangedRegularCount := ClassifyFeatureCount([]*NatFeature{c, v})
|
||||||
appendMode0 := func() {
|
appendMode0 := func() {
|
||||||
switch {
|
switch {
|
||||||
@@ -212,13 +217,17 @@ func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {
|
|||||||
scores = append(scores, getBehaviorScoresByMode(DetectMode1, 1)...)
|
scores = append(scores, getBehaviorScoresByMode(DetectMode1, 1)...)
|
||||||
scores = append(scores, getBehaviorScoresByMode(DetectMode3, 1)...)
|
scores = append(scores, getBehaviorScoresByMode(DetectMode3, 1)...)
|
||||||
}
|
}
|
||||||
return &MakeHoleRecords{scores: scores, LastUpdateTime: time.Now()}
|
return &makeHoleRecords{
|
||||||
|
scores: scores,
|
||||||
|
clock: clk,
|
||||||
|
lastUpdateTime: clk.Now(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mhr *MakeHoleRecords) ReportSuccess(mode int, index int) {
|
func (mhr *makeHoleRecords) reportSuccess(mode int, index int) {
|
||||||
mhr.mu.Lock()
|
mhr.mu.Lock()
|
||||||
defer mhr.mu.Unlock()
|
defer mhr.mu.Unlock()
|
||||||
mhr.LastUpdateTime = time.Now()
|
mhr.lastUpdateTime = mhr.clock.Now()
|
||||||
for i := range mhr.scores {
|
for i := range mhr.scores {
|
||||||
score := mhr.scores[i]
|
score := mhr.scores[i]
|
||||||
if score.Mode != mode || score.Index != index {
|
if score.Mode != mode || score.Index != index {
|
||||||
@@ -231,22 +240,22 @@ func (mhr *MakeHoleRecords) ReportSuccess(mode int, index int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mhr *MakeHoleRecords) Recommand() (mode, index int) {
|
func (mhr *makeHoleRecords) recommand() (mode, index int) {
|
||||||
mhr.mu.Lock()
|
mhr.mu.Lock()
|
||||||
defer mhr.mu.Unlock()
|
defer mhr.mu.Unlock()
|
||||||
|
|
||||||
if len(mhr.scores) == 0 {
|
if len(mhr.scores) == 0 {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
maxScore := slices.MaxFunc(mhr.scores, func(a, b *BehaviorScore) int {
|
maxScore := slices.MaxFunc(mhr.scores, func(a, b *behaviorScore) int {
|
||||||
return cmp.Compare(a.Score, b.Score)
|
return cmp.Compare(a.Score, b.Score)
|
||||||
})
|
})
|
||||||
maxScore.Score--
|
maxScore.Score--
|
||||||
mhr.LastUpdateTime = time.Now()
|
mhr.lastUpdateTime = mhr.clock.Now()
|
||||||
return maxScore.Mode, maxScore.Index
|
return maxScore.Mode, maxScore.Index
|
||||||
}
|
}
|
||||||
|
|
||||||
type BehaviorScore struct {
|
type behaviorScore struct {
|
||||||
Mode int
|
Mode int
|
||||||
Index int
|
Index int
|
||||||
// between -10 and 10
|
// between -10 and 10
|
||||||
@@ -255,16 +264,25 @@ type BehaviorScore struct {
|
|||||||
|
|
||||||
type Analyzer struct {
|
type Analyzer struct {
|
||||||
// key is client ip + visitor ip
|
// key is client ip + visitor ip
|
||||||
records map[string]*MakeHoleRecords
|
records map[string]*makeHoleRecords
|
||||||
dataReserveDuration time.Duration
|
dataReserveDuration time.Duration
|
||||||
|
clock clock.PassiveClock
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAnalyzer(dataReserveDuration time.Duration) *Analyzer {
|
func NewAnalyzer(dataReserveDuration time.Duration) *Analyzer {
|
||||||
|
return newAnalyzerWithClock(dataReserveDuration, clock.RealClock{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAnalyzerWithClock(dataReserveDuration time.Duration, clk clock.PassiveClock) *Analyzer {
|
||||||
|
if clk == nil {
|
||||||
|
clk = clock.RealClock{}
|
||||||
|
}
|
||||||
return &Analyzer{
|
return &Analyzer{
|
||||||
records: make(map[string]*MakeHoleRecords),
|
records: make(map[string]*makeHoleRecords),
|
||||||
dataReserveDuration: dataReserveDuration,
|
dataReserveDuration: dataReserveDuration,
|
||||||
|
clock: clk,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,12 +290,12 @@ func (a *Analyzer) GetRecommandBehaviors(key string, c, v *NatFeature) (mode, in
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
records, ok := a.records[key]
|
records, ok := a.records[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
records = NewMakeHoleRecords(c, v)
|
records = newMakeHoleRecordsWithClock(c, v, a.clock)
|
||||||
a.records[key] = records
|
a.records[key] = records
|
||||||
}
|
}
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
mode, index = records.Recommand()
|
mode, index = records.recommand()
|
||||||
cBehavior, vBehavior := getBehaviorByModeAndIndex(mode, index)
|
cBehavior, vBehavior := getBehaviorByModeAndIndex(mode, index)
|
||||||
|
|
||||||
switch mode {
|
switch mode {
|
||||||
@@ -307,11 +325,11 @@ func (a *Analyzer) ReportSuccess(key string, mode, index int) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
records.ReportSuccess(mode, index)
|
records.reportSuccess(mode, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Analyzer) Clean() (int, int) {
|
func (a *Analyzer) Clean() (int, int) {
|
||||||
now := time.Now()
|
now := a.clock.Now()
|
||||||
total := 0
|
total := 0
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
@@ -321,7 +339,7 @@ func (a *Analyzer) Clean() (int, int) {
|
|||||||
total = len(a.records)
|
total = len(a.records)
|
||||||
// clean up records that have not been used for a period of time.
|
// clean up records that have not been used for a period of time.
|
||||||
for key, records := range a.records {
|
for key, records := range a.records {
|
||||||
if now.Sub(records.LastUpdateTime) > a.dataReserveDuration {
|
if now.Sub(records.lastUpdateTime) > a.dataReserveDuration {
|
||||||
delete(a.records, key)
|
delete(a.records, key)
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -70,12 +70,8 @@ func ClassifyNATFeature(addresses []string, localIPs []string) (*NatFeature, err
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if portNum > portMax {
|
portMax = max(portMax, portNum)
|
||||||
portMax = portNum
|
portMin = min(portMin, portNum)
|
||||||
}
|
|
||||||
if portNum < portMin {
|
|
||||||
portMin = portNum
|
|
||||||
}
|
|
||||||
if baseIP != ip {
|
if baseIP != ip {
|
||||||
ipChanged = true
|
ipChanged = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,9 @@ func (c *Controller) GenSid() string {
|
|||||||
|
|
||||||
func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.MessageTransporter, visitorUser string) {
|
func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.MessageTransporter, visitorUser string) {
|
||||||
if m.PreCheck {
|
if m.PreCheck {
|
||||||
|
c.mu.RLock()
|
||||||
cfg, ok := c.clientCfgs[m.ProxyName]
|
cfg, ok := c.clientCfgs[m.ProxyName]
|
||||||
|
c.mu.RUnlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
_ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, fmt.Sprintf("xtcp server for [%s] doesn't exist", m.ProxyName)))
|
_ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, fmt.Sprintf("xtcp server for [%s] doesn't exist", m.ProxyName)))
|
||||||
return
|
return
|
||||||
@@ -324,40 +326,16 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol := vm.Protocol
|
protocol := vm.Protocol
|
||||||
vResp := &msg.NatHoleResp{
|
vResp := newNatHoleResponse(
|
||||||
TransactionID: vm.TransactionID,
|
vm.TransactionID, session.sid, protocol, mode,
|
||||||
Sid: session.sid,
|
cm.MappedAddrs, cm.AssistedAddrs, vBehavior,
|
||||||
Protocol: protocol,
|
timeoutMs-vBehavior.SendDelayMs, cNatFeature.PortsDifference,
|
||||||
CandidateAddrs: slices.Compact(cm.MappedAddrs),
|
)
|
||||||
AssistedAddrs: slices.Compact(cm.AssistedAddrs),
|
cResp := newNatHoleResponse(
|
||||||
DetectBehavior: msg.NatHoleDetectBehavior{
|
cm.TransactionID, session.sid, protocol, mode,
|
||||||
Mode: mode,
|
vm.MappedAddrs, vm.AssistedAddrs, cBehavior,
|
||||||
Role: vBehavior.Role,
|
timeoutMs-cBehavior.SendDelayMs, vNatFeature.PortsDifference,
|
||||||
TTL: vBehavior.TTL,
|
)
|
||||||
SendDelayMs: vBehavior.SendDelayMs,
|
|
||||||
ReadTimeoutMs: timeoutMs - vBehavior.SendDelayMs,
|
|
||||||
SendRandomPorts: vBehavior.PortsRandomNumber,
|
|
||||||
ListenRandomPorts: vBehavior.ListenRandomPorts,
|
|
||||||
CandidatePorts: getRangePorts(cm.MappedAddrs, cNatFeature.PortsDifference, vBehavior.PortsRangeNumber),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cResp := &msg.NatHoleResp{
|
|
||||||
TransactionID: cm.TransactionID,
|
|
||||||
Sid: session.sid,
|
|
||||||
Protocol: protocol,
|
|
||||||
CandidateAddrs: slices.Compact(vm.MappedAddrs),
|
|
||||||
AssistedAddrs: slices.Compact(vm.AssistedAddrs),
|
|
||||||
DetectBehavior: msg.NatHoleDetectBehavior{
|
|
||||||
Mode: mode,
|
|
||||||
Role: cBehavior.Role,
|
|
||||||
TTL: cBehavior.TTL,
|
|
||||||
SendDelayMs: cBehavior.SendDelayMs,
|
|
||||||
ReadTimeoutMs: timeoutMs - cBehavior.SendDelayMs,
|
|
||||||
SendRandomPorts: cBehavior.PortsRandomNumber,
|
|
||||||
ListenRandomPorts: cBehavior.ListenRandomPorts,
|
|
||||||
CandidatePorts: getRangePorts(vm.MappedAddrs, vNatFeature.PortsDifference, cBehavior.PortsRangeNumber),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("sid [%s] visitor nat: %+v, candidateAddrs: %v; client nat: %+v, candidateAddrs: %v, protocol: %s",
|
log.Debugf("sid [%s] visitor nat: %+v, candidateAddrs: %v; client nat: %+v, candidateAddrs: %v, protocol: %s",
|
||||||
session.sid, *vNatFeature, vm.MappedAddrs, *cNatFeature, cm.MappedAddrs, protocol)
|
session.sid, *vNatFeature, vm.MappedAddrs, *cNatFeature, cm.MappedAddrs, protocol)
|
||||||
@@ -366,6 +344,38 @@ func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleR
|
|||||||
return vResp, cResp, nil
|
return vResp, cResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newNatHoleResponse(
|
||||||
|
transactionID string,
|
||||||
|
sid string,
|
||||||
|
protocol string,
|
||||||
|
mode int,
|
||||||
|
candidateAddrs []string,
|
||||||
|
assistedAddrs []string,
|
||||||
|
behavior RecommandBehavior,
|
||||||
|
readTimeoutMs int,
|
||||||
|
portsDifference int,
|
||||||
|
) *msg.NatHoleResp {
|
||||||
|
compactCandidateAddrs := slices.Compact(candidateAddrs)
|
||||||
|
compactAssistedAddrs := slices.Compact(assistedAddrs)
|
||||||
|
return &msg.NatHoleResp{
|
||||||
|
TransactionID: transactionID,
|
||||||
|
Sid: sid,
|
||||||
|
Protocol: protocol,
|
||||||
|
CandidateAddrs: compactCandidateAddrs,
|
||||||
|
AssistedAddrs: compactAssistedAddrs,
|
||||||
|
DetectBehavior: msg.NatHoleDetectBehavior{
|
||||||
|
Mode: mode,
|
||||||
|
Role: behavior.Role,
|
||||||
|
TTL: behavior.TTL,
|
||||||
|
SendDelayMs: behavior.SendDelayMs,
|
||||||
|
ReadTimeoutMs: readTimeoutMs,
|
||||||
|
SendRandomPorts: behavior.PortsRandomNumber,
|
||||||
|
ListenRandomPorts: behavior.ListenRandomPorts,
|
||||||
|
CandidatePorts: getRangePorts(candidateAddrs, portsDifference, behavior.PortsRangeNumber),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
|
||||||
if maxNumber <= 0 {
|
if maxNumber <= 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pion/stun/v2"
|
"github.com/pion/stun/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var responseTimeout = 3 * time.Second
|
var responseTimeout = 3 * time.Second
|
||||||
|
|||||||
@@ -298,11 +298,13 @@ func waitDetectMessage(
|
|||||||
n, raddr, err := conn.ReadFromUDP(buf)
|
n, raddr, err := conn.ReadFromUDP(buf)
|
||||||
_ = conn.SetReadDeadline(time.Time{})
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
pool.PutBuf(buf)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
xl.Debugf("get udp message local %s, from %s", conn.LocalAddr(), raddr)
|
xl.Debugf("get udp message local %s, from %s", conn.LocalAddr(), raddr)
|
||||||
var m msg.NatHoleSid
|
var m msg.NatHoleSid
|
||||||
if err := DecodeMessageInto(buf[:n], key, &m); err != nil {
|
if err := DecodeMessageInto(buf[:n], key, &m); err != nil {
|
||||||
|
pool.PutBuf(buf)
|
||||||
xl.Warnf("decode sid message error: %v", err)
|
xl.Warnf("decode sid message error: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -408,7 +410,7 @@ func sendSidMessageToRandomPorts(
|
|||||||
xl := xlog.FromContextSafe(ctx)
|
xl := xlog.FromContextSafe(ctx)
|
||||||
used := sets.New[int]()
|
used := sets.New[int]()
|
||||||
getUnusedPort := func() int {
|
getUnusedPort := func() int {
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
port := rand.IntN(65535-1024) + 1024
|
port := rand.IntN(65535-1024) + 1024
|
||||||
if !used.Has(port) {
|
if !used.Has(port) {
|
||||||
used.Insert(port)
|
used.Insert(port)
|
||||||
@@ -418,7 +420,7 @@ func sendSidMessageToRandomPorts(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < count; i++ {
|
for range count {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/fatedier/golib/crypto"
|
"github.com/fatedier/golib/crypto"
|
||||||
"github.com/pion/stun/v2"
|
"github.com/pion/stun/v3"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/msg"
|
"github.com/fatedier/frp/pkg/msg"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,16 +17,9 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
stdlog "log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
|
||||||
"github.com/fatedier/golib/pool"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -36,57 +29,28 @@ func init() {
|
|||||||
type HTTP2HTTPPlugin struct {
|
type HTTP2HTTPPlugin struct {
|
||||||
opts *v1.HTTP2HTTPPluginOptions
|
opts *v1.HTTP2HTTPPluginOptions
|
||||||
|
|
||||||
l *Listener
|
*httpBridgePlugin
|
||||||
s *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTP2HTTPPluginOptions)
|
opts := options.(*v1.HTTP2HTTPPluginOptions)
|
||||||
|
|
||||||
listener := NewProxyListener()
|
|
||||||
|
|
||||||
p := &HTTP2HTTPPlugin{
|
p := &HTTP2HTTPPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
l: listener,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rp := &httputil.ReverseProxy{
|
rp := newHTTPBridgeReverseProxy(
|
||||||
Rewrite: func(r *httputil.ProxyRequest) {
|
func(r *httputil.ProxyRequest) {
|
||||||
req := r.Out
|
req := r.Out
|
||||||
req.URL.Scheme = "http"
|
rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
|
||||||
req.URL.Host = p.opts.LocalAddr
|
|
||||||
if p.opts.HostHeaderRewrite != "" {
|
|
||||||
req.Host = p.opts.HostHeaderRewrite
|
|
||||||
}
|
|
||||||
for k, v := range p.opts.RequestHeaders.Set {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
BufferPool: pool.NewBuffer(32 * 1024),
|
nil,
|
||||||
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
|
)
|
||||||
}
|
p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: rp,
|
|
||||||
ReadHeaderTimeout: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.Serve(listener)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTP2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPPlugin) Name() string {
|
func (p *HTTP2HTTPPlugin) Name() string {
|
||||||
return v1.PluginHTTP2HTTP
|
return v1.PluginHTTP2HTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTP2HTTPPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,17 +17,11 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
stdlog "log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
|
||||||
"github.com/fatedier/golib/pool"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -37,65 +31,35 @@ func init() {
|
|||||||
type HTTP2HTTPSPlugin struct {
|
type HTTP2HTTPSPlugin struct {
|
||||||
opts *v1.HTTP2HTTPSPluginOptions
|
opts *v1.HTTP2HTTPSPluginOptions
|
||||||
|
|
||||||
l *Listener
|
*httpBridgePlugin
|
||||||
s *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTP2HTTPSPluginOptions)
|
opts := options.(*v1.HTTP2HTTPSPluginOptions)
|
||||||
|
|
||||||
listener := NewProxyListener()
|
|
||||||
|
|
||||||
p := &HTTP2HTTPSPlugin{
|
p := &HTTP2HTTPSPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
l: listener,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
rp := &httputil.ReverseProxy{
|
rp := newHTTPBridgeReverseProxy(
|
||||||
Rewrite: func(r *httputil.ProxyRequest) {
|
func(r *httputil.ProxyRequest) {
|
||||||
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
||||||
r.Out.Header["X-Forwarded-Host"] = r.In.Header["X-Forwarded-Host"]
|
r.Out.Header["X-Forwarded-Host"] = r.In.Header["X-Forwarded-Host"]
|
||||||
r.Out.Header["X-Forwarded-Proto"] = r.In.Header["X-Forwarded-Proto"]
|
r.Out.Header["X-Forwarded-Proto"] = r.In.Header["X-Forwarded-Proto"]
|
||||||
req := r.Out
|
req := r.Out
|
||||||
req.URL.Scheme = "https"
|
rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
|
||||||
req.URL.Host = p.opts.LocalAddr
|
|
||||||
if p.opts.HostHeaderRewrite != "" {
|
|
||||||
req.Host = p.opts.HostHeaderRewrite
|
|
||||||
}
|
|
||||||
for k, v := range p.opts.RequestHeaders.Set {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Transport: tr,
|
tr,
|
||||||
BufferPool: pool.NewBuffer(32 * 1024),
|
)
|
||||||
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
|
p.httpBridgePlugin = newHTTPBridgePluginServer(rp, false)
|
||||||
}
|
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: rp,
|
|
||||||
ReadHeaderTimeout: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.Serve(listener)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTP2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTP2HTTPSPlugin) Name() string {
|
func (p *HTTP2HTTPSPlugin) Name() string {
|
||||||
return v1.PluginHTTP2HTTPS
|
return v1.PluginHTTP2HTTPS
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTP2HTTPSPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
s *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const httpProxyReadHeaderTimeout = 60 * time.Second
|
||||||
|
|
||||||
func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPProxyPluginOptions)
|
opts := options.(*v1.HTTPProxyPluginOptions)
|
||||||
listener := NewProxyListener()
|
listener := NewProxyListener()
|
||||||
@@ -56,7 +58,7 @@ func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin
|
|||||||
|
|
||||||
hp.s = &http.Server{
|
hp.s = &http.Server{
|
||||||
Handler: hp,
|
Handler: hp,
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
ReadHeaderTimeout: httpProxyReadHeaderTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -73,16 +75,19 @@ func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
||||||
|
|
||||||
sc, rd := libnet.NewSharedConn(wrapConn)
|
sc, rd := libnet.NewSharedConn(wrapConn)
|
||||||
firstBytes := make([]byte, 7)
|
firstBytes := make([]byte, len(http.MethodConnect))
|
||||||
_, err := rd.Read(firstBytes)
|
_ = wrapConn.SetReadDeadline(time.Now().Add(httpProxyReadHeaderTimeout))
|
||||||
|
_, err := io.ReadFull(rd, firstBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = wrapConn.SetReadDeadline(time.Time{})
|
||||||
wrapConn.Close()
|
wrapConn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.ToUpper(string(firstBytes)) == "CONNECT" {
|
if strings.EqualFold(string(firstBytes), http.MethodConnect) {
|
||||||
bufRd := bufio.NewReader(sc)
|
bufRd := bufio.NewReader(sc)
|
||||||
request, err := http.ReadRequest(bufRd)
|
request, err := http.ReadRequest(bufRd)
|
||||||
|
_ = wrapConn.SetReadDeadline(time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wrapConn.Close()
|
wrapConn.Close()
|
||||||
return
|
return
|
||||||
@@ -91,6 +96,7 @@ func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = wrapConn.SetReadDeadline(time.Time{})
|
||||||
_ = hp.l.PutConn(sc)
|
_ = hp.l.PutConn(sc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,13 +113,7 @@ func (hp *HTTPProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Method == http.MethodConnect {
|
hp.HTTPHandler(rw, req)
|
||||||
// deprecated
|
|
||||||
// Connect request is handled in Handle function.
|
|
||||||
hp.ConnectHandler(rw, req)
|
|
||||||
} else {
|
|
||||||
hp.HTTPHandler(rw, req)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hp *HTTPProxy) HTTPHandler(rw http.ResponseWriter, req *http.Request) {
|
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 {
|
func (hp *HTTPProxy) Auth(req *http.Request) bool {
|
||||||
if hp.opts.HTTPUser == "" && hp.opts.HTTPPassword == "" {
|
if hp.opts.HTTPUser == "" && hp.opts.HTTPPassword == "" {
|
||||||
return true
|
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
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
stdlog "log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fatedier/golib/pool"
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -42,80 +29,35 @@ func init() {
|
|||||||
type HTTPS2HTTPPlugin struct {
|
type HTTPS2HTTPPlugin struct {
|
||||||
opts *v1.HTTPS2HTTPPluginOptions
|
opts *v1.HTTPS2HTTPPluginOptions
|
||||||
|
|
||||||
l *Listener
|
*httpBridgePlugin
|
||||||
s *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPPluginOptions)
|
||||||
listener := NewProxyListener()
|
|
||||||
|
|
||||||
p := &HTTPS2HTTPPlugin{
|
p := &HTTPS2HTTPPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
l: listener,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rp := &httputil.ReverseProxy{
|
rp := newHTTPBridgeReverseProxy(
|
||||||
Rewrite: func(r *httputil.ProxyRequest) {
|
func(r *httputil.ProxyRequest) {
|
||||||
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
||||||
r.SetXForwarded()
|
r.SetXForwarded()
|
||||||
req := r.Out
|
req := r.Out
|
||||||
req.URL.Scheme = "http"
|
rewriteHTTPPluginRequest(req, "http", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
|
||||||
req.URL.Host = p.opts.LocalAddr
|
|
||||||
if p.opts.HostHeaderRewrite != "" {
|
|
||||||
req.Host = p.opts.HostHeaderRewrite
|
|
||||||
}
|
|
||||||
for k, v := range p.opts.RequestHeaders.Set {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
BufferPool: pool.NewBuffer(32 * 1024),
|
nil,
|
||||||
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
|
)
|
||||||
}
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.TLS != nil {
|
|
||||||
tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
|
|
||||||
host, _ := httppkg.CanonicalHost(r.Host)
|
|
||||||
if tlsServerName != "" && tlsServerName != host {
|
|
||||||
w.WriteHeader(http.StatusMisdirectedRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rp.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.httpBridgePlugin = server
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: handler,
|
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
|
||||||
TLSConfig: tlsConfig,
|
|
||||||
}
|
|
||||||
if !lo.FromPtr(opts.EnableHTTP2) {
|
|
||||||
p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.ServeTLS(listener, "", "")
|
|
||||||
}()
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPS2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
if connInfo.SrcAddr != nil {
|
|
||||||
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
|
|
||||||
}
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPS2HTTPPlugin) Name() string {
|
func (p *HTTPS2HTTPPlugin) Name() string {
|
||||||
return v1.PluginHTTPS2HTTP
|
return v1.PluginHTTPS2HTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPS2HTTPPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,22 +17,11 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
|
||||||
stdlog "log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fatedier/golib/pool"
|
|
||||||
"github.com/samber/lo"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -42,86 +31,39 @@ func init() {
|
|||||||
type HTTPS2HTTPSPlugin struct {
|
type HTTPS2HTTPSPlugin struct {
|
||||||
opts *v1.HTTPS2HTTPSPluginOptions
|
opts *v1.HTTPS2HTTPSPluginOptions
|
||||||
|
|
||||||
l *Listener
|
*httpBridgePlugin
|
||||||
s *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {
|
||||||
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
opts := options.(*v1.HTTPS2HTTPSPluginOptions)
|
||||||
|
|
||||||
listener := NewProxyListener()
|
|
||||||
|
|
||||||
p := &HTTPS2HTTPSPlugin{
|
p := &HTTPS2HTTPSPlugin{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
l: listener,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
rp := &httputil.ReverseProxy{
|
rp := newHTTPBridgeReverseProxy(
|
||||||
Rewrite: func(r *httputil.ProxyRequest) {
|
func(r *httputil.ProxyRequest) {
|
||||||
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
||||||
r.SetXForwarded()
|
r.SetXForwarded()
|
||||||
req := r.Out
|
req := r.Out
|
||||||
req.URL.Scheme = "https"
|
rewriteHTTPPluginRequest(req, "https", p.opts.LocalAddr, p.opts.HostHeaderRewrite, p.opts.RequestHeaders)
|
||||||
req.URL.Host = p.opts.LocalAddr
|
|
||||||
if p.opts.HostHeaderRewrite != "" {
|
|
||||||
req.Host = p.opts.HostHeaderRewrite
|
|
||||||
}
|
|
||||||
for k, v := range p.opts.RequestHeaders.Set {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Transport: tr,
|
tr,
|
||||||
BufferPool: pool.NewBuffer(32 * 1024),
|
)
|
||||||
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
|
|
||||||
}
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.TLS != nil {
|
|
||||||
tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)
|
|
||||||
host, _ := httppkg.CanonicalHost(r.Host)
|
|
||||||
if tlsServerName != "" && tlsServerName != host {
|
|
||||||
w.WriteHeader(http.StatusMisdirectedRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rp.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "")
|
server, err := newHTTPSBridgePluginServer(rp, p.opts.CrtPath, p.opts.KeyPath, opts.EnableHTTP2, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("gen TLS config error: %v", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.httpBridgePlugin = server
|
||||||
|
|
||||||
p.s = &http.Server{
|
|
||||||
Handler: handler,
|
|
||||||
ReadHeaderTimeout: 60 * time.Second,
|
|
||||||
TLSConfig: tlsConfig,
|
|
||||||
}
|
|
||||||
if !lo.FromPtr(opts.EnableHTTP2) {
|
|
||||||
p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = p.s.ServeTLS(listener, "", "")
|
|
||||||
}()
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPS2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {
|
|
||||||
wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)
|
|
||||||
if connInfo.SrcAddr != nil {
|
|
||||||
wrapConn.SetRemoteAddr(connInfo.SrcAddr)
|
|
||||||
}
|
|
||||||
_ = p.l.PutConn(wrapConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPS2HTTPSPlugin) Name() string {
|
func (p *HTTPS2HTTPSPlugin) Name() string {
|
||||||
return v1.PluginHTTPS2HTTPS
|
return v1.PluginHTTPS2HTTPS
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPS2HTTPSPlugin) Close() error {
|
|
||||||
return p.s.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -62,14 +62,25 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
|||||||
|
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
xl.Warnf("tls handshake error: %v", err)
|
xl.Warnf("tls handshake error: %v", err)
|
||||||
|
tlsConn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rawConn, err := net.Dial("tcp", p.opts.LocalAddr)
|
rawConn, err := net.Dial("tcp", p.opts.LocalAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xl.Warnf("dial to local addr error: %v", err)
|
xl.Warnf("dial to local addr error: %v", err)
|
||||||
|
tlsConn.Close()
|
||||||
return
|
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)
|
libio.Join(tlsConn, rawConn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user